├── smartapps └── kirkbrownok │ ├── sensithermostat │ ├── sensi-thermostat-keypad-lockout-on-open-sensors.src │ │ └── sensi-thermostat-keypad-lockout-on-open-sensors.groovy │ └── sensi-connect.src │ │ └── sensi-connect.groovy │ ├── camera-status-monitor.src │ └── camera-status-monitor.groovy │ ├── minimote-sensor-demuxing.src │ └── minimote-sensor-demuxing.groovy │ ├── arduino-temperature-sensor-de-mux.src │ └── arduino-temperature-sensor-de-mux.groovy │ ├── lock-it-with-virtual-light.src │ └── lock-it-with-virtual-light.groovy │ ├── lights-from-from-an-arduino.src │ └── lights-from-from-an-arduino.groovy │ ├── orvibo-s20-connect.src │ └── orvibo-s20-connect.groovy │ ├── sendsoundstoarduinospeaker │ └── send-sounds-to-arduino-speaker.src │ │ └── send-sounds-to-arduino-speaker.groovy │ ├── community-rachio-connect.src │ └── community-rachio-connect.groovy │ └── bmw-connected │ └── bmw-connected-drive-i3-connect.src │ └── bmw-connected-drive-i3-connect.groovy ├── README.md └── devicetypes └── kirkbrownok └── sensithermostat └── sensi-thermostat.src └── sensi-thermostat.groovy /smartapps/kirkbrownok/sensithermostat/sensi-thermostat-keypad-lockout-on-open-sensors.src/sensi-thermostat-keypad-lockout-on-open-sensors.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Kirk Brown 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at: 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 11 | * for the specific language governing permissions and limitations under the License. 12 | * 13 | * engage keypad lockout when windows/doors open 14 | * 15 | * Author: kirk brown 16 | * Date: 2017-7-30 17 | */ 18 | definition( 19 | name: "Sensi Thermostat Keypad Lockout on Open Sensors", 20 | namespace: "kirkbrownOK/SensiThermostat", 21 | author: "Kirk Brown", 22 | description: "When contact sensors open then lock thermostat keypad", 23 | category: "Convenience", 24 | iconUrl: "http://i.imgur.com/QVbsCpu.jpg", 25 | iconX2Url: "http://i.imgur.com/4BfQn6I.jpg", 26 | ) 27 | 28 | preferences { 29 | 30 | section("Monitor these contact sensors") { 31 | input "contact", "capability.contactSensor", multiple: true 32 | } 33 | 34 | section("Disable the keypad on which thermostats?") { 35 | input "thermostat", "capability.thermostat", description: "Select which thermostat to lock the keypad", required: false, multiple: true 36 | } 37 | } 38 | 39 | def installed() { 40 | log.trace "installed()" 41 | subscribe() 42 | } 43 | 44 | def updated() { 45 | log.trace "updated()" 46 | unsubscribe() 47 | subscribe() 48 | } 49 | 50 | def subscribe() { 51 | subscribe(contact, "contact.open", doorOpen) 52 | subscribe(contact, "contact.closed", doorClosed) 53 | } 54 | 55 | def doorOpen(evt) { 56 | log.trace "doorOpen($evt.name: $evt.value)" 57 | thermostat.setKeypadLockoutOn() 58 | 59 | } 60 | 61 | def doorClosed(evt) { 62 | log.trace "doorClosed($evt.name: $evt.value)" 63 | def anySensorStillOpen = false 64 | contact.each { 65 | log.trace "contact: ${it.currentContact}" 66 | if( it.currentContact != "closed") { 67 | anySensorStillOpen = true 68 | } 69 | } 70 | if( !anySensorStillOpen) { 71 | thermostat.setKeypadLockoutOff() 72 | } 73 | 74 | } 75 | 76 | -------------------------------------------------------------------------------- /smartapps/kirkbrownok/camera-status-monitor.src/camera-status-monitor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Camera Status Monitor 3 | * 4 | * Copyright 2016 Kirk Brown 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | definition( 17 | name: "Camera Status Monitor", 18 | namespace: "kirkbrownOK", 19 | author: "Kirk Brown", 20 | description: "This app monitors the status of a camera device. When the camera device reports the DVR is no longer responsive, it will send a MAKER command to IFTTT to restart the WEMO switch the device is connected to. ", 21 | category: "Convenience", 22 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 23 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", 24 | iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png") 25 | 26 | 27 | preferences { 28 | section("Which Camera Device:") { 29 | input "camera", "capability.switch", multiple: false, required: true 30 | } 31 | section("Maker Key:") { 32 | input "maker_key", "string", defaultValue: "sG20x6jo8kFWwxUsBVqVX", required: true 33 | 34 | } 35 | section("Which Wemo outlet:") { 36 | input "cameraOutlet", "capability.switch", multiple: false, required: true 37 | } 38 | } 39 | 40 | def installed() { 41 | log.debug "Installed with settings: ${settings}" 42 | 43 | initialize() 44 | } 45 | 46 | def updated() { 47 | log.debug "Updated with settings: ${settings}" 48 | 49 | unsubscribe() 50 | initialize() 51 | } 52 | 53 | def initialize() { 54 | subscribe(camera, "switch.off", resetOutlet) 55 | subscribe(cameraOutlet, "switch.off", scheduleOn) 56 | 57 | // TODO: subscribe to attributes, devices, locations, etc. 58 | } 59 | def resetOutlet(evt) { 60 | log.debug "Resetting Outlet" 61 | cameraOutlet.reset() 62 | camera.beenReset() 63 | } 64 | 65 | def scheduleOn(evt) { 66 | log.debug "outlet is off scheduling on" 67 | cameraOutlet.on() 68 | log.debug "Outlet turned on" 69 | if (cameraOutlet.currentValue('switch') == "off") { 70 | 71 | runIn(60,scheduleOn) 72 | } 73 | } 74 | /* 75 | def resetOutlet(evt) { 76 | log.debug "Resetting Outlet" 77 | def params = [ 78 | uri: "https://maker.ifttt.com", 79 | path: "/trigger/reset/with/key/${maker_key}" 80 | ] 81 | log.debug "${params}" 82 | try { 83 | httpGet(params) { resp -> 84 | resp.headers.each { 85 | log.debug "${it.name} : ${it.value}" 86 | } 87 | log.debug "response contentType: ${resp.contentType}" 88 | log.debug "response data: ${resp.data}" 89 | } 90 | } catch (e) { 91 | log.error "something went wrong: $e" 92 | return 93 | } 94 | camera.beenReset() 95 | } 96 | */ 97 | //https://maker.ifttt.com/trigger/{event}/with/key/sG20x6jo8kFWwxUsBVqVX 98 | // TODO: implement event handlers -------------------------------------------------------------------------------- /smartapps/kirkbrownok/minimote-sensor-demuxing.src/minimote-sensor-demuxing.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Kirk Brown 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at: 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 11 | * for the specific language governing permissions and limitations under the License. 12 | * 13 | * Minimote Sensor DEMUXING 14 | * 15 | * Author: Kirk Brown 16 | * 17 | * Date: 2015-10-1 18 | */ 19 | definition( 20 | name: "MiniMote Sensor DEMUXING", 21 | namespace: "kirkbrownOK", 22 | author: "Kirk Brown", 23 | description: "Takes minimote buttons and maps them to individual button tiles", 24 | category: "Convenience", 25 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet.png", 26 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet@2x.png" 27 | 28 | 29 | ) 30 | 31 | 32 | preferences { 33 | section("When this sensor has events: (MUX-ed button input)") { 34 | input "master", "capability.button", title: "Which minimote?" 35 | } 36 | section("Button 1 controls this virtual button:") { 37 | input "button1", "capability.button", multiple: false, required: false 38 | } 39 | section("Button 2 controls this virtual sensor:") { 40 | input "button2", "capability.button", multiple: false, required: false 41 | } 42 | section("Button 3 controls this virtual sensor") { 43 | input "button3", "capability.button", multiple: false, required: false 44 | } 45 | section("Button 4 controls this virtual sensor") { 46 | input "button4", "capability.button", multiple: false, required: false 47 | } 48 | } 49 | 50 | def installed() 51 | { 52 | subscribeToDevices() 53 | 54 | } 55 | 56 | def updated() 57 | { 58 | unsubscribe() 59 | subscribeToDevices() 60 | } 61 | def subscribeToDevices() { 62 | log.debug "Subscribing to devices" 63 | subscribe(master, "button", buttonParser) 64 | log.debug "Subscribed to ${master}" 65 | /* 66 | if(button1) { 67 | log.debug "Subscribed to ${button1}" 68 | subscribe(button1, "button.pushed", button1pushed) 69 | subscribe(button1, "button.held", button1held) 70 | 71 | } 72 | if(button2) { 73 | log.debug "subscribed to ${button2}" 74 | subscribe(button2, "button.pushed", button2pushed) 75 | subscribe(button2, "button.held", button2held) 76 | } 77 | if(button3) { 78 | subscribe(button3, "button.pushed", button3pushed) 79 | subscribe(button3, "button.held", button3held) 80 | } 81 | if(button4) { 82 | subscribe(button4, "button.pushed", button4pushed) 83 | subscribe(button4, "button.held", button4held) 84 | } 85 | */ 86 | 87 | } 88 | def logHandler(evt) { 89 | log.debug evt.value 90 | } 91 | 92 | def buttonParser(evt) { 93 | log.debug "EVT value: ${evt.value} ${evt.data}" 94 | def buttonNumber = evt.data 95 | def buttonState = evt.value 96 | 97 | log.debug "Button: ${buttonNumber} : ${buttonState}" 98 | 99 | if (buttonNumber == '1' && button1) { 100 | log.debug "B1: calling ${buttonState}" 101 | if(buttonState == 'pushed') { 102 | button1.push1() 103 | } else if (buttonState == 'held') { 104 | button1.hold1() 105 | } 106 | } else if (buttonNumber == '2' && button2) { 107 | log.debug "B2: calling ${buttonState}" 108 | if(buttonState == 'pushed') { 109 | button2.push1() 110 | } else if (buttonState == 'held') { 111 | button2.hold1() 112 | } 113 | } 114 | else if (buttonNumber == '3' && button3) { 115 | log.debug "B3: calling ${buttonState}" 116 | if(buttonState == 'pushed') { 117 | button3.push1() 118 | } else if (buttonState == 'held') { 119 | button3.hold1() 120 | } 121 | } else if (buttonNumber == '4' && button4) { 122 | log.debug "B4: calling ${buttonState}" 123 | if(buttonState == 'pushed') { 124 | button4.push1() 125 | } else if (buttonState == 'held') { 126 | button4.hold1() 127 | } 128 | } 129 | 130 | } -------------------------------------------------------------------------------- /smartapps/kirkbrownok/arduino-temperature-sensor-de-mux.src/arduino-temperature-sensor-de-mux.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 Kirk Brown 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at: 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 11 | * for the specific language governing permissions and limitations under the License. 12 | * 13 | * temperature sensor De-Mux 14 | * 15 | * Author: Kirk Brown 16 | * 17 | * Date: 2015-10-1 18 | */ 19 | definition( 20 | name: "Arduino Temperature Sensor De-Mux", 21 | namespace: "kirkbrownOK", 22 | author: "Kirk Brown", 23 | description: "Takes sensor events from arduino and applies them to individual virtual sensors for ease of use in other smartapps.", 24 | category: "Convenience", 25 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet.png", 26 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet@2x.png" 27 | 28 | 29 | ) 30 | 31 | 32 | preferences { 33 | section("Arduino Input Dev1: ") { 34 | input "inDev1", "capability.temperatureMeasurement", multiple: false, required: true 35 | } 36 | section("Temperature output Dev1") { 37 | input "outDev1", "capability.temperatureMeasurement", multiple: false, required: false 38 | } 39 | section("Thermostat out for Dev1") { 40 | input "thermDev1", "capability.temperatureMeasurement", multiple: false, required: false 41 | } 42 | section("Arduino Input Dev2: ") { 43 | input "inDev2", "capability.temperatureMeasurement", multiple: false, required: false 44 | } 45 | section("Temperature output Dev2") { 46 | input "outDev2", "capability.temperatureMeasurement", multiple: false, required: false 47 | } 48 | section("Arduino Input Dev3: ") { 49 | input "inDev3", "capability.temperatureMeasurement", multiple: false, required: false 50 | } 51 | section("Temperature output Dev3") { 52 | input "outDev3", "capability.temperatureMeasurement", multiple: false, required: false 53 | } 54 | } 55 | 56 | def installed() 57 | { 58 | subscribeToDevices() 59 | 60 | } 61 | 62 | def updated() 63 | { 64 | unsubscribe() 65 | subscribeToDevices() 66 | } 67 | def subscribeToDevices() { 68 | log.debug "Subscribing to devices" 69 | subscribe(inDev1, "temperature", temperatureHandler1) 70 | subscribe(outDev1, "switch.refresh", refreshHandler1) 71 | if (thermDev1) { 72 | log.debug "Subscribed to ${thermDev1}" 73 | subscribe(thermDev1, "refresh", refreshHandler1) 74 | } 75 | log.debug "Subscribed to ${inDev1}" 76 | if(inDev2) { 77 | log.debug "Subscribed to ${inDev2}" 78 | subscribe(inDev2, "temperature1", temperatureHandler2) 79 | subscribe(outDev2, "switch.refresh", refreshHandler2) 80 | } 81 | if(inDev3) { 82 | log.debug "subscribed to ${inDev3}" 83 | subscribe(inDev3, "temperature", temperatureHandler3) 84 | subscribe(outDev3, "switch.refresh", refreshHandler3) 85 | } 86 | 87 | } 88 | def logHandler(evt) { 89 | log.debug evt.value 90 | } 91 | def temperatureHandler1(evt) { 92 | log.debug "Sending $evt.value to ${outDev1}" 93 | outDev1.setTemperature(evt.value) 94 | if(thermDev1) { 95 | log.debug "Sending $evt.value to ${thermDev1}" 96 | thermDev1.setTemperature(evt.value) 97 | } 98 | } 99 | def temperatureHandler2(evt) { 100 | log.debug "Sending $evt.value to ${outDev2}" 101 | outDev2.setTemperature(evt.value) 102 | } 103 | def temperatureHandler3(evt) { 104 | log.debug "Sending $evt.value to ${outDev3}" 105 | outDev3.setTemperature(evt.value) 106 | } 107 | def refreshHandler1(evt) { 108 | outDev1.setTemperature(inDev1.currentValue("temperature")) 109 | if(thermDev1) { 110 | thermDev1.setTemperature(inDev1.currentValue("temperature")) 111 | } 112 | log.debug "Sending refresh to ${inDev1}" 113 | inDev1.refresh() 114 | } 115 | def refreshHandler2(evt) { 116 | outDev2.setTemperature(inDev2.currentValue("temperature1")) 117 | log.debug "Sending refresh to ${inDev2}" 118 | inDev2.refresh() 119 | } 120 | def refreshHandler3(evt) { 121 | outDev3.setTemperature(inDev3.currentValue("temperature")) 122 | log.debug "Sending refresh to ${inDev3}" 123 | inDev3.refresh() 124 | } -------------------------------------------------------------------------------- /smartapps/kirkbrownok/lock-it-with-virtual-light.src/lock-it-with-virtual-light.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2016 Kirk Brown 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at: 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 11 | * for the specific language governing permissions and limitations under the License. 12 | * 13 | * Lock It When I Leave 14 | * 15 | * Author: Kirk Brown 16 | * Date: 2016-11-06 17 | */ 18 | 19 | definition( 20 | name: "Lock It With Virtual Light", 21 | namespace: "kirkbrownOK", 22 | author: "Kirk Brown", 23 | description: "Locks a deadbolt or lever lock when a Light turns on and Unlocks when light turns off.", 24 | category: "Safety & Security", 25 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 26 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" 27 | ) 28 | 29 | preferences { 30 | section("Match with this Virtual Light") { 31 | input "light1", "capability.switch", title: "Which Virtual Light?" 32 | input "lockedOn", "enum", title: "Should the lock be locked when the light is ON or OFF?", options: ["ON", "OFF"] 33 | } 34 | section("Lock the lock...") { 35 | input "lock1","capability.lock", multiple: true 36 | input "unlock", "enum", title: "Allow the Tile to unlock the door?", options: ["YES","NO"] 37 | } 38 | } 39 | 40 | def installed() 41 | { 42 | initialize() 43 | } 44 | 45 | def updated() 46 | { 47 | unsubscribe() 48 | initialize() 49 | } 50 | def initialize() 51 | { 52 | 53 | subscribe(light1, "switch.on", lightOn) 54 | if( unlock == "YES") { 55 | subscribe(light1, "switch.off", lightOff) 56 | } 57 | subscribe(lock1, "lock.locked", doorLocked) 58 | subscribe(lock1, "lock.unlocked", doorUnlocked) 59 | if(lock1.currentLock == "locked") { 60 | 61 | //send lock sync to light 62 | if(lockedOn == "ON") { 63 | TRACE("LOCKED SYNCON") 64 | light1.syncON() 65 | } else { 66 | TRACE("LOCKED SYNCOFF") 67 | light1.syncOFF() 68 | } 69 | } else if(lock1.currentLock == "unlocked") { 70 | //send unlock sync to light 71 | if(lockedOn == "ON") { 72 | TRACE("UNLOCKED SYNCOFF") 73 | light1.syncOFF() 74 | } else { 75 | TRACE("UNLOCKED SYNCON") 76 | light1.syncON() 77 | } 78 | } 79 | } 80 | def lightOn(evt) 81 | { 82 | log.info "$evt.name $evt.value $evt.descriptionText" 83 | if(descriptionText == "Virtual") { 84 | TRACE("light ON Virtual") 85 | //The light on event is from the lock smart app. For syncronization purposes. 86 | return 87 | } 88 | //Received real ON from something -> Alexa 89 | if( lockedOn == "ON") { 90 | TRACE("LON LOCKING") 91 | lock1.lock() 92 | } else { 93 | if(unlock == "YES") { 94 | TRACE("LON UNLOCKING") 95 | lock1.unlock() 96 | } 97 | } 98 | 99 | } 100 | def lightOff(evt) 101 | { 102 | log.info "$evt.name $evt.value $evt.descriptionText" 103 | if(descriptionText == "Virtual") { 104 | TRACE("Virtual Light OFF") 105 | //The light off event is from the lock smart app. For syncronization purposes. 106 | return 107 | } 108 | //Received real OFF from something -> Alexa 109 | if(lockedOn == "ON") { 110 | if(unlock == "YES") { 111 | TRACE("LOFF UNLOCK") 112 | lock1.unlock() 113 | } 114 | } else { 115 | TRACE("LOFF LOCK") 116 | lock1.lock() 117 | } 118 | } 119 | def doorLocked(evt) { 120 | log.info "$evt.name $evt.value $evt.descriptionText" 121 | //The lock was manually changed so update the virtual tile 122 | if(lockedOn == "ON") { 123 | light1.syncOn() 124 | 125 | } else { 126 | light1.syncOff() 127 | } 128 | 129 | } 130 | 131 | def doorUnlocked(evt) { 132 | log.info "$evt.name $evt.value $evt.descriptionText" 133 | //The lock was manually changed so update the virtual tile 134 | if(lockedOn == "ON") { 135 | light1.syncOff() 136 | 137 | } else { 138 | light1.syncOn() 139 | } 140 | } 141 | def TRACE (msg) { 142 | 143 | log.debug "$msg" 144 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SensiThermostat 2 | SmartThings Sensi Thermostat 3 | 4 | 5 | The Sensi (Connect) SmartApp and the Sensi Thermostat Device Handler are fully functional as far as I know. Please give them a try and see what you think. 6 | 7 | 8 | Quick word on SmartThings (Connect) apps and their device type handler. You must copy and paste the code into your account for smartapps into the smartapp section. Then you must copy and past the code for the device type handler in the device type handler section. Then you install the smartapp. If you do it right, the smartapp itself will actually create the devices itself and not you manually creating them. Good luck! Here are detailed information on copy/paste your own code. 9 | 10 | 11 | These directions are from: https://community.smartthings.com/t/faq-an-overview-of-using-custom-code-in-smartthings/16772 12 | 13 | USING A CUSTOM SMARTAPP 14 | 15 | This involves two steps. 16 | 17 | A one time process to "publish" the smartapp code to yourself using the Developers section of the SmartThings.com website so it is available for future installation. 18 | 19 | Then using the official SmartThings mobile app to install it so you can use it with specific devices. 20 | 21 | First, the One Time Process to Publish it to Yourself 22 | 23 | SA1) Copy the code from the author. 24 | 25 | SA2) Sign in to the Developers section of the SmartThings website so you can access the IDE (Integrated Development Environment). To get there, first click on "Community" at the top right of this page, then click on "Developer Tools" in the top right of that next page. 26 | 27 | SA3) Choose SmartApps, then Add a New SmartApp from Code. 28 | 29 | SA4) Paste in the code you copied, change anything necessary based on the author's instructions, then PUBLISH it to yourself. 30 | 31 | SA5) Make any additional edits according to the author's instructions, such as enabling OAUTH. 32 | -THIS SMARTAPP DOES NOT NEED OAUTH. That was from a more generic description of installing custom apps. 33 | 34 | Now when you open the official SmartThings mobile app, this new custom smartapp will appear as a choice under My SmartApps in the SmartApp section in the Marketplace. 35 | 36 | Next, the Install Process to Assign that SmartApp to a Specific Device.. 37 | 38 | To assign that SmartApp to a specific device: 39 | 40 | SA6) Open the ST mobile app. 41 | 42 | SA7) Go to the Dashboard, then click on the Marketplace icon (the multicolored asterisk in the lower right). 43 | 44 | SA8) Choose SmartApps 45 | 46 | SA9) Scroll down to the MY SMARTAPPS section and choose it. 47 | 48 | SA10) Scroll down to find the custom smartapp you want, then install it. 49 | 50 | SA11) Follow the set up wizard instructions for that smartapp. 51 | 52 | Some custom SmartApps also require a custom Device Handler to work. If so, the author will mention that in the installation instructions 53 | 54 | USING A CUSTOM DEVICE TYPE HANDLER 55 | 56 | Easy! 57 | 58 | These steps assume you have already added the device to your account through the SmartThings mobile app. It may be using a standard device type handler, or it may just have been added as a "thing", but it should show up on the list of devices for your account. 59 | 60 | (If this is an ip-addressable device like a camera you may not have been able to add it to your account through the SmartThings mobile app, so the system will not assign it a device ID. In that case you will need to sign into the Developers section (IDE) and first choose My Devices and then use the ADD NEW DEVICE button to enter a placeholder for the device and assign it a unique device ID. You can choose any device type handler for the placeholder since you're going to change it in a minute anyway. Then you can continue with the following steps.) 61 | 62 | DT1) Copy the code from the author. 63 | 64 | DT2) Sign in to the Developers section of the SmartThings website. (To get there, first click on "Community" at the top right of this page, then click on "Developer Tools" in the top right of that next page.) 65 | 66 | DT3 Choose Device Handlers, then Add a New Device Handler from Code. 67 | 68 | DT4) Paste in the code you copied, change anything necessary based on the author's instructions, then CREATE it for yourself. 69 | 70 | DT5) Once the Device Handler is published in your own library, select MY DEVICES in the IDE and choose the specific device you want to have use that new device handler. 71 | 72 | DT6) Edit the Device so that it uses that device type handler. 73 | 74 | Done! 75 | 76 | Now any SmartApp that wants to talk to that device will be able to request the features specified in the custom device type handler. (Again, the physical device has to already support the features, the device type handler just translates the requests between SmartThings and the device.) 77 | -------------------------------------------------------------------------------- /smartapps/kirkbrownok/lights-from-from-an-arduino.src/lights-from-from-an-arduino.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Light Follows Me and restores state before motion 3 | * 4 | * Author: SmartThings and OKpowerman 5 | */ 6 | 7 | definition( 8 | name: "Lights From from an Arduino", 9 | namespace: "kirkbrownOK", 10 | author: "Kirk Brown", 11 | description: "Turn your lights on when contact sensors Open then off after they close and some period of time later.", 12 | category: "Convenience", 13 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo-switch.png", 14 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo-switch@2x.png" 15 | ) 16 | 17 | preferences { 18 | section("Turn on when the contact sensor opens..."){ 19 | input "contact1", "capability.contactSensor", title: "Where?", multiple: true 20 | } 21 | section("Which Sensor number should I listen for?") { 22 | input "sensorNum", "number",required: false 23 | } 24 | 25 | section("How long should the light stay on after the sensor is closed?"){ 26 | input "minutes1", "number", title: "Minutes?" 27 | } 28 | section("Turn on/off light(s)..."){ 29 | input "switches", "capability.switch", multiple: true 30 | } 31 | section("Using either on this light sensor (optional) or the local sunrise and sunset"){ 32 | input "lightSensor", "capability.illuminanceMeasurement", required: false 33 | } 34 | section("Perform light operations no matter the time of day?") { 35 | input "ignoreTOD", "enum", required: true, options: ["Yes", "No"] 36 | } 37 | section ("Sunrise offset (optional)...") { 38 | input "sunriseOffsetValue", "text", title: "HH:MM", required: false 39 | input "sunriseOffsetDir", "enum", title: "Before or After", required: false, options: ["Before","After"] 40 | } 41 | section ("Sunset offset (optional)...") { 42 | input "sunsetOffsetValue", "text", title: "HH:MM", required: false 43 | input "sunsetOffsetDir", "enum", title: "Before or After", required: false, options: ["Before","After"] 44 | } 45 | section ("Zip code (optional, defaults to location coordinates when location services are enabled)...") { 46 | input "zipCode", "text", title: "Zip code", required: false 47 | } 48 | 49 | } 50 | 51 | def installed() { 52 | initialize() 53 | 54 | 55 | } 56 | 57 | def updated() { 58 | unsubscribe() 59 | unschedule() 60 | getSunriseOffset() 61 | getSunsetOffset() 62 | initialize() 63 | 64 | 65 | } 66 | 67 | def contactHandlerOpen(evt) { 68 | TRACE("Opened") 69 | if( ignoreTOD == "Yes") { 70 | switches.on() 71 | switches.setLevel(99) 72 | TRACE( "Ignored TOD Open Event: ${evt.name}: ${evt.value}") 73 | state.switches=switches.currentSwitch 74 | state.levels= switches.currentLevel 75 | TRACE("switches.currentState ${switches.currentSwitch} state: ${state.switches} level ${state.levels}") 76 | } 77 | else if(enabled()) { 78 | 79 | switches.on() 80 | switches.setLevel(99) 81 | TRACE( "Open Event: $evt.name: $evt.value") 82 | state.switches=switches.currentSwitch 83 | state.levels= switches.currentLevel 84 | TRACE("switches.currentState ${switches.currentSwitch} state: ${state.switches} level ${state.levels}") 85 | } 86 | 87 | } 88 | def contactHandlerClose(evt) { 89 | TRACE("Door Closed, Timer started") 90 | TRACE("Close Event: ") 91 | runIn(minutes1 * 60, returnLightsToNormal) 92 | 93 | } 94 | def returnLightsToNormal() { 95 | TRACE("Turning lights off") 96 | //switches.setLevel(10) 97 | switches.off() 98 | 99 | for (it in (switches)) { 100 | //TRACE("state sw: ${state.switches[it]}") 101 | // switches.${state.switches[it]} 102 | 103 | } 104 | 105 | } 106 | 107 | 108 | def initialize() { 109 | if (sensorNum > 0) { 110 | TRACE("SUBSCRIBING TO Sensornum: ${sensorNum}") 111 | subscribe(contact1, "contact.Sensor${sensorNum}:open", contactHandlerOpen) 112 | subscribe(contact1, "contact.Sensor${sensorNum}:close", contactHandlerClose) 113 | } else { 114 | TRACE("Subscribing to ${contact1} contact.open") 115 | subscribe(contact1, "contact.open", contactHandlerOpen) 116 | subscribe(contact1, "contact.closed", contactHandlerClose) 117 | } 118 | //subscribe(switches, "switch", switchHandler) 119 | state.controlState = "off" 120 | 121 | state.controlControl = false 122 | if (ignoreTOD == "Yes") { 123 | TRACE("Ignore TOD") 124 | }else if (lightSensor) { 125 | subscribe(lightSensor, "illuminance", illuminanceHandler, [filterEvents: false]) 126 | } 127 | else { 128 | //subscribe(location, "position", locationPositionChange) 129 | //subscribe(switch, "on", sunriseSunsetTimeHandler) 130 | //subscribe(location, "sunriseTime", sunriseSunsetTimeHandler) 131 | //subscribe(location, "sunsetTime", sunriseSunsetTimeHandler) 132 | astroCheck() 133 | //state.lastAstroCheck = now() - 86400000 134 | } 135 | } 136 | def switchHandler(evt) { 137 | //Disable any motion control even because the switch was manually controlled 138 | updated() 139 | TRACE("Motion Control Restart") 140 | 141 | 142 | } 143 | /* 144 | def sunriseSunsetTimeHandler(evt) { 145 | state.lastSunriseSunsetEvent = now() 146 | log.debug "SmartNightlight.sunriseSunsetTimeHandler($app.id)" 147 | astroCheck() 148 | } 149 | */ 150 | def astroCheck() { 151 | def s = getSunriseAndSunset(zipCode: zipCode, sunriseOffset: sunriseOffset, sunsetOffset: sunsetOffset) 152 | state.riseTime = s.sunrise.time 153 | state.setTime = s.sunset.time 154 | state.lastAstroCheck = now() 155 | TRACE( "rise: ${new Date(state.riseTime)}($state.riseTime), set: ${new Date(state.setTime)}($state.setTime) lastAstro ${state.lastAstroCheck}") 156 | 157 | } 158 | 159 | private enabled() { 160 | def result 161 | if (now() - state.lastAstroCheck > 3600000) { 162 | //Its been 1 hours since last sunset/sunrise time 163 | astroCheck() 164 | } else { 165 | TRACE( "Astro not needed, perform in ${(86400000-(now() - state.lastAstroCheck))/(1000*60*60)} hours") 166 | } 167 | if (lightSensor) { 168 | result = lightSensor.currentIlluminance < 30 169 | } 170 | else { 171 | def t = now() 172 | TRACE("now is ${t} rising Time: ${state.riseTime} setTime: ${state.setTime}") 173 | result = t < state.riseTime || t > state.setTime 174 | } 175 | TRACE("Finish enabled: ${result}") 176 | result 177 | } 178 | 179 | private getSunriseOffset() { 180 | sunriseOffsetValue ? (sunriseOffsetDir == "Before" ? "-$sunriseOffsetValue" : sunriseOffsetValue) : null 181 | } 182 | 183 | private getSunsetOffset() { 184 | sunsetOffsetValue ? (sunsetOffsetDir == "Before" ? "-$sunsetOffsetValue" : sunsetOffsetValue) : null 185 | } 186 | 187 | private def TRACE(message) { 188 | log.debug message 189 | } -------------------------------------------------------------------------------- /smartapps/kirkbrownok/orvibo-s20-connect.src/orvibo-s20-connect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Orvibo S20 Connect 3 | * 4 | * 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | definition( 18 | name: "Orvibo S20 Connect", 19 | namespace: "kirkbrownOK", 20 | author: "OKpowerman", 21 | description: "Connect an Orvibo S20 Wifi Outlet", 22 | category: "SmartThings Labs", 23 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 24 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", 25 | iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png") 26 | 27 | preferences { 28 | page(name:"selectOrviboPage", title:"Configuration", content:"selectOrviboPage") 29 | } 30 | 31 | /* Preferences page to be shown when installing app */ 32 | def selectOrviboPage() { 33 | def refreshInterval = 5 34 | 35 | if(!state.subscribe) { 36 | log.debug('Subscribing to updates') 37 | // subscribe to M-SEARCH answers from hub 38 | subscribe(location, null, locationHandler, [filterEvents:false]) 39 | state.subscribe = true 40 | } 41 | 42 | // Perform M-SEARCH 43 | log.debug('Performing discovery') 44 | ssdpDiscover() 45 | def devicesForDialog = getDevicesForDialog() 46 | 47 | // Only one page - can install or uninstall from this page 48 | return dynamicPage(name:"selectOrviboPage", title:"", nextPage:"", refreshInterval: refreshInterval, install:true, uninstall: true) { 49 | section("Reset Devices") { 50 | input "resetDevices", "enum", options: ["Yes", "No"], required: false 51 | } 52 | section("") { 53 | input "selectedorvibo", "enum", required:false, title:"Select orvibo \n(${devicesForDialog.size() ?: 0} found)", multiple:true, options:devicesForDialog 54 | } 55 | } 56 | } 57 | 58 | void ssdpDiscover() { 59 | sendHubCommand(new physicalgraph.device.HubAction("hd\00\06qa", physicalgraph.device.Protocol.LAN)) 60 | } 61 | 62 | /* Generate the list of devices for the preferences dialog */ 63 | def getDevicesForDialog() { 64 | def devices = getDevices() 65 | log.debug devices 66 | def map = [:] 67 | devices.each { 68 | def value = convertHexToIP(it.value.ip) + ':' + convertHexToInt(it.value.port) 69 | def key = it.value.ssdpUSN.toString() 70 | map["${key}"] = value 71 | } 72 | TRACE(map) 73 | map 74 | } 75 | 76 | /* Get map containing discovered devices. Maps USN to parsed event. */ 77 | def getDevices() { 78 | if(resetDevices == "Yes" && state.justReset == "No") { 79 | log.debug "RESET DEVICES" 80 | state.devices = [:] 81 | state.justReset = "Yes" 82 | } else if( resetDevices == "No"){ 83 | state.justReset = "No" 84 | log.debug "Clear state.justReset" 85 | } 86 | 87 | if (!state.devices) { state.devices = [:] } 88 | log.debug("There are ${state.devices.size()} devices at this time") 89 | state.devices 90 | } 91 | 92 | /* Callback when an M-SEARCH answer is received */ 93 | def locationHandler(evt) { 94 | if(evt.name == "ping") { 95 | return "" 96 | } 97 | 98 | log.debug('Orvibo App Received Response: ' + evt.description) 99 | 100 | def description = evt.description 101 | def hub = evt?.hubId 102 | def parsedEvent = parseDiscoveryMessage(description) 103 | parsedEvent << ["hub":hub] 104 | log.debug "parsedEvent ${parsedEvent}" 105 | if (parsedEvent?.ssdpTerm?.contains("schemas-upnp-org:device:orvibo_LAN:")) { 106 | def devices = getDevices() 107 | 108 | if (!(devices."${parsedEvent.ssdpUSN.toString()}")) { //if it doesn't already exist 109 | //log.debug('Parsed Event: ' + parsedEvent) 110 | devices << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] 111 | } else { // just update the values 112 | log.debug "Device exists, updating devices" 113 | def d = devices."${parsedEvent.ssdpUSN.toString()}" 114 | boolean deviceChangedValues = false 115 | 116 | if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { 117 | d.ip = parsedEvent.ip 118 | d.port = parsedEvent.port 119 | deviceChangedValues = true 120 | } 121 | 122 | if (deviceChangedValues) { 123 | def children = getChildDevices() 124 | children.each { 125 | if (it.getDeviceDataByName("ssdpUSN") == parsedEvent.ssdpUSN) { 126 | //it.subscribe(parsedEvent.ip, parsedEvent.port) 127 | } 128 | } 129 | } 130 | 131 | } 132 | } 133 | } 134 | 135 | def installed() { 136 | // remove location subscription 137 | unsubscribe() 138 | state.subscribe = false 139 | 140 | log.debug "Installed with settings: ${settings}" 141 | 142 | initialize() 143 | } 144 | 145 | def updated() { 146 | log.debug "Updated with settings: ${settings}" 147 | 148 | unsubscribe() 149 | initialize() 150 | } 151 | 152 | def initialize() { 153 | log.debug('Initializing') 154 | 155 | selectedorvibo.each { ssdpUSN -> 156 | def devices = getDevices() 157 | 158 | // Make the dni the MAC followed by the index from the USN, unless it's the USN ending in :1 159 | // that device has just the MAC address as its DNI and receives all the notifications from 160 | // the RPi 161 | def dni = devices[ssdpUSN].mac + ':' + ssdpUSN.split(':').last() 162 | 163 | if (ssdpUSN.endsWith(":1")) { 164 | dni = devices[ssdpUSN].mac 165 | } 166 | 167 | // Check if child already exists 168 | def d = getChildDevices()?.find { 169 | it.device.deviceNetworkId == dni 170 | } 171 | 172 | if (!d) { 173 | def ip = devices[ssdpUSN].ip 174 | def port = devices[ssdpUSN].port 175 | log.debug("Adding ${dni} for ${ssdpUSN} / ${ip}:${port}") 176 | d = addChildDevice("kirkbrownOK", "orvibo LAN", dni, devices[ssdpUSN].hub, [ 177 | "label": convertHexToIP(ip) + ':' + convertHexToInt(port), 178 | "data": [ 179 | "ip": ip, 180 | "port": port, 181 | "ssdpUSN": ssdpUSN, 182 | "ssdpPath": devices[ssdpUSN].ssdpPath 183 | ] 184 | ]) 185 | } else { log.debug "DNI already exists" } 186 | } 187 | 188 | // Subscribe immediately, then once every ten minutes 189 | unschedule() 190 | schedule("0 0/10 * * * ?", subscribeToDevices) 191 | subscribeToDevices() 192 | } 193 | 194 | def subscribeToDevices() { 195 | log.debug "subscribeToDevices() called" 196 | def devices = getAllChildDevices() 197 | devices.each { d -> 198 | //log.debug('Call subscribe on '+d.id) 199 | d.subscribe() 200 | } 201 | } 202 | 203 | private def parseDiscoveryMessage(String description) { 204 | def device = [:] 205 | def parts = description.split(',') 206 | parts.each { part -> 207 | part = part.trim() 208 | if (part.startsWith('devicetype:')) { 209 | def valueString = part.split(":")[1].trim() 210 | device.devicetype = valueString 211 | } else if (part.startsWith('mac:')) { 212 | def valueString = part.split(":")[1].trim() 213 | if (valueString) { 214 | device.mac = valueString 215 | } 216 | } else if (part.startsWith('networkAddress:')) { 217 | def valueString = part.split(":")[1].trim() 218 | if (valueString) { 219 | device.ip = valueString 220 | } 221 | } else if (part.startsWith('deviceAddress:')) { 222 | def valueString = part.split(":")[1].trim() 223 | if (valueString) { 224 | device.port = valueString 225 | } 226 | } else if (part.startsWith('ssdpPath:')) { 227 | def valueString = part.split(":")[1].trim() 228 | if (valueString) { 229 | device.ssdpPath = valueString 230 | } 231 | } else if (part.startsWith('ssdpUSN:')) { 232 | part -= "ssdpUSN:" 233 | def valueString = part.trim() 234 | if (valueString) { 235 | device.ssdpUSN = valueString 236 | } 237 | } else if (part.startsWith('ssdpTerm:')) { 238 | part -= "ssdpTerm:" 239 | def valueString = part.trim() 240 | if (valueString) { 241 | device.ssdpTerm = valueString 242 | } 243 | } else if (part.startsWith('headers')) { 244 | part -= "headers:" 245 | def valueString = part.trim() 246 | if (valueString) { 247 | device.headers = valueString 248 | } 249 | } else if (part.startsWith('body')) { 250 | part -= "body:" 251 | def valueString = part.trim() 252 | if (valueString) { 253 | device.body = valueString 254 | } 255 | } 256 | } 257 | 258 | device 259 | } 260 | 261 | /* Convert hex (e.g. port number) to decimal number */ 262 | private Integer convertHexToInt(hex) { 263 | Integer.parseInt(hex,16) 264 | } 265 | 266 | /* Convert internal hex representation of IP address to dotted quad */ 267 | private String convertHexToIP(hex) { 268 | [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") 269 | } 270 | 271 | private def TRACE(message) { 272 | log.debug message 273 | } -------------------------------------------------------------------------------- /smartapps/kirkbrownok/sendsoundstoarduinospeaker/send-sounds-to-arduino-speaker.src/send-sounds-to-arduino-speaker.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Kirk Brown 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at: 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 11 | * for the specific language governing permissions and limitations under the License. 12 | * 13 | * Severe Weather Alert controls LIGHTS 14 | * 15 | * Author: Kirk Brown based on Severe Weather Alert from SmartThings 16 | * Date: 2013-03-04 17 | */ 18 | definition( 19 | name: "Send Sounds to Arduino Speaker", 20 | namespace: "kirkbrownOK/SendSoundsToArduinoSpeaker", 21 | author: "kirkbrown", 22 | description: "Monitor Doors, locks, people, and severe weather then play sounds on speaker", 23 | category: "Safety & Security", 24 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-SevereWeather.png", 25 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-SevereWeather@2x.png" 26 | ) 27 | 28 | preferences { 29 | section ("Make noise on these Door Sensors:") { 30 | input("doors", "capability.contactSensor", title: "Chime Only on these Doors", multiple:true, required: false) 31 | input("doors1","capability.contactSensor", title: "Specific Tone for this door:", multiple:false, required: false) 32 | input("noise1", "enum", title: "Tone # for door1:",options: attributeValues(),required: false) 33 | input("doors2","capability.contactSensor", title: "Specific Tone for this door:", multiple:false, required: false) 34 | input("noise2", "enum", title: "Tone # for door2:",options: attributeValues(),required: false) 35 | input("doors3","capability.contactSensor", title: "Specific Tone for this door:", multiple:false, required: false) 36 | input("noise3", "enum", title: "Tone # for door3:",options: attributeValues(),required: false) 37 | input("doors4","capability.contactSensor", title: "Specific Tone for this door:", multiple:false, required: false) 38 | input("noise4", "enum", title: "Tone # for door4:",options: attributeValues(),required: false) 39 | input("doors5","capability.contactSensor", title: "Specific Tone for this door:", multiple:false, required: false) 40 | input("noise5", "enum", title: "Tone # for door5:",options: attributeValues(),required: false) 41 | input("doors6","capability.contactSensor", title: "Specific Tone for this door:", multiple:false, required: false) 42 | input("noise6", "enum", title: "Tone # for door6:",options: attributeValues(),required: false) 43 | input("doors7","capability.contactSensor", title: "Specific Tone for this door:", multiple:false, required: false) 44 | input("noise7", "enum", title: "Tone # for door7:",options: attributeValues(),required: false) 45 | input("doors8","capability.contactSensor", title: "Specific Tone for this door:", multiple:false, required: false) 46 | input("noise8", "enum", title: "Tone # for door8:",options: attributeValues(),required: false) 47 | } 48 | section ("Doorbell") { 49 | input("doorbell", "capability.contactSensor", title: "Doorbell Noise", multiple:true) 50 | } 51 | section ("Tornado alarms:") { 52 | input "watchSiren", "boolean", title: "Audible alarm for Tornado Watch? ", required: true 53 | input "warningSiren", "boolean", title: "Audible alarm for Tornado Warning", required: true 54 | } 55 | 56 | section ("Zip code (optional, defaults to location coordinates)...") { 57 | input "zipcode", "text", title: "Zip Code", required: false 58 | } 59 | section ("Update on this switch") { 60 | input "updateSwitch", "capability.switch", title: "Switch that causes update", required: false, multiple: false 61 | } 62 | section ("Use this speaker") { 63 | input "speaker", "capability.switch", title: "Speaker for sounds", required: false, multiple: false 64 | } 65 | } 66 | 67 | def installed() { 68 | log.debug "Installed with settings: ${settings}" 69 | initiliaze() 70 | 71 | } 72 | 73 | def updated() { 74 | log.debug "Updated with settings: ${settings}" 75 | unschedule() 76 | unsubscribe() 77 | initialize() 78 | } 79 | def initialize() { 80 | state.alertKeys = "" 81 | scheduleJob() 82 | if(updateSwitch) { 83 | subscribe(updateSwitch,"switch.on",checkForSevereWeather) 84 | } 85 | if(doors) { 86 | subscribe(doors, "contact.open", doorChime) 87 | } 88 | if(doors1) { 89 | subscribe(doors1, "contact.open", doorChime1) 90 | } 91 | if(doors2) { 92 | subscribe(doors2, "contact.open", doorChime2) 93 | } 94 | if(doors3) { 95 | subscribe(doors3, "contact.open", doorChime3) 96 | } 97 | if(doors4) { 98 | subscribe(doors4, "contact.open", doorChime4) 99 | } 100 | if(doors5) { 101 | subscribe(doors5, "contact.open", doorChime5) 102 | } 103 | if(doors6) { 104 | subscribe(doors6, "contact.open", doorChime6) 105 | } 106 | if(doors7) { 107 | subscribe(doors7, "contact.open", doorChime7) 108 | } 109 | if(doors8) { 110 | subscribe(doors8, "contact.open", doorChime8) 111 | } 112 | if(doorbell) { 113 | subscribe(doorbell, "contact.open", doorbellRing) 114 | } 115 | } 116 | 117 | def scheduleJob() { 118 | def sec = Math.round(Math.floor(Math.random() * 60)) 119 | def min = Math.round(Math.floor(Math.random() * 60)) 120 | def cron = "$sec $min * * * ?" 121 | log.debug "chron: ${cron}" 122 | schedule(cron, "checkForSevereWeather") 123 | } 124 | 125 | def checkForSevereWeather(evt) { 126 | def alerts 127 | if(locationIsDefined()) { 128 | if(zipcodeIsValid()) { 129 | alerts = getWeatherFeature("alerts", zipcode)?.alerts 130 | } else { 131 | log.warn "Severe Weather Alert: Invalid zipcode entered, defaulting to location's zipcode" 132 | alerts = getWeatherFeature("alerts")?.alerts 133 | } 134 | } else { 135 | log.warn "Severe Weather Alert: Location is not defined" 136 | } 137 | log.debug "alerts: ${alerts}" 138 | if(alerts == []) { 139 | state.tornadoWatch = false 140 | state.tornadoWarning = false 141 | } 142 | def newKeys = alerts?.collect{it.type + it.date_epoch} ?: [] 143 | log.debug "Severe Weather Alert: newKeys: $newKeys" 144 | 145 | def oldKeys = state.alertKeys ?: [] 146 | log.debug "Severe Weather Alert: oldKeys: $oldKeys" 147 | 148 | if (newKeys != oldKeys) { 149 | 150 | state.alertKeys = newKeys 151 | 152 | alerts.each {alert -> 153 | if (!oldKeys.contains(alert.type + alert.date_epoch) && descriptionFilter(alert.description)) { 154 | def msg = "Weather Alert! ${alert.description} from ${alert.date} until ${alert.expires}" 155 | if (alert.description.contains("Tornado Warning") && !state.tornadoWarning){ 156 | log.debug "Sending Tornado Warning Siren" 157 | state.tornadoWatch = false 158 | state.tornadoWarning = true 159 | if(warningSiren) { 160 | 161 | speaker.smsenddoorbell(1,3) 162 | } 163 | if (alert.description.contains("Tornado Watch") && !state.tornadoWatch) { 164 | log.debug "Sending Tornado Watch Siren" 165 | state.tornadoWarning = false 166 | state.tornadoWatch = true 167 | if(watchSiren && !state.tornadoWarning) { 168 | 169 | speaker.smsenddoorbell(1,2) 170 | } 171 | } 172 | 173 | } 174 | } 175 | } 176 | } 177 | } 178 | 179 | def descriptionFilter(String description) { 180 | def filterList = ["special", "statement","thunderstorm", "test"] 181 | def passesFilter = true 182 | filterList.each() { word -> 183 | if(description.toLowerCase().contains(word)) { passesFilter = false } 184 | } 185 | log.debug "Description ${description} filter: ${passesFilter}" 186 | passesFilter 187 | } 188 | 189 | def locationIsDefined() { 190 | zipcodeIsValid() || location.zipCode || ( location.latitude && location.longitude ) 191 | } 192 | 193 | def zipcodeIsValid() { 194 | zipcode && zipcode.isNumber() && zipcode.size() == 5 195 | } 196 | def doorChime(evt) { 197 | log.debug "doorChime()" 198 | //speaker.setRepeat(2) 199 | speaker.smsenddoorbell(1,0) 200 | } 201 | def doorbellRing(evt) { 202 | log.debug "doorbellRing()" 203 | //speaker.setRepeat(4) 204 | speaker.smsenddoorbell(1,1) 205 | doorbell.close() 206 | } 207 | def stopSpeaker(evt) { 208 | speaker.smsenddoorbell(1,1) 209 | } 210 | def doorChime1(evt) { 211 | log.debug "doorChime(1,$noise1)" 212 | //speaker.setRepeat(2) 213 | speaker.smsenddoorbell(1,noise1) 214 | } 215 | def doorChime2(evt) { 216 | log.debug "doorChime(1,$noise2)" 217 | //speaker.setRepeat(2) 218 | speaker.smsenddoorbell(1,noise2) 219 | } 220 | def doorChime3(evt) { 221 | log.debug "doorChime(1,$noise3)" 222 | //speaker.setRepeat(2) 223 | speaker.smsenddoorbell(1,noise3) 224 | } 225 | def doorChime4(evt) { 226 | log.debug "doorChime(1,$noise4)" 227 | //speaker.setRepeat(2) 228 | speaker.smsenddoorbell(1,noise4) 229 | } 230 | def doorChime5(evt) { 231 | log.debug "doorChime(1,$noise5)" 232 | //speaker.setRepeat(2) 233 | speaker.smsenddoorbell(1,noise5) 234 | } 235 | def doorChime6(evt) { 236 | log.debug "doorChime(1,$noise6)" 237 | //speaker.setRepeat(2) 238 | speaker.smsenddoorbell(1,noise6) 239 | } 240 | def doorChime7(evt) { 241 | log.debug "doorChime(1,$noise7)" 242 | //speaker.setRepeat(2) 243 | speaker.smsenddoorbell(1,noise7) 244 | } 245 | def doorChime8(evt) { 246 | log.debug "doorChime(1,$noise8)" 247 | //speaker.setRepeat(2) 248 | speaker.smsenddoorbell(1,noise8) 249 | } 250 | 251 | 252 | private attributeValues() { 253 | return ["7" : "Back Door", 254 | "8" : "Front Door", 255 | "9" : "Garage Door", 256 | "10" : "Garage Entry Door", 257 | "11" : "Garage Hail Door", 258 | "12" : "Garage Hall Door", 259 | "13" : "Kitchen Door", 260 | "14" : "Living Room Door", 261 | "15" : "Side Door", 262 | "16" : "Jokes Back Door", 263 | "17" : "Jokes Front Door", 264 | "18" : "Jokes Garage Entry Door", 265 | "19" : "Jokes Garage Hall Door", 266 | "20" : "Jokes Garage Door", 267 | "21" : "Jokes Garage Side Door", 268 | "22" : "Jokes Kitchen Door", 269 | "23" : "Jokes Living Room Door", 270 | "24" : "Jokes Side Door"] 271 | } -------------------------------------------------------------------------------- /smartapps/kirkbrownok/community-rachio-connect.src/community-rachio-connect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Kirk Brown 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at: 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 11 | * for the specific language governing permissions and limitations under the License. 12 | * 13 | * Community Driven Rachio IRO Controller -> Because SmartThings won't approve the Official One 14 | * 15 | * Author: Kirk Brown 16 | * Date: 2017- January 17 | * 18 | * Place the Community Rachio (Connect) code under the My SmartApps section. Be certain you publish the app for you. 19 | * Place the Rachio Zone Device Type Handler under My Device Handlers section. 20 | * 21 | * Be careful that if you change the Name and Namespace you that additionally change it in the addDevice() function 22 | * 23 | * 24 | * The Program Flow is as follows: 25 | * 1. SmartApp gets user credentials in the install process. The app uses the credentials to get the Api Access Token 26 | * 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 27 | */ 28 | 29 | definition( 30 | name: "Community Rachio (Connect)", 31 | namespace: "kirkbrownOK", 32 | author: "Kirk Brown", 33 | description: "Connect your Rachio SmartThings. (Unofficial)", 34 | category: "SmartThings Labs", 35 | iconUrl: "http://i.imgur.com/QVbsCpu.jpg", 36 | iconX2Url: "http://i.imgur.com/4BfQn6I.jpg", 37 | singleInstance: true 38 | ) 39 | 40 | preferences { 41 | page(name: "auth", title: "Rachio", nextPage:"", content:"authPage", uninstall: true) 42 | page(name: "getDevicesPage", title: "Rachio Zones", nextPage:"", content:"getDevicesPage", uninstall: true, install: true) 43 | } 44 | 45 | def authPage() { 46 | 47 | def description 48 | def uninstallAllowed = false 49 | if(state.connectionToken) { 50 | description = "You are connected." 51 | uninstallAllowed = true 52 | } else { 53 | description = "Click to enter Rachio Credentials" 54 | } 55 | 56 | return dynamicPage(name: "auth", title: "Login", nextPage: "getDevicesPage", uninstall:uninstallAllowed) { 57 | section() { 58 | paragraph "Enter your API Access Token. Log in to the Rachio Client. Click on the user options and find the API Access Token. " + 59 | "Your token will be saved in SmartThings in whatever secure/insecure manner SmartThings saves them." 60 | input("userAPIToken", "string", title:"Rachio API Token", required:true, displayDuringSetup: true) 61 | //input("userName", "string", title:"Rachio Email Address", required:true, displayDuringSetup: true) 62 | //input("userPassword", "password", title:"Rachio account password", required:true, displayDuringSetup:true) 63 | input("pollInput", "number", title: "How often should ST poll Rachio? (in minutes)", required: false, displayDureingSetup: true, defaultValue: 5) 64 | } 65 | } 66 | 67 | } 68 | def getDevicesPage() { 69 | getRachioInfo() 70 | def _zones = getRachioDevices() 71 | return dynamicPage(name: "getDevicesPage", title: "Select Your Zones", uninstall: true) { 72 | section("") { 73 | paragraph "Tap below to see the list of Rachio Zones available in your Rachio account and select the ones you want to connect to SmartThings." 74 | input(name: "myZones", title:"", type: "enum", required:false, multiple:true, description: "Tap to choose", metadata:[values:_zones]) 75 | 76 | } 77 | } 78 | } 79 | 80 | 81 | def getDeviceDisplayName(dev) { 82 | if(dev?.DeviceName) { 83 | return dev.DeviceName.toString() 84 | } 85 | return "Unknown" 86 | } 87 | 88 | def installed() { 89 | log.debug "Installed with settings: ${settings}" 90 | initialize() 91 | } 92 | 93 | def updated() { 94 | log.debug "Updated with settings: ${settings}" 95 | unsubscribe() 96 | unschedule() 97 | initialize() 98 | } 99 | 100 | def initialize() { 101 | 102 | def devices = myZones.collect { dni -> 103 | def d = getChildDevice(dni) 104 | 105 | //TRACE("dni: $dni device: $d $state.rachioDevices") 106 | if(!d) { 107 | if(state.rachioControllers[dni] != null) { 108 | //TRACE("Its a controller device: ${state.rachioDevices[dni]}") 109 | 110 | //TRACE( "addChildDevice($app.namespace, ${getControllerName()}, $dni, null, [\"name\": ${state.rachioDevices[dni]},\"label\":\"Rachio Controller:$state.rachioDevices[dni]\"])") 111 | //d = addChildDevice(app.namespace, getControllerName(), dni, null, ["name":"Rachio Zone: $state.rachioDevices[dni],"label":"$state.rachioDevices[dni]"]) 112 | } else { 113 | //TRACE("Its a zone device: ${state.rachioDevices[dni]}") 114 | //TRACE( "addChildDevice($app.namespace, ${getChildName()}, $dni, null, [\"name\": ${state.rachioDevices[dni]},\"label\":\"Rachio Zone:$state.rachioDevices[dni]\"])") 115 | d = addChildDevice(app.namespace, getChildName(), dni, null, ["name":"Rachio Zone: ${state.rachioDevices[dni]}","label":"${state.rachioDevices[dni]}"]) 116 | } 117 | log.debug "created ${d.name} with id $dni" 118 | } else { 119 | //log.debug "found ${d.displayName} with id $dni already exists" 120 | } 121 | return d 122 | } 123 | 124 | TRACE( "created ${devices.size()} zones.") 125 | 126 | def delete // Delete any that are no longer in settings 127 | if(!myZones) { 128 | log.debug "delete zones" 129 | delete = getAllChildDevices() //inherits from SmartApp (data-management) 130 | } else { //delete only thermostat 131 | log.debug "delete individual zone" 132 | delete = getChildDevices().findAll { !myZones.contains(it.deviceNetworkId) } 133 | } 134 | log.warn "delete: ${delete}, deleting ${delete.size()} zones" 135 | delete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management) 136 | 137 | //send activity feeds to tell that device is connected 138 | def notificationMessage = "is connected to SmartThings" 139 | sendActivityFeeds(notificationMessage) 140 | 141 | try{ 142 | //poll() //first time polling data data from thermostat 143 | } catch (e) { 144 | log.warn "Error in first time polling. Could mean something is wrong." 145 | } 146 | //automatically update devices status every 5 mins 147 | def pollRate = pollInput == null ? 5 : pollInput 148 | if(pollRate > 59) { 149 | pollRate = 5 150 | log.warn "You picked an invalid pollRate: $pollInput minutes. Changed to 5 minutes." 151 | } 152 | //schedule("0 0/${pollRate} * * * ?","poll") 153 | 154 | 155 | } 156 | 157 | def getAuthorized() { 158 | TRACE("Get Authorized") 159 | def deviceListParams = [ 160 | uri: getApiEndpoint(), 161 | path: "/login?", 162 | headers: ["Content-Type": "application/json", "Accept": "application/json; text/javascript, */*; q=0.01", "X-Requested-With":"XMLHttpRequest"], 163 | body: [ username: userName, password: userPassword] 164 | ] 165 | TRACE("dlp: $deviceListParams") 166 | try { 167 | httpPostJson(deviceListParams) { resp -> 168 | //log.debug "Resp Headers: ${resp.headers}" 169 | //log.debug "Resp $resp" 170 | if (resp.status == 200) { 171 | resp.headers.each { 172 | log.debug "${it.name} : ${it.value}" 173 | 174 | } 175 | resp.data.each { 176 | log.debug "${it}" 177 | 178 | } 179 | } else { 180 | log.debug "http status: ${resp.status}" 181 | } 182 | } 183 | } catch (e) { 184 | log.trace "Exception trying to authenticate $e" 185 | } 186 | 187 | } 188 | //This function uses the API token to retrieve the User ID 189 | def getRachioInfo() { 190 | state.apiToken = userAPIToken 191 | def deviceListParams = [ 192 | uri: apiEndpoint, 193 | path: "/1/public/person/info", 194 | requestContentType: "application/json", 195 | //contentType: "application/json", 196 | headers: ["Authorization":"Bearer ${state.apiToken}"] 197 | ] 198 | //log.debug "getRachio Params: ${deviceListParams}" 199 | try { 200 | httpGet(deviceListParams) { resp -> 201 | //TRACE("resp: $resp.data") 202 | if (resp.status == 200) { 203 | 204 | resp.data.each { name, value -> 205 | if(name =="id") { 206 | state.id = value 207 | } 208 | } 209 | } else { 210 | log.debug "http status: ${resp.status}" 211 | } 212 | } 213 | } catch (e) { 214 | log.trace "Exception getting rachio id " + e 215 | state.connected = false 216 | return false 217 | } 218 | TRACE("User ID: $state.id") 219 | return true 220 | } 221 | //This function uses the User ID to retrieve the Device ID and details 222 | def getRachioDevices() { 223 | state.rachioDevices = [:] 224 | state.rachioControllers =[:] 225 | def myDevices = [:] 226 | def deviceListParams = [ 227 | uri: apiEndpoint, 228 | path: "/1/public/person/${state.id}", 229 | requestContentType: "application/json", 230 | //contentType: "application/json", 231 | headers: ["Authorization":"Bearer ${state.apiToken}"] 232 | ] 233 | log.debug "getRachioControllers: ${deviceListParams}" 234 | try { 235 | httpGet(deviceListParams) { resp -> 236 | if (resp.status == 200) { 237 | //This contains the summary of Controllers 238 | resp.data.devices.each { controllers -> 239 | state.rachioDevices[controllers.id.toString()] = controllers.name.toString() 240 | state.rachioControllers[controllers.id.toString()] = controllers.name.toString() 241 | myDevices[controllers.id.toString()] = controllers.name.toString() 242 | controllers.each { name, value -> 243 | if(name =="zones") { 244 | value.each{ zone -> 245 | zone.each{zname, zvalue -> 246 | if(zname == "name" && zone.enabled) { 247 | //TRACE("name: $zname, value: $zvalue") 248 | state.rachioDevices[zone.id.toString()] = zvalue.toString() 249 | myDevices[zone.id.toString()] = zvalue.toString() 250 | } 251 | 252 | 253 | } 254 | } 255 | 256 | 257 | } 258 | 259 | } 260 | } 261 | //resp.data.devices.each { name, value -> 262 | // TRACE("name: $name, value: $value") 263 | //} 264 | } else { 265 | log.debug "http status: ${resp.status}" 266 | } 267 | } 268 | } catch (e) { 269 | log.trace "Exception getting device ids " + e 270 | state.connected = false 271 | } 272 | //TRACE("Rachio Controllers: $state.rachioControllers") 273 | TRACE("Rachio Devices: ${myDevices}") 274 | return myDevices 275 | } 276 | 277 | def pollHandler() { 278 | TRACE("pollHandler()") 279 | pollChildren(null) // Hit the sensi API for update on all thermostats 280 | 281 | } 282 | 283 | def pollChildren() { 284 | def devices = getChildDevices() 285 | TRACE("Update Zones") 286 | try{ 287 | 288 | } catch (e) { 289 | log.error "Error $e in pollChildren() for $child.device.label" 290 | } 291 | devices.each { child -> 292 | 293 | 294 | } 295 | 296 | return true 297 | } 298 | def getSubscribed(thermostatIdsString) { 299 | if(state.lastSubscribedDNI == thermostatIdsString) { 300 | TRACE("Thermostat already subscribed") 301 | return true 302 | } else if(state.lastSubscribedDNI != null) { 303 | TRACE("Thermostat not subscribed") 304 | getUnsubscribed(state.lastSubscribedDNI) 305 | } 306 | TRACE("Getting subscribed to $thermostatIdsString") 307 | if(!state.connected) { getConnected() } 308 | if( state.RBCounter > 50) { 309 | state.RBCounter = 0 310 | } 311 | def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"Subscribe\",\"A\":[\"${thermostatIdsString}\"],\"I\":$state.RBCounter}"] 312 | state.RBCounter = state.RBCounter + 1 313 | 314 | 315 | def params = [ 316 | uri: getApiEndpoint(), 317 | path: '/realtime/send', 318 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 319 | 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"], 320 | body: requestBody 321 | ] 322 | 323 | try { 324 | 325 | httpPost(params) { resp -> 326 | TRACE( "Subscribe response: ${resp.data} Expected Response: {I:${state.RBCounter - 1}") 327 | if(resp?.data.I?.toInteger() == (state.RBCounter - 1)) { 328 | state.lastSubscribedDNI = thermostatIdsString 329 | TRACE("Subscribe successfully") 330 | } else { 331 | TRACE("Failed to subscribe") 332 | state.connected = false 333 | } 334 | } 335 | } catch (e) { 336 | log.error "getSubscribed failed: $e" 337 | state.connected = false 338 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 339 | } 340 | } 341 | 342 | def getUnsubscribed(thermostatIdsString) { 343 | 344 | //Unsubscribe from this device 345 | def requestBody3 = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"Unsubscribe\",\"A\":[\"${thermostatIdsString}\"],\"I\":$state.RBCounter}"] 346 | def params = [ 347 | uri: getApiEndpoint(), 348 | path: '/realtime/send', 349 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 350 | 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"], 351 | body: requestBody3 352 | ] 353 | state.RBCounter = state.RBCounter + 1 354 | try { 355 | 356 | httpPost(params) { resp -> 357 | TRACE( "resp 3: ${resp.data}") 358 | } 359 | } 360 | catch (e) { 361 | log.trace "Exception unsubscribing " + e 362 | state.connected = false 363 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 364 | } 365 | state.lastSubscribedDNI = null 366 | 367 | } 368 | def pollChildData(data) { 369 | def device = getChildDevice(data.value) 370 | TRACE("Scheduled re-poll of $device.deviceLabel") 371 | pollChild(data.value) 372 | } 373 | // Poll Child is invoked from the Child Device itself as part of the Poll Capability 374 | // If no dni is passed it will call pollChildren and poll all devices 375 | def pollChild(dni = null) { 376 | 377 | if(dni == null) { 378 | TRACE("dni in pollChild is $dni") 379 | pollChildren() 380 | return 381 | } 382 | def thermostatIdsString = dni 383 | 384 | def params = [] 385 | def result = false 386 | if(!state.connected || (state.messageId == null)) { 387 | getConnected() 388 | } 389 | getSubscribed(thermostatIdsString) 390 | params = [ 391 | uri: getApiEndpoint(), 392 | path: '/realtime/poll', 393 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]" 394 | ,connectionId:state.connectionId,messageId:state.messageId,tid:state.RBCounter,'_':now()], 395 | 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"] 396 | ] 397 | if(state.GroupsToken) { 398 | params.query = [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]" 399 | ,connectionId:state.connectionId,messageId:state.messageId,GroupsToken:state.GroupsToken,tid:state.RBCounter,'_':now()] 400 | } 401 | 402 | try{ 403 | httpGet(params) { resp -> 404 | def httpResp = resp.data.M[0].A[1] == null ? " " : resp.data.M[0].A[1] 405 | if(httpResp && (httpResp != true)) { 406 | state.thermostatResponse[thermostatIdsString] = httpResp 407 | TRACE("child.generateEvent=$httpResp") 408 | def myChild = getChildDevice(dni) 409 | myChild.generateEvent(httpResp) 410 | result = true 411 | } else { 412 | log.debug "Unexpected final resp: ${resp.data}" 413 | } 414 | if(resp.data.C) { 415 | state.messageId = resp.data.C 416 | 417 | } 418 | if(resp.data.G) { 419 | state.GroupsToken = resp.data.G 420 | } 421 | } 422 | state.RBCounter = state.RBCounter + 1 423 | } catch (e) { 424 | log.trace "Exception polling child $e repoll in 30 seconds" 425 | state.connected = false //This will trigger new authentication next time the poll occurs 426 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 427 | } 428 | 429 | return result 430 | } 431 | 432 | void poll() { 433 | pollChildren() 434 | } 435 | 436 | def availableModes(child) { 437 | 438 | 439 | def modes = ["off", "heat", "cool", "aux", "auto"] 440 | 441 | return modes 442 | } 443 | 444 | def currentMode(child) { 445 | debugEvent ("state.Thermos = ${state.thermostats}") 446 | debugEvent ("Child DNI = ${child.device.deviceNetworkId}") 447 | 448 | def tData = state.thermostatResponse[child.device.deviceNetworkId]?.EnvironmentControls 449 | 450 | //debugEvent("Data = ${tData}") 451 | 452 | if(!tData) { 453 | log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling" 454 | return null 455 | } 456 | 457 | def mode = tData?.SystemMode 458 | return mode 459 | } 460 | 461 | /** 462 | * Executes the cmdString and cmdVal 463 | * @param deviceId - the ID of the device 464 | * @cmdString is passed directly to Sensi Web 465 | * @cmdVal is the value to send on. 466 | * 467 | * @retrun true if the command was successful, false otherwise. 468 | */ 469 | 470 | boolean setStringCmd(deviceId, cmdString, cmdVal) { 471 | //getConnected() 472 | getSubscribed(deviceId) 473 | def result = sendDniStringCmd(deviceId,cmdString,cmdVal) 474 | log.debug "Setstring ${result}" 475 | //The sensi web app immediately polls the thermostat for updates after send before unsubscribe 476 | pollChild(deviceId) 477 | //getUnsubscribed(deviceId) 478 | return result 479 | } 480 | boolean setTempCmd(deviceId, cmdString, cmdVal) { 481 | //getConnected() 482 | getSubscribed(deviceId) 483 | def result = sendDniValue(deviceId,cmdString,cmdVal) 484 | log.debug "Setstring ${result}" 485 | //The sensi web app immediately polls the thermostat for updates after send before unsubscribe 486 | pollChild(deviceId) 487 | //getUnsubscribed 488 | return result 489 | } 490 | boolean sendDniValue(thermostatIdsString,cmdString,cmdVal) { 491 | def result = false 492 | def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"$cmdString\",\"A\":[\"${thermostatIdsString}\",$cmdVal,\"$location.temperatureScale\"],\"I\":$state.RBCounter}"] 493 | 494 | TRACE( "sendDNIValue body: ${requestBody}") 495 | def params = [ 496 | uri: getApiEndpoint(), 497 | path: '/realtime/send', 498 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 499 | 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"], 500 | body: requestBody 501 | ] 502 | 503 | try { 504 | 505 | httpPost(params) { resp -> 506 | 507 | if (resp.data.I.toInteger() == state.RBCounter.toInteger()) { 508 | result = true 509 | } 510 | state.RBCounter = state.RBCounter + 1 511 | } 512 | } catch (e) { 513 | log.error "Send DNI Command went wrong: $e" 514 | state.connected = false 515 | state.RBCounter = state.RBCounter + 1 516 | 517 | } 518 | 519 | return result 520 | } 521 | boolean sendDniStringCmd(thermostatIdsString,cmdString,cmdVal) { 522 | def result = false 523 | def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"$cmdString\",\"A\":[\"${thermostatIdsString}\",\"$cmdVal\"],\"I\":$state.RBCounter}"] 524 | 525 | def params = [ 526 | uri: getApiEndpoint(), 527 | path: '/realtime/send', 528 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 529 | 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"], 530 | body: requestBody 531 | ] 532 | 533 | try { 534 | 535 | httpPost(params) { resp -> 536 | 537 | if (resp.data.I.toInteger() == state.RBCounter.toInteger()) { 538 | result = true 539 | } 540 | state.RBCounter = state.RBCounter + 1 541 | } 542 | } catch (e) { 543 | log.error "Send DNI Command went wrong: $e" 544 | state.connected = false 545 | state.RBCounter = state.RBCounter + 1 546 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 547 | 548 | } 549 | TRACE( "Send Function : $result") 550 | return result 551 | } 552 | 553 | 554 | def getChildName() { return "Unofficial Rachio Zone" } 555 | def getControllerName() { return "Unofficial Rachio Controller" } 556 | def getServerUrl() { return "https://graph.api.smartthings.com" } 557 | def getApiEndpoint() { return "https://api.rach.io" } 558 | 559 | 560 | def sendActivityFeeds(notificationMessage) { 561 | def devices = getChildDevices() 562 | devices.each { child -> 563 | child.generateActivityFeedsEvent(notificationMessage) //parse received message from parent 564 | } 565 | } 566 | 567 | private def TRACE(message) { 568 | log.debug message 569 | } -------------------------------------------------------------------------------- /smartapps/kirkbrownok/bmw-connected/bmw-connected-drive-i3-connect.src/bmw-connected-drive-i3-connect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Kirk Brown 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at: 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 11 | * for the specific language governing permissions and limitations under the License. 12 | * 13 | * BMW Connected Drive service manager to create car device 14 | * 15 | * Author: Kirk Brown 16 | * Date: 2017-9-1 17 | * Place the BMW (Connect) code under the My SmartApps section. Be certain you publish the app for you. 18 | * Place the BMW Car Device Type Handler under My Device Handlers section. 19 | * Be careful that if you change the Name and Namespace you that additionally change it in the addChildDevice() function 20 | * 21 | * 22 | * The Program Flow is as follows: 23 | * 1. SmartApp gets user credentials in the install process. 24 | * 2. The SmartApp gets the user’s car and list it for subscription in the SmartApp. -> I can only afford 1 BMW so i'm writing for 1 car. 25 | * a. The smartApp uses the user’s credentials to get authorized, get a connection token, and then list the car 26 | * 3. The User then selects the desired car(s) to add to SmartThings -> Can't test multiple cars, don't intend to write for that. 27 | * 4. The SmartApp schedules a refresh/poll of the car every so often. Default is 60 minutes for now but can be changed in the install configurations. The interface is not official, so polling to often could get noticed. 28 | * 5. The car can be polled by a pollster type SmartApp. 29 | * 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 30 | */ 31 | 32 | definition( 33 | name: "BMW Connected Drive i3 (Connect)", 34 | namespace: "kirkbrownOK/BMW_Connected", 35 | author: "Kirk Brown", 36 | description: "Connect your BMW i3 to SmartThings.", 37 | category: "SmartThings Labs", 38 | iconUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/BMW.svg/1200px-BMW.svg.png", 39 | iconX2Url: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/BMW.svg/1200px-BMW.svg.png", 40 | singleInstance: true 41 | ) 42 | 43 | preferences { 44 | page(name: "auth", title: "BMW Connected", nextPage:"", content:"authPage", uninstall: true) 45 | page(name: "getDevicesPage", title: "BMW Cars", nextPage:"", content:"getDevicesPage", uninstall: true, install: true) 46 | } 47 | 48 | def authPage() { 49 | 50 | def description 51 | def uninstallAllowed = false 52 | if(state.connectionToken) { 53 | description = "You are connected." 54 | uninstallAllowed = true 55 | } else { 56 | description = "Click to enter BMW Credentials" 57 | } 58 | 59 | return dynamicPage(name: "auth", title: "Login", nextPage: "getDevicesPage", uninstall:uninstallAllowed) { 60 | section() { 61 | paragraph "This connected app is not sanctioned by BMW. It requires api key, and secret from the Android/iPhone app in order to work. "+ 62 | "according to edent: \"You can get the i Remote details from either decompiling the Android App or from intercepting communications "+ 63 | "between your phone and the BMW server. This is left as an exercise for the reader. see: https://github.com/edent/BMW-i-Remote "+ 64 | "The key will look like -> Authorization: Basic APIKEY:APISECRET where APIKEY:APISECRET is a crazy encoded hex string. Use the encoded " 65 | "hex string for the following input." 66 | input("encodedSecret", "string", title: " API Key and Secret", required: true, displayDuringSetup: true, defaultValue:"blF2NkNxdHhKdVhXUDc0eGYzQ0p3VUVQOjF6REh4NnVuNGNEanliTEVOTjNreWZ1bVgya0VZaWdXUGNRcGR2RFJwSUJrN3JPSg==") 67 | paragraph "Enter your Username and Password for BMW Connected Drive. Your username and password will be saved in SmartThings in whatever secure/insecure manner SmartThings saves them." 68 | input("userName", "string", title:"BMW Email Address", required:true, displayDuringSetup: true,defaultValue: "tokirk.brown@gmail.com") 69 | input("userPassword", "password", title:"BMW account password", required:true, displayDuringSetup:true,defaultValue:"Transport3") 70 | input("secretAnswer", "password", title:"Answer to your security question from BMW Connected Account", required: true, displayDuringSetup: true, defaultValue:"Iron Druid") 71 | input("pollInput", "number", title: "How often should ST poll BMW Connected Drive? (minutes)", required: false, defaultValue: 30, displayDuringSetup: true) 72 | } 73 | } 74 | 75 | } 76 | def getDevicesPage() { 77 | getAuthorized() 78 | 79 | def myCars = getBMWCars() 80 | return dynamicPage(name: "getDevicesPage", title: "Select Your Cars", uninstall: true) { 81 | section("") { 82 | paragraph "Tap below to see the list of cars available in your BMW account and select the ones you want to connect to SmartThings." 83 | input(name: "cars", title:"", type: "enum", required:false, multiple:true, description: "Tap to choose", metadata:[values:myCars]) 84 | 85 | } 86 | } 87 | } 88 | 89 | 90 | def getCarDisplayName(myCar) { 91 | if(myCar?.model) { 92 | def nameString = "${myCar.yearOfConstruction} BMW ${myCar.model}" 93 | return nameString 94 | } 95 | return "Unknown" 96 | } 97 | 98 | def installed() { 99 | log.info "Installed with settings: ${settings}" 100 | initialize() 101 | } 102 | 103 | def updated() { 104 | state.refresh_token = null 105 | log.info "Updated with settings: ${settings}" 106 | unsubscribe() 107 | unschedule() 108 | initialize() 109 | } 110 | 111 | def initialize() { 112 | 113 | getAuthorized() 114 | 115 | def devices = cars.collect { vin -> 116 | def d = getChildDevice(vin) 117 | if(!d) { 118 | TRACE( "addChildDevice($app.namespace, ${getChildName()}, $vin, null, [\"label\":\"${state.bmwCars[vin]}\" : \"BMW Car\"])") 119 | d = addChildDevice(app.namespace, getChildName(), vin, null, ["label":"${state.bmwCars[vin]}" ?: "BMW Car"]) 120 | log.info "created ${d.displayName} with id $vin" 121 | } else { 122 | log.info "found ${d.displayName} with id $vin already exists" 123 | } 124 | return d 125 | } 126 | 127 | TRACE( "created ${devices.size()} car(s).") 128 | 129 | def delete // Delete any that are no longer in settings 130 | if(!cars) { 131 | log.info "delete cars" 132 | delete = getAllChildDevices() //inherits from SmartApp (data-management) 133 | } else { //delete only thermostat 134 | log.info "delete individual car" 135 | delete = getChildDevices().findAll { !cars.contains(it.deviceNetworkId) } 136 | } 137 | log.warn "delete: ${delete}, deleting ${delete.size()} cars" 138 | delete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management) 139 | 140 | //send activity feeds to tell that device is connected 141 | def notificationMessage = "is connected to SmartThings" 142 | sendActivityFeeds(notificationMessage) 143 | state.timeSendPush = null 144 | state.reAttempt = 0 145 | 146 | try{ 147 | poll() //first time polling data from device 148 | } catch (e) { 149 | log.warn "Error in first time polling. Could mean something is wrong." 150 | } 151 | //automatically update devices status every 5 mins 152 | def pollRate = pollInput == null ? 5 : pollInput 153 | if(pollRate > 59 || pollRate < 1) { 154 | pollRate = 58 155 | log.warn "You picked an invalid pollRate: $pollInput minutes. Changed to 58 minutes." 156 | } 157 | schedule("0 0/${pollRate} * * * ?","poll") 158 | 159 | 160 | } 161 | 162 | def getAuthorized() { 163 | if ( (state.expiresAt > now() )&&( state.refresh_token != null)) { 164 | TRACE("Still authorized-> not refreshing auth\n $now() expires at $state.expiresAt") 165 | return 166 | } 167 | def deviceListParams = [:] 168 | if( state.refresh_token == null) { 169 | TRACE("Authorizing using passwords") 170 | deviceListParams = [ 171 | uri: getApiEndpoint(), 172 | path: "/webapi/oauth/token/", 173 | headers: ["Content-Type": "application/x-www-form-urlencoded","Authorization": "Basic ${encodedSecret}"], 174 | body: [grant_type: "password", password: userPassword, username: userName, scope: "remote_services vehicle_data" ] 175 | ] 176 | } else { 177 | TRACE("Authorizing using Refresh Token") 178 | deviceListParams = [ 179 | uri: getApiEndpoint(), 180 | path: "/webapi/oauth/token/", 181 | headers: ["Content-Type": "application/x-www-form-urlencoded","Authorization": "Basic ${encodedSecret}"], 182 | body: [grant_type: "refresh_token", refresh_token: "${state.refresh_token}"] 183 | ] 184 | } 185 | try { 186 | httpPost(deviceListParams) { resp -> 187 | if (resp.status == 200) { 188 | TRACE("Status 200") 189 | try{ 190 | TRACE("Headers:") 191 | resp.headers.each { 192 | //TRACE( "${it.name} : ${it.value}") 193 | 194 | } 195 | } catch(e) { 196 | TRACE("error $e in headers") 197 | } 198 | try{ 199 | resp.data.each { name, value -> 200 | //TRACE("name:${name} , value: ${value}") 201 | if(name == "access_token") { 202 | state.access_token = value 203 | } else if( name == "token_type") { 204 | state.token_type = value 205 | } else if( name == "expires_in") { 206 | state.expires_in = value 207 | state.expiresAt = now() + value*1000 208 | def tempNow = now() 209 | 210 | } else if( name == "refresh_token") { 211 | state.refresh_token = value 212 | } else if( name == "scope") { 213 | state.scope = value 214 | } 215 | } 216 | }catch(e) { 217 | log.info "No new data" 218 | } 219 | printState() 220 | } else { 221 | TRACE( "http status: ${resp.status}") 222 | } 223 | } 224 | } catch (e) { 225 | log.warn "Exception trying to authenticate $e" 226 | } 227 | 228 | } 229 | def printState() { 230 | TRACE("State:\naccess_token: $state.access_token\ntoken_type: $state.token_type\nexpires_in: $state.expires_in\nrefresh_token: $state.refresh_token\nscope: $state.scope") 231 | } 232 | 233 | def getBMWCars() { 234 | TRACE("Getting Cars") 235 | state.bmwCars = [] 236 | def deviceListParams = [ 237 | uri: apiEndpoint, 238 | path: "/webapi/v1/user/vehicles/", 239 | contentType: 'application/json', 240 | headers: ["Authorization":"$state.token_type $state.access_token"] 241 | ] 242 | def myCars = [:] 243 | try { 244 | httpGet(deviceListParams) { resp -> 245 | if (resp.status == 200) { 246 | 247 | resp.data.vehicles.each { myCar -> 248 | TRACE("myCar:\n$myCar") 249 | state.bmwCars = state.bmwCars == null ? myCar.model : state.bmwCars << myCar.model 250 | def vin = myCar.vin 251 | myCars[vin] = getCarDisplayName(myCar) 252 | } 253 | } else { 254 | state.refresh_token = null 255 | log.warn "Failed to get car list in getBMWCars: ${resp.status}" 256 | } 257 | } 258 | } catch (e) { 259 | log.trace "Exception getting cars: " + e 260 | state.refresh_token = null 261 | } 262 | state.bmwCars = myCars 263 | state.bmwResponse = myCars 264 | return myCars 265 | } 266 | def pollHandler() { 267 | //log.debug "pollHandler()" 268 | pollChildren(null) // Hit the sensi API for update on all thermostats 269 | 270 | } 271 | 272 | def pollChildren() { 273 | def devices = getChildDevices() 274 | devices.each { child -> 275 | TRACE("pollChild($child.device.deviceNetworkId)") 276 | try{ 277 | if(pollChild(child.device.deviceNetworkId)) { 278 | TRACE("pollChildren successful") 279 | 280 | } else { 281 | log.warn "pollChildren FAILED for $child.device.label" 282 | state.connected = false 283 | runIn(30, poll) 284 | } 285 | } catch (e) { 286 | state.refresh_token = null 287 | log.error "Error $e in pollChildren() for $child.device.label" 288 | } 289 | } 290 | return true 291 | } 292 | def getSubscribed(thermostatIdsString) { 293 | /* 294 | if(state.lastSubscribedDNI == thermostatIdsString) { 295 | TRACE("Thermostat already subscribed") 296 | return true 297 | } else */ 298 | if(state.lastSubscribedDNI != null) { 299 | TRACE("Unsubscribing from: $state.lastSubscribedDNI") 300 | getUnsubscribed(state.lastSubscribedDNI) 301 | } 302 | TRACE("Getting subscribed to $thermostatIdsString") 303 | if(!state.connected) { getConnected() } 304 | if( state.RBCounter > 50) { 305 | state.RBCounter = 0 306 | } 307 | def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"Subscribe\",\"A\":[\"${thermostatIdsString}\"],\"I\":$state.RBCounter}"] 308 | state.RBCounter = state.RBCounter + 1 309 | 310 | 311 | def params = [ 312 | uri: getApiEndpoint(), 313 | path: '/realtime/send', 314 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 315 | 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"], 316 | body: requestBody 317 | ] 318 | 319 | try { 320 | 321 | httpPost(params) { resp -> 322 | TRACE( "Subscribe response: ${resp.data} Expected Response: [I:${state.RBCounter - 1}]") 323 | if(resp?.data.I?.toInteger() == (state.RBCounter - 1)) { 324 | state.lastSubscribedDNI = thermostatIdsString 325 | TRACE("Subscribe successfully") 326 | } else { 327 | TRACE("Failed to subscribe") 328 | state.connected = false 329 | } 330 | } 331 | } catch (e) { 332 | log.error "getSubscribed failed: $e" 333 | state.connected = false 334 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 335 | } 336 | } 337 | 338 | def pollChildData(data) { 339 | def device = getChildDevice(data.value) 340 | log.info "Scheduled re-poll of $device.deviceLabel $data.value $device.label" 341 | pollChild(data.value) 342 | } 343 | public deviceTimeDateFormat() { "yyyy-MM-dd'T'HH:mm:ss" } 344 | // Poll Child is invoked from the Child Device itself as part of the Poll Capability 345 | // If no dni is passed it will call pollChildren and poll all devices 346 | def pollChild(vin = null) { 347 | TRACE("poll child called for ${vin}") 348 | if(vin == null) { 349 | TRACE("No vin calling all cars") 350 | pollChildren() 351 | return 352 | } 353 | def vinString = vin 354 | 355 | def params = [] 356 | def result = false 357 | getAuthorized() 358 | 359 | def timeString = new Date(now()+ location.timeZone.rawOffset + location.timeZone.dstSavings ).format(deviceTimeDateFormat()) 360 | TRACE("devTime: ${timeString}") 361 | 362 | params = [ 363 | uri: getApiEndpoint(), 364 | path: "/webapi/v1/user/vehicles/${vinString}/status", 365 | contentType: 'application/json', 366 | headers: ["Authorization":"$state.token_type $state.access_token"], 367 | query: [deviceTime: "${timeString}"] 368 | ] 369 | TRACE("Status Params:\n $params") 370 | 371 | try{ 372 | httpGet(params) { resp -> 373 | TRACE("Status GET:\n$resp.data") 374 | def httpResp = resp.data.vehicleStatus == null ? " " : resp.data.vehicleStatus 375 | if(httpResp && (httpResp != true)) { 376 | state.bmwResponse[vinString] = httpResp 377 | TRACE("child.generateEvent=$httpResp") 378 | def myChild = getChildDevice(vin) 379 | myChild.generateEvent(httpResp) 380 | result = true 381 | } else { 382 | httpResp = resp.data.vehicleStatus == null ? " " : resp.data.vehicleStatus 383 | log.warn "Unexpected final resp in pollChild: ${resp.data} likely offline: $httpResp" 384 | } 385 | } 386 | 387 | } catch (e) { 388 | state.refresh_token = null 389 | log.error "Exception in pollChild: $e " 390 | log.error "repoll in 30 seconds. Re-poll: $vinString" 391 | 392 | //runIn(30, pollChildData,[data: [value: vinString], overwrite: true]) //when user click button this runIn will be overwrite 393 | } 394 | 395 | return result 396 | } 397 | 398 | void poll() { 399 | pollChildren() 400 | } 401 | 402 | def availableModes(child) { 403 | 404 | 405 | def modes = ["off", "heat", "cool", "aux", "auto"] 406 | 407 | return modes 408 | } 409 | 410 | def currentMode(child) { 411 | debugEvent ("state.Thermos = ${state.thermostats}") 412 | debugEvent ("Child DNI = ${child.device.deviceNetworkId}") 413 | 414 | def tData = state.thermostatResponse[child.device.deviceNetworkId]?.EnvironmentControls 415 | 416 | //debugEvent("Data = ${tData}") 417 | 418 | if(!tData) { 419 | log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling" 420 | return null 421 | } 422 | 423 | def mode = tData?.SystemMode 424 | return mode 425 | } 426 | 427 | /** 428 | * Executes the cmdString and cmdVal 429 | * @param deviceId - the ID of the device 430 | * @cmdString is passed directly to Sensi Web 431 | * @cmdVal is the value to send on. 432 | * 433 | * @retrun true if the command was successful, false otherwise. 434 | */ 435 | boolean sendExecuteService(deviceId, serviceType, useKey = 0) { 436 | state.lastServiceType = serviceType 437 | state.lastVIN = deviceId 438 | def bodyParams = [] 439 | if (useKey == 1) { 440 | bodyParams = ["serviceType" : "$serviceType",extendedStatusUpdates: "false",bmwSkAnswer:"$secretAnswer"] 441 | } else { 442 | bodyParams = ["serviceType" : "$serviceType",extendedStatusUpdates: "false"] 443 | } 444 | def params = [ 445 | uri: getApiEndpoint(), 446 | path: "/webapi/v1/user/vehicles/${deviceId}/executeService", 447 | headers: ['Content-Type':'application/x-www-form-urlencoded',"Authorization":"$state.token_type $state.access_token"], 448 | body: bodyParams 449 | ] 450 | TRACE("SES:\n$params") 451 | try { 452 | 453 | httpPost(params) { resp -> 454 | TRACE("sendCmd: $resp.data") 455 | 456 | } 457 | } catch (e) { 458 | log.warn "Send executeService Command went wrong: $e" 459 | 460 | } 461 | runIn(60,checkCommand) 462 | return true 463 | } 464 | def checkCommand() { 465 | def data = null 466 | def params = [ 467 | uri: getApiEndpoint(), 468 | path: "/webapi/v1/user/vehicles/${state.lastVIN}/serviceExecutionStatus", 469 | headers: ['Content-Type':'application/x-www-form-urlencoded',"Authorization":"$state.token_type $state.access_token"], 470 | query: ["serviceType" : "$state.lastServiceType"] 471 | ] 472 | TRACE("serviceStatus:\n$params") 473 | try { 474 | 475 | httpGet(params) { resp -> 476 | TRACE("serviceStatus: $resp.data") 477 | data = resp 478 | } 479 | } catch (e) { 480 | log.warn "Get executeService status went wrong: $e" 481 | 482 | } 483 | return data 484 | } 485 | boolean setStringCmd(deviceId, cmdString, cmdVal) { 486 | //getConnected() 487 | getSubscribed(deviceId) 488 | def result = sendDniStringCmd(deviceId,cmdString,cmdVal) 489 | TRACE( "Setstring ${result}") 490 | //The sensi web app immediately polls the thermostat for updates after send before unsubscribe 491 | pollChild(deviceId) 492 | getUnsubscribed(deviceId) 493 | return result 494 | } 495 | boolean setSettingsStringCmd(deviceId,cmdSettings, cmdString, cmdVal) { 496 | //getConnected() 497 | getSubscribed(deviceId) 498 | def result = sendDniSettingsStringCmd(deviceId,cmdSettings,cmdString,cmdVal) 499 | TRACE( "Setstring ${result}") 500 | //The sensi web app immediately polls the thermostat for updates after send before unsubscribe 501 | pollChild(deviceId) 502 | getUnsubscribed(deviceId) 503 | return result 504 | } 505 | boolean setTempCmd(deviceId, cmdString, cmdVal) { 506 | //getConnected() 507 | getSubscribed(deviceId) 508 | def result = sendDniValue(deviceId,cmdString,cmdVal) 509 | TRACE( "Setstring ${result}") 510 | //The sensi web app immediately polls the thermostat for updates after send before unsubscribe 511 | pollChild(deviceId) 512 | getUnsubscribed 513 | return result 514 | } 515 | boolean sendDniValue(thermostatIdsString,cmdString,cmdVal) { 516 | def result = false 517 | def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"$cmdString\",\"A\":[\"${thermostatIdsString}\",$cmdVal,\"$location.temperatureScale\"],\"I\":$state.RBCounter}"] 518 | 519 | TRACE( "sendDNIValue body: ${requestBody}") 520 | def params = [ 521 | uri: getApiEndpoint(), 522 | path: '/realtime/send', 523 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 524 | 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"], 525 | body: requestBody 526 | ] 527 | 528 | try { 529 | 530 | httpPost(params) { resp -> 531 | 532 | if (resp.data.I.toInteger() == state.RBCounter.toInteger()) { 533 | result = true 534 | } 535 | state.RBCounter = state.RBCounter + 1 536 | } 537 | } catch (e) { 538 | log.warn "Send DNI Command went wrong: $e" 539 | state.connected = false 540 | state.RBCounter = state.RBCounter + 1 541 | 542 | } 543 | 544 | return result 545 | } 546 | boolean sendDniStringCmd(thermostatIdsString,cmdString,cmdVal) { 547 | def result = false 548 | def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"$cmdString\",\"A\":[\"${thermostatIdsString}\",\"$cmdVal\"],\"I\":$state.RBCounter}"] 549 | 550 | def params = [ 551 | uri: getApiEndpoint(), 552 | path: '/realtime/send', 553 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 554 | 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"], 555 | body: requestBody 556 | ] 557 | 558 | try { 559 | 560 | httpPost(params) { resp -> 561 | 562 | if (resp.data.I.toInteger() == state.RBCounter.toInteger()) { 563 | result = true 564 | } 565 | state.RBCounter = state.RBCounter + 1 566 | } 567 | } catch (e) { 568 | log.warn "Send DNI String Command went wrong: $e" 569 | state.connected = false 570 | state.RBCounter = state.RBCounter + 1 571 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 572 | 573 | } 574 | TRACE( "Send Function : $result") 575 | return result 576 | } 577 | /* {"H":"thermostat-v1","M":"ChangeSetting","A":["thermostatid is here","KeypadLockout","Off"],"I":8} */ 578 | boolean sendDniSettingsStringCmd(thermostatIdsString,cmdSettings,cmdString,cmdVal) { 579 | def result = false 580 | def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"$cmdSettings\",\"A\":[\"${thermostatIdsString}\",\"$cmdString\",\"$cmdVal\"],\"I\":$state.RBCounter}"] 581 | 582 | def params = [ 583 | uri: getApiEndpoint(), 584 | path: '/realtime/send', 585 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 586 | 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"], 587 | body: requestBody 588 | ] 589 | 590 | try { 591 | 592 | httpPost(params) { resp -> 593 | 594 | if (resp.data.I.toInteger() == state.RBCounter.toInteger()) { 595 | result = true 596 | } 597 | state.RBCounter = state.RBCounter + 1 598 | } 599 | } catch (e) { 600 | log.warn "Send DNI Setting String Command went wrong: $e" 601 | state.connected = false 602 | state.RBCounter = state.RBCounter + 1 603 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 604 | 605 | } 606 | TRACE( "Send Function : $result") 607 | return result 608 | } 609 | 610 | def getChildName() { return "BMW Connected Car" } 611 | def getServerUrl() { return "https://graph.api.smartthings.com" } 612 | def getApiEndpoint() { return "https://b2vapi.bmwgroup.us" } 613 | def getApiEndpoint2() { return "https://tnrtkrucm4ig.runscope.net" } 614 | //tnrtkrucm4ig.runscope.net 615 | 616 | def debugEvent(message, displayEvent = false) { 617 | def results = [ 618 | name: "appdebug", 619 | descriptionText: message, 620 | displayed: displayEvent 621 | ] 622 | log.debug "Generating AppDebug Event: ${results}" 623 | sendEvent (results) 624 | } 625 | 626 | def sendActivityFeeds(notificationMessage) { 627 | def devices = getChildDevices() 628 | devices.each { child -> 629 | child.generateActivityFeedsEvent(notificationMessage) //parse received message from parent 630 | } 631 | } 632 | 633 | private def TRACE(message) { 634 | log.trace message 635 | } -------------------------------------------------------------------------------- /smartapps/kirkbrownok/sensithermostat/sensi-connect.src/sensi-connect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2016 Kirk Brown 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at: 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 11 | * for the specific language governing permissions and limitations under the License. 12 | * 13 | * Emerson Sensi Community Created Service Manager 14 | * 15 | * Author: Kirk Brown 16 | * Date: 2016-12-24 17 | * Date: 2017-01-02 The Sensi Connect App is near fully functional 18 | * Date: 2017-01-07 The Sensi Connect App has been changed to allow individual device polling. 19 | * It also polls a thermostat immediately after sending a command 20 | * 21 | * Date: 2017-05-02 Changed the frequency of subscription/unsubscribe. Any time a new poll or before command subscribe new. 22 | * 23 | * Place the Sensi (Connect) code under the My SmartApps section. Be certain you publish the app for you. 24 | * Place the Sensi Thermostat Device Type Handler under My Device Handlers section. 25 | * Be careful that if you change the Name and Namespace you that additionally change it in the addChildDevice() function 26 | * 27 | * 28 | * The Program Flow is as follows: 29 | * 1. SmartApp gets user credentials in the install process. 30 | * 2. The SmartApp gets the user’s thermostats and lists them for subscription in the SmartApp. 31 | * a. The smartApp uses the user’s credentials to get authorized, get a connection token, and then list the thermostats 32 | * 3. The User then selects the desired thermostats to add to SmartThings 33 | * 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. 34 | * 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 don’t use pollster for more than 1 thermostat device -> if you do then all devices will get updated each time. 35 | * 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. 36 | * 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 37 | */ 38 | 39 | definition( 40 | name: "Sensi (Connect)", 41 | namespace: "kirkbrownOK/SensiThermostat", 42 | author: "Kirk Brown", 43 | description: "Connect your Sensi thermostats to SmartThings.", 44 | category: "SmartThings Labs", 45 | iconUrl: "http://i.imgur.com/QVbsCpu.jpg", 46 | iconX2Url: "http://i.imgur.com/4BfQn6I.jpg", 47 | singleInstance: true 48 | ) 49 | 50 | preferences { 51 | page(name: "auth", title: "Sensi", nextPage:"", content:"authPage", uninstall: true) 52 | page(name: "getDevicesPage", title: "Sensi Devices", nextPage:"", content:"getDevicesPage", uninstall: true, install: true) 53 | } 54 | 55 | def authPage() { 56 | 57 | def description 58 | def uninstallAllowed = false 59 | if(state.connectionToken) { 60 | description = "You are connected." 61 | uninstallAllowed = true 62 | } else { 63 | description = "Click to enter Sensi Credentials" 64 | } 65 | 66 | return dynamicPage(name: "auth", title: "Login", nextPage: "getDevicesPage", uninstall:uninstallAllowed) { 67 | section() { 68 | 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." 69 | input("userName", "string", title:"Sensi Email Address", required:true, displayDuringSetup: true) 70 | input("userPassword", "password", title:"Sensi account password", required:true, displayDuringSetup:true) 71 | input("pollInput", "number", title: "How often should ST poll Sensi Thermostat? (minutes)", required: false, displayDureingSetup: true) 72 | } 73 | } 74 | 75 | } 76 | def getDevicesPage() { 77 | getConnected() 78 | 79 | def stats = getSensiThermostats() 80 | return dynamicPage(name: "getDevicesPage", title: "Select Your Thermostats", uninstall: true) { 81 | section("") { 82 | 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." 83 | input(name: "thermostats", title:"", type: "enum", required:false, multiple:true, description: "Tap to choose", metadata:[values:stats]) 84 | 85 | } 86 | } 87 | } 88 | 89 | 90 | def getThermostatDisplayName(stat) { 91 | if(stat?.DeviceName) { 92 | return stat.DeviceName.toString() 93 | } 94 | return "Unknown" 95 | } 96 | 97 | def installed() { 98 | log.info "Installed with settings: ${settings}" 99 | initialize() 100 | } 101 | 102 | def updated() { 103 | log.info "Updated with settings: ${settings}" 104 | unsubscribe() 105 | unschedule() 106 | initialize() 107 | } 108 | 109 | def initialize() { 110 | 111 | getAuthorized() 112 | getToken() 113 | 114 | def devices = thermostats.collect { dni -> 115 | def d = getChildDevice(dni) 116 | if(!d) { 117 | TRACE( "addChildDevice($app.namespace, ${getChildName()}, $dni, null, [\"label\":\"${state.thermostats[dni]}\" : \"Sensi Thermostat\"])") 118 | d = addChildDevice(app.namespace, getChildName(), dni, null, ["label":"${state.thermostats[dni]}" ?: "Sensi Thermostat"]) 119 | log.info "created ${d.displayName} with id $dni" 120 | } else { 121 | log.info "found ${d.displayName} with id $dni already exists" 122 | } 123 | return d 124 | } 125 | 126 | TRACE( "created ${devices.size()} thermostats.") 127 | 128 | def delete // Delete any that are no longer in settings 129 | if(!thermostats) { 130 | log.info "delete thermostats ands sensors" 131 | delete = getAllChildDevices() //inherits from SmartApp (data-management) 132 | } else { //delete only thermostat 133 | log.info "delete individual thermostat" 134 | delete = getChildDevices().findAll { !thermostats.contains(it.deviceNetworkId) } 135 | } 136 | log.warn "delete: ${delete}, deleting ${delete.size()} thermostats" 137 | delete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management) 138 | 139 | //send activity feeds to tell that device is connected 140 | def notificationMessage = "is connected to SmartThings" 141 | sendActivityFeeds(notificationMessage) 142 | state.timeSendPush = null 143 | state.reAttempt = 0 144 | 145 | try{ 146 | poll() //first time polling data data from thermostat 147 | } catch (e) { 148 | log.warn "Error in first time polling. Could mean something is wrong." 149 | } 150 | //automatically update devices status every 5 mins 151 | def pollRate = pollInput == null ? 5 : pollInput 152 | if(pollRate > 59 || pollRate < 1) { 153 | pollRate = 5 154 | log.warn "You picked an invalid pollRate: $pollInput minutes. Changed to 5 minutes." 155 | } 156 | schedule("0 0/${pollRate} * * * ?","poll") 157 | 158 | 159 | } 160 | 161 | def getAuthorized() { 162 | def bodyParams = [ Password: "${userPassword}", UserName: "${userName}" ] 163 | state.RBCounter = 2 164 | state.sendCounter= 0 165 | state.GroupsToken = null 166 | def deviceListParams = [ 167 | uri: getApiEndpoint(), 168 | path: "/api/authorize", 169 | headers: ["Content-Type": "application/json", "Accept": "application/json; version=1, */*; q=0.01", "X-Requested-With":"XMLHttpRequest"], 170 | body: [ Password: userPassword, UserName: userName ] 171 | ] 172 | try { 173 | httpPostJson(deviceListParams) { resp -> 174 | //log.debug "Resp Headers: ${resp.headers}" 175 | 176 | if (resp.status == 200) { 177 | resp.headers.each { 178 | //log.debug "${it.name} : ${it.value}" 179 | if (it.name == "Set-Cookie") { 180 | //log.debug "Its SETCOOKIE ${it.value}" 181 | //state.myCookie = it.value 182 | def tempC = it.value.split(";") 183 | tempC = tempC[0].trim() 184 | if(tempC == state.myCookie) { 185 | //log.debug "Cookie didn't change" 186 | } else { 187 | state.myCookie = tempC 188 | //log.debug "My Cookie: ${state.myCookie}" 189 | } 190 | } 191 | } 192 | } else { 193 | TRACE( "http status: ${resp.status}") 194 | } 195 | } 196 | } catch (e) { 197 | log.warn "Exception trying to authenticate $e" 198 | } 199 | 200 | } 201 | 202 | def getToken() { 203 | //log.debug "GetToken" 204 | def params = [ 205 | uri: getApiEndpoint(), 206 | path: '/realtime/negotiate', 207 | requestContentType: 'application/json', 208 | contentType: 'application/json', 209 | headers: [ 210 | 'Cookie':state.myCookie, 211 | 'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip' 212 | ] 213 | ] 214 | try { 215 | httpGet(params) { resp -> 216 | state.connectionToken = resp.data.ConnectionToken 217 | state.connectionId = resp.data.ConnectionId 218 | } 219 | } catch (e) { 220 | log.error "Connection Token error $e" 221 | } 222 | 223 | } 224 | def getConnected() { 225 | getAuthorized() 226 | getToken() 227 | log.info "GetConnected" 228 | 229 | def params = [ 230 | 231 | uri: getApiEndpoint(), 232 | path: '/realtime/connect', 233 | query: [ 234 | transport:'longPolling', 235 | connectionToken:state.connectionToken, 236 | connectionData:"[{\"name\": \"thermostat-v1\"}]", 237 | connectionId:state.connectionId, 238 | tid:state.RBCounter,"_":now() 239 | ], 240 | contentType: 'application/json', 241 | headers: ['Cookie':state.myCookie,'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip'] 242 | ] 243 | try { 244 | httpGet(params) { resp -> 245 | if(resp.data.C) { 246 | state.messageId= resp.data.C 247 | //log.debug "MessageID: ${state.messageId}" 248 | } 249 | state.connected = true 250 | state.RBCounter = state.RBCounter + 1 251 | state.lastSubscribedDNI = null 252 | } 253 | } catch (e) { 254 | log.error "Get Connected went wrong: $e" 255 | state.connected = false 256 | } 257 | } 258 | def getSensiThermostats() { 259 | TRACE("getting device list") 260 | state.sensiSensors = [] 261 | def deviceListParams = [ 262 | uri: apiEndpoint, 263 | path: "/api/thermostats", 264 | requestContentType: 'application/json', 265 | contentType: 'application/json', 266 | headers: ['Cookie':state.myCookie,'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip'] 267 | ] 268 | //log.debug "Get Stats: ${deviceListParams}" 269 | def stats = [:] 270 | try { 271 | httpGet(deviceListParams) { resp -> 272 | 273 | if (resp.status == 200) { 274 | TRACE ("resp.data.DeviceName: ${resp.data.DeviceName}") 275 | resp.data.each { stat -> 276 | 277 | state.sensiSensors = state.sensiSensors == null ? stat.DeviceName : state.sensiSensors << stat.DeviceName 278 | def dni = stat.ICD 279 | stats[dni] = getThermostatDisplayName(stat) 280 | } 281 | } else { 282 | log.warn "Failed to get thermostat list in getSensiThermostats: ${resp.status}" 283 | } 284 | } 285 | } catch (e) { 286 | log.trace "Exception getting thermostats: " + e 287 | state.connected = false 288 | } 289 | state.thermostats = stats 290 | state.thermostatResponse = stats 291 | //log.debug "State Thermostats: ${state.thermostats}" 292 | return stats 293 | } 294 | def pollHandler() { 295 | //log.debug "pollHandler()" 296 | pollChildren(null) // Hit the sensi API for update on all thermostats 297 | 298 | } 299 | 300 | def pollChildren() { 301 | def devices = getChildDevices() 302 | devices.each { child -> 303 | TRACE("pollChild($child.device.deviceNetworkId)") 304 | try{ 305 | if(pollChild(child.device.deviceNetworkId)) { 306 | TRACE("pollChildren successful") 307 | 308 | } else { 309 | log.warn "pollChildren FAILED for $child.device.label" 310 | state.connected = false 311 | runIn(30, poll) 312 | } 313 | } catch (e) { 314 | log.error "Error $e in pollChildren() for $child.device.label" 315 | } 316 | } 317 | return true 318 | } 319 | def getSubscribed(thermostatIdsString) { 320 | /* 321 | if(state.lastSubscribedDNI == thermostatIdsString) { 322 | TRACE("Thermostat already subscribed") 323 | return true 324 | } else */ 325 | if(state.lastSubscribedDNI != null) { 326 | TRACE("Unsubscribing from: $state.lastSubscribedDNI") 327 | getUnsubscribed(state.lastSubscribedDNI) 328 | } 329 | TRACE("Getting subscribed to $thermostatIdsString") 330 | if(!state.connected) { getConnected() } 331 | if( state.RBCounter > 50) { 332 | state.RBCounter = 0 333 | } 334 | def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"Subscribe\",\"A\":[\"${thermostatIdsString}\"],\"I\":$state.RBCounter}"] 335 | state.RBCounter = state.RBCounter + 1 336 | 337 | 338 | def params = [ 339 | uri: getApiEndpoint(), 340 | path: '/realtime/send', 341 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 342 | 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"], 343 | body: requestBody 344 | ] 345 | 346 | try { 347 | 348 | httpPost(params) { resp -> 349 | TRACE( "Subscribe response: ${resp.data} Expected Response: [I:${state.RBCounter - 1}]") 350 | if(resp?.data.I?.toInteger() == (state.RBCounter - 1)) { 351 | state.lastSubscribedDNI = thermostatIdsString 352 | TRACE("Subscribe successfully") 353 | } else { 354 | TRACE("Failed to subscribe") 355 | state.connected = false 356 | } 357 | } 358 | } catch (e) { 359 | log.error "getSubscribed failed: $e" 360 | state.connected = false 361 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 362 | } 363 | } 364 | 365 | def getUnsubscribed(thermostatIdsString) { 366 | 367 | //Unsubscribe from this device 368 | def requestBody3 = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"Unsubscribe\",\"A\":[\"${thermostatIdsString}\"],\"I\":$state.RBCounter}"] 369 | def params = [ 370 | uri: getApiEndpoint(), 371 | path: '/realtime/send', 372 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 373 | 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"], 374 | body: requestBody3 375 | ] 376 | state.RBCounter = state.RBCounter + 1 377 | try { 378 | 379 | httpPost(params) { resp -> 380 | TRACE( "resp 3: ${resp.data}") 381 | } 382 | } 383 | catch (e) { 384 | log.trace "Exception unsubscribing " + e 385 | state.connected = false 386 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 387 | } 388 | state.lastSubscribedDNI = null 389 | 390 | } 391 | def pollChildData(data) { 392 | def device = getChildDevice(data.value) 393 | log.info "Scheduled re-poll of $device.deviceLabel $data.value $device.label" 394 | pollChild(data.value) 395 | } 396 | // Poll Child is invoked from the Child Device itself as part of the Poll Capability 397 | // If no dni is passed it will call pollChildren and poll all devices 398 | def pollChild(dni = null) { 399 | 400 | if(dni == null) { 401 | TRACE("dni in pollChild is $dni") 402 | pollChildren() 403 | return 404 | } 405 | def thermostatIdsString = dni 406 | 407 | def params = [] 408 | def result = false 409 | if(!state.connected || (state.messageId == null)) { 410 | getConnected() 411 | } 412 | getSubscribed(thermostatIdsString) 413 | params = [ 414 | uri: getApiEndpoint(), 415 | path: '/realtime/poll', 416 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]" 417 | ,connectionId:state.connectionId,messageId:state.messageId,tid:state.RBCounter,'_':now()], 418 | 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"] 419 | ] 420 | if(state.GroupsToken) { 421 | params.query = [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]" 422 | ,connectionId:state.connectionId,messageId:state.messageId,GroupsToken:state.GroupsToken,tid:state.RBCounter,'_':now()] 423 | } 424 | 425 | try{ 426 | httpGet(params) { resp -> 427 | def httpResp = resp.data.M[0].A[1] == null ? " " : resp.data.M[0].A[1] 428 | if(httpResp && (httpResp != true)) { 429 | state.thermostatResponse[thermostatIdsString] = httpResp 430 | TRACE("child.generateEvent=$httpResp") 431 | def myChild = getChildDevice(dni) 432 | myChild.generateEvent(httpResp) 433 | result = true 434 | } else { 435 | httpResp = resp.data.M[0].M == null ? " " : resp.data.M[0].M 436 | log.warn "Unexpected final resp in pollChild: ${resp.data} likely offline: $httpResp" 437 | } 438 | if(resp.data.C) { 439 | state.messageId = resp.data.C 440 | 441 | } 442 | if(resp.data.G) { 443 | state.GroupsToken = resp.data.G 444 | } 445 | } 446 | state.RBCounter = state.RBCounter + 1 447 | } catch (e) { 448 | log.error "Exception in pollChild: $e data: $resp.data" 449 | log.error "repoll in 30 seconds. Re-poll: $thermostatIdsString" 450 | state.connected = false //This will trigger new authentication next time the poll occurs 451 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 452 | } 453 | 454 | return result 455 | } 456 | 457 | void poll() { 458 | pollChildren() 459 | } 460 | 461 | def availableModes(child) { 462 | 463 | 464 | def modes = ["off", "heat", "cool", "aux", "auto"] 465 | 466 | return modes 467 | } 468 | 469 | def currentMode(child) { 470 | debugEvent ("state.Thermos = ${state.thermostats}") 471 | debugEvent ("Child DNI = ${child.device.deviceNetworkId}") 472 | 473 | def tData = state.thermostatResponse[child.device.deviceNetworkId]?.EnvironmentControls 474 | 475 | //debugEvent("Data = ${tData}") 476 | 477 | if(!tData) { 478 | log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling" 479 | return null 480 | } 481 | 482 | def mode = tData?.SystemMode 483 | return mode 484 | } 485 | 486 | /** 487 | * Executes the cmdString and cmdVal 488 | * @param deviceId - the ID of the device 489 | * @cmdString is passed directly to Sensi Web 490 | * @cmdVal is the value to send on. 491 | * 492 | * @retrun true if the command was successful, false otherwise. 493 | */ 494 | 495 | boolean setStringCmd(deviceId, cmdString, cmdVal) { 496 | //getConnected() 497 | getSubscribed(deviceId) 498 | def result = sendDniStringCmd(deviceId,cmdString,cmdVal) 499 | TRACE( "Setstring ${result}") 500 | //The sensi web app immediately polls the thermostat for updates after send before unsubscribe 501 | pollChild(deviceId) 502 | getUnsubscribed(deviceId) 503 | return result 504 | } 505 | boolean setSettingsStringCmd(deviceId,cmdSettings, cmdString, cmdVal) { 506 | //getConnected() 507 | getSubscribed(deviceId) 508 | def result = sendDniSettingsStringCmd(deviceId,cmdSettings,cmdString,cmdVal) 509 | TRACE( "Setstring ${result}") 510 | //The sensi web app immediately polls the thermostat for updates after send before unsubscribe 511 | pollChild(deviceId) 512 | getUnsubscribed(deviceId) 513 | return result 514 | } 515 | boolean setTempCmd(deviceId, cmdString, cmdVal) { 516 | //getConnected() 517 | getSubscribed(deviceId) 518 | def result = sendDniValue(deviceId,cmdString,cmdVal) 519 | TRACE( "Setstring ${result}") 520 | //The sensi web app immediately polls the thermostat for updates after send before unsubscribe 521 | pollChild(deviceId) 522 | getUnsubscribed 523 | return result 524 | } 525 | boolean sendDniValue(thermostatIdsString,cmdString,cmdVal) { 526 | def result = false 527 | def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"$cmdString\",\"A\":[\"${thermostatIdsString}\",$cmdVal,\"$location.temperatureScale\"],\"I\":$state.RBCounter}"] 528 | 529 | TRACE( "sendDNIValue body: ${requestBody}") 530 | def params = [ 531 | uri: getApiEndpoint(), 532 | path: '/realtime/send', 533 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 534 | 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"], 535 | body: requestBody 536 | ] 537 | 538 | try { 539 | 540 | httpPost(params) { resp -> 541 | 542 | if (resp.data.I.toInteger() == state.RBCounter.toInteger()) { 543 | result = true 544 | } 545 | state.RBCounter = state.RBCounter + 1 546 | } 547 | } catch (e) { 548 | log.warn "Send DNI Command went wrong: $e" 549 | state.connected = false 550 | state.RBCounter = state.RBCounter + 1 551 | 552 | } 553 | 554 | return result 555 | } 556 | boolean sendDniStringCmd(thermostatIdsString,cmdString,cmdVal) { 557 | def result = false 558 | def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"$cmdString\",\"A\":[\"${thermostatIdsString}\",\"$cmdVal\"],\"I\":$state.RBCounter}"] 559 | 560 | def params = [ 561 | uri: getApiEndpoint(), 562 | path: '/realtime/send', 563 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 564 | 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"], 565 | body: requestBody 566 | ] 567 | 568 | try { 569 | 570 | httpPost(params) { resp -> 571 | 572 | if (resp.data.I.toInteger() == state.RBCounter.toInteger()) { 573 | result = true 574 | } 575 | state.RBCounter = state.RBCounter + 1 576 | } 577 | } catch (e) { 578 | log.warn "Send DNI String Command went wrong: $e" 579 | state.connected = false 580 | state.RBCounter = state.RBCounter + 1 581 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 582 | 583 | } 584 | TRACE( "Send Function : $result") 585 | return result 586 | } 587 | /* {"H":"thermostat-v1","M":"ChangeSetting","A":["thermostatid is here","KeypadLockout","Off"],"I":8} */ 588 | boolean sendDniSettingsStringCmd(thermostatIdsString,cmdSettings,cmdString,cmdVal) { 589 | def result = false 590 | def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"$cmdSettings\",\"A\":[\"${thermostatIdsString}\",\"$cmdString\",\"$cmdVal\"],\"I\":$state.RBCounter}"] 591 | 592 | def params = [ 593 | uri: getApiEndpoint(), 594 | path: '/realtime/send', 595 | query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId], 596 | 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"], 597 | body: requestBody 598 | ] 599 | 600 | try { 601 | 602 | httpPost(params) { resp -> 603 | 604 | if (resp.data.I.toInteger() == state.RBCounter.toInteger()) { 605 | result = true 606 | } 607 | state.RBCounter = state.RBCounter + 1 608 | } 609 | } catch (e) { 610 | log.warn "Send DNI Setting String Command went wrong: $e" 611 | state.connected = false 612 | state.RBCounter = state.RBCounter + 1 613 | runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite 614 | 615 | } 616 | TRACE( "Send Function : $result") 617 | return result 618 | } 619 | 620 | def getChildName() { return "Sensi Thermostat" } 621 | def getServerUrl() { return "https://graph.api.smartthings.com" } 622 | def getApiEndpoint() { return "https://manager.sensicomfort.com" } 623 | 624 | def debugEvent(message, displayEvent = false) { 625 | def results = [ 626 | name: "appdebug", 627 | descriptionText: message, 628 | displayed: displayEvent 629 | ] 630 | log.debug "Generating AppDebug Event: ${results}" 631 | sendEvent (results) 632 | } 633 | 634 | def sendActivityFeeds(notificationMessage) { 635 | def devices = getChildDevices() 636 | devices.each { child -> 637 | child.generateActivityFeedsEvent(notificationMessage) //parse received message from parent 638 | } 639 | } 640 | 641 | private def TRACE(message) { 642 | //log.trace message 643 | } -------------------------------------------------------------------------------- /devicetypes/kirkbrownok/sensithermostat/sensi-thermostat.src/sensi-thermostat.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2016 Kirk Brown 3 | * This code was started from the ECOBEE Thermostat device type template. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 6 | * in compliance with the License. You may obtain a copy of the License at: 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 11 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 12 | * for the specific language governing permissions and limitations under the License. 13 | * 14 | * Sensi Thermostat 15 | * 16 | * Author: Kirk Brown 17 | * Date: 2016-12-26 18 | * Modified Heavily 2017-01-07 will NOT work with versions of SmartApp before 2017-01-07 19 | * The device type now correctly parses partial update messages in addition to complete new subscription messages. This should be most noticeable 20 | * for people with 1 sensi thermostat. 21 | * 22 | * 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 23 | * 24 | * 25 | */ 26 | metadata { 27 | definition (name: "Sensi Thermostat", namespace: "kirkbrownOK/SensiThermostat", author: "Kirk Brown") { 28 | 29 | capability "Thermostat" 30 | capability "Temperature Measurement" 31 | capability "Sensor" 32 | capability "Refresh" 33 | capability "Relative Humidity Measurement" 34 | capability "Health Check" 35 | capability "Battery" 36 | 37 | command "generateEvent" 38 | command "raiseSetpoint" 39 | command "lowerSetpoint" 40 | command "resumeProgram" 41 | command "switchMode" 42 | command "switchFanMode" 43 | command "stopSchedule" 44 | command "heatUp" 45 | command "heatDown" 46 | command "coolUp" 47 | command "coolDown" 48 | command "refresh" 49 | command "poll" 50 | command "setKeypadLockoutOn" 51 | command "setKeypadLockoutOff" 52 | attribute "keypadLockout", "string" 53 | 54 | attribute "thermostatSetpoint", "number" 55 | attribute "thermostatStatus", "string" 56 | attribute "maxHeatingSetpoint", "number" 57 | attribute "minHeatingSetpoint", "number" 58 | attribute "maxCoolingSetpoint", "number" 59 | attribute "minCoolingSetpoint", "number" 60 | attribute "deviceTemperatureUnit", "string" 61 | attribute "deviceAlive", "enum", ["true", "false"] 62 | attribute "operationalStatus", "string" 63 | attribute "environmentControls", "string" 64 | attribute "capabilities", "string" 65 | attribute "product", "string" 66 | attribute "settings", "string" 67 | 68 | 69 | 70 | attribute "sensiThermostatMode", "string" 71 | attribute "sensiBatteryVoltage", "number" 72 | attribute "sensiLowPower", "string" //New Attribute 73 | attribute "thermostatHoldMode", "string" 74 | attribute "thermostatOperatingMode", "string" 75 | attribute "thermostatFanState", "string" 76 | } 77 | 78 | tiles(scale:2) { 79 | multiAttributeTile(name:"thermostatFull", type:"thermostat", width:6, height:4) { 80 | tileAttribute("device.temperature", key: "PRIMARY_CONTROL") { 81 | attributeState("default", label:'${currentValue}', unit:"dF", defaultState: true) 82 | } 83 | tileAttribute("device.temperature", key: "VALUE_CONTROL") { 84 | attributeState("VALUE_UP", action: "raiseSetpoint") 85 | attributeState("VALUE_DOWN", action: "lowerSetpoint") 86 | } 87 | tileAttribute("device.humidity", key: "SECONDARY_CONTROL") { 88 | attributeState("default", label:'${currentValue}%', unit:"%") 89 | } 90 | tileAttribute("device.thermostatOperatingState", key: "OPERATING_STATE") { 91 | attributeState("idle", backgroundColor:"#44b621") 92 | attributeState("heating", backgroundColor:"#ffa81e") 93 | attributeState("cooling", backgroundColor:"#269bd2") 94 | } 95 | tileAttribute("device.thermostatMode", key: "THERMOSTAT_MODE") { 96 | attributeState("off", label:'${name}') 97 | attributeState("heat", label:'${name}') 98 | attributeState("aux", label: '${name}') 99 | attributeState("cool", label:'${name}') 100 | attributeState("auto", label:'${name}') 101 | } 102 | tileAttribute("device.heatingSetpoint", key: "HEATING_SETPOINT") { 103 | attributeState("default", label:'${currentValue}', unit:"dF") 104 | } 105 | tileAttribute("device.coolingSetpoint", key: "COOLING_SETPOINT") { 106 | attributeState("default", label:'${currentValue}', unit:"dF") 107 | } 108 | } 109 | valueTile("temperature", "device.temperature", decoration: "flat") { 110 | state "temperature", label:'${currentValue}°', unit:"F", 111 | backgroundColors:[ 112 | 113 | // Celsius 114 | [value: 0, color: "#153591"], 115 | [value: 7, color: "#1e9cbb"], 116 | [value: 15, color: "#90d2a7"], 117 | [value: 23, color: "#44b621"], 118 | [value: 28, color: "#f1d801"], 119 | [value: 35, color: "#d04e00"], 120 | [value: 37, color: "#bc2323"], 121 | // Fahrenheit 122 | [value: 40, color: "#153591"], 123 | [value: 44, color: "#1e9cbb"], 124 | [value: 63, color: "#90d2a7"], 125 | [value: 74, color: "#44b621"], 126 | [value: 80, color: "#f1d801"], 127 | [value: 95, color: "#d04e00"], 128 | [value: 96, color: "#bc2323"] 129 | ] 130 | } 131 | valueTile("temperatureIcon", "device.temperature", decoration: "flat") { 132 | state "temperature", label:'${currentValue}°', unit:"F",icon:"st.Home.home1", 133 | backgroundColors:[ 134 | 135 | // Celsius 136 | [value: 0, color: "#153591"], 137 | [value: 7, color: "#1e9cbb"], 138 | [value: 15, color: "#90d2a7"], 139 | [value: 23, color: "#44b621"], 140 | [value: 28, color: "#f1d801"], 141 | [value: 35, color: "#d04e00"], 142 | [value: 37, color: "#bc2323"], 143 | // Fahrenheit 144 | [value: 40, color: "#153591"], 145 | [value: 44, color: "#1e9cbb"], 146 | [value: 63, color: "#90d2a7"], 147 | [value: 74, color: "#44b621"], 148 | [value: 80, color: "#f1d801"], 149 | [value: 95, color: "#d04e00"], 150 | [value: 96, color: "#bc2323"] 151 | ] 152 | } 153 | 154 | valueTile("heatingSetpoint", "device.heatingSetpoint", inactiveLabel:false) { 155 | state "default", label:'${currentValue}°', unit:"F", 156 | backgroundColors:[ 157 | [value: 31, color: "#153591"], 158 | [value: 44, color: "#1e9cbb"], 159 | [value: 59, color: "#90d2a7"], 160 | [value: 74, color: "#44b621"], 161 | [value: 84, color: "#f1d801"], 162 | [value: 95, color: "#d04e00"], 163 | [value: 96, color: "#bc2323"] 164 | ] 165 | } 166 | 167 | valueTile("coolingSetpoint", "device.coolingSetpoint", inactiveLabel:false) { 168 | state "default", label:'${currentValue}°', unit:"F", 169 | backgroundColors:[ 170 | [value: 31, color: "#153591"], 171 | [value: 44, color: "#1e9cbb"], 172 | [value: 59, color: "#90d2a7"], 173 | [value: 74, color: "#44b621"], 174 | [value: 84, color: "#f1d801"], 175 | [value: 95, color: "#d04e00"], 176 | [value: 96, color: "#bc2323"] 177 | ] 178 | } 179 | 180 | 181 | standardTile("heatUp", "device.heatingSetpoint", inactiveLabel:false, decoration:"flat") { 182 | state "default", label:'Heating', icon:"st.custom.buttons.add-icon", action:"heatUp" 183 | } 184 | 185 | standardTile("heatDown", "device.heatingSetpoint", inactiveLabel:false, decoration:"flat") { 186 | state "default", label:'Heating', icon:"st.custom.buttons.subtract-icon", action:"heatDown" 187 | } 188 | 189 | standardTile("coolUp", "device.coolingSetpoint", inactiveLabel:false, decoration:"flat") { 190 | state "default", label:'Cooling', icon:"st.custom.buttons.add-icon", action:"coolUp" 191 | } 192 | 193 | standardTile("coolDown", "device.coolingSetpoint", inactiveLabel:false, decoration:"flat") { 194 | state "default", label:'Cooling', icon:"st.custom.buttons.subtract-icon", action:"coolDown" 195 | } 196 | 197 | standardTile("operatingState", "device.thermostatOperatingState", inactiveLabel:false, decoration:"flat") { 198 | state "default", label:'[State]' 199 | state "idle", label:'', icon:"st.thermostat.heating-cooling-off" 200 | state "heating", label:'', icon:"st.thermostat.heating" 201 | state "cooling", label:'', icon:"st.thermostat.cooling" 202 | } 203 | 204 | standardTile("fanState", "device.thermostatFanState", inactiveLabel:false, decoration:"flat") { 205 | state "default", label:'[Fan State]' 206 | state "on", label:'', icon:"st.thermostat.fan-on" 207 | state "off", label:'', icon:"st.thermostat.fan-off" 208 | } 209 | standardTile("keypadLockout", "device.keypadLockout", inactiveLabel:false, decoration:"flat", width: 2) { 210 | state "default", label:'Keypad Unknown', action: "setKeypadLockoutOn" 211 | state "on", label:'KEYPAD: LOCKED', action: "setKeypadLockoutOff", nextState: "off" 212 | state "off", label:'KEYPAD: UNLOCKED', action: "setKeypadLockoutOn", nextState: "on" 213 | } 214 | 215 | standardTile("mode", "device.thermostatMode", inactiveLabel:false) { 216 | state "default", label:'[Mode]' 217 | state "off", label:'', icon:"st.thermostat.heating-cooling-off", backgroundColor:"#FFFFFF", action:"thermostat.heat" 218 | state "heat", label:'', icon:"st.thermostat.heat", backgroundColor:"#FFCC99", action:"thermostat.cool" 219 | state "aux", label:'', icon:"st.thermostat.heat", backgroundColor:"#FFCC99", action:"thermostat.cool" 220 | state "cool", label:'', icon:"st.thermostat.cool", backgroundColor:"#99CCFF", action:"thermostat.auto" 221 | state "auto", label:'', icon:"st.thermostat.auto", backgroundColor:"#99FF99", action:"thermostat.off" 222 | } 223 | 224 | standardTile("fanMode", "device.thermostatFanMode", inactiveLabel:false) { 225 | state "default", label:'[Fan Mode]' 226 | state "auto", label:'', icon:"st.thermostat.fan-auto", backgroundColor:"#A4FCA6", action:"thermostat.fanOn" 227 | state "on", label:'', icon:"st.thermostat.fan-on", backgroundColor:"#FAFCA4", action:"thermostat.fanAuto" 228 | } 229 | 230 | standardTile("hold", "device.thermostatHoldMode",decoration:"flat") { 231 | state "default", label:'[Hold]' 232 | state "on", label:'Permanent Hold', backgroundColor:"#FFDB94", action:"resumeProgram" 233 | state "temporary", label: 'Temp Hold', backgroundColor:"#FFDB94", action:"resumeProgram" 234 | state "off", label:'Sensi Schedule', backgroundColor:"#FFFFFF", action:"stopSchedule" 235 | } 236 | 237 | standardTile("refresh", "device.refresh", decoration:"flat",width:1, height:1) { 238 | state "default", icon:"st.secondary.refresh", action:"refresh.refresh" 239 | state "error", icon:"st.secondary.refresh", action:"refresh.refresh" 240 | } 241 | //No clue what a Fully Charged battery is vs a Dead Battery. Guessing from 2 AA nominal 1.5V per unit and 3.0V is adequate 242 | valueTile("batteryDisplay", "device.sensiBatteryVoltage", inactiveLabel:false) { 243 | state "default", label:'${currentValue}', unit:"V", 244 | backgroundColors:[ 245 | [value: 2.9, color: "#ff3300"], 246 | [value: 3.07, color: "#ffff00"], 247 | [value: 3.1, color: "#33cc33"], 248 | [value: 3.5, color: "#33cc33"], 249 | [value: 2900, color: "#ff3300"], 250 | [value: 3075, color: "#ffff00"], 251 | [value: 3100, color: "#33cc33"], 252 | [value: 3500, color: "#33cc33"] 253 | ] 254 | } 255 | valueTile("lowBattery", "device.sensiLowPower", inactiveLabel:false, width: 2) { 256 | state "default", label:'LowPower: ${currentValue}', unit:"V" 257 | } 258 | main(["temperatureIcon" ]) 259 | 260 | details(["thermostatFull","temperature", "operatingState", "fanState", 261 | "mode", "fanMode", "hold", 262 | "heatingSetpoint", "heatDown", "heatUp", 263 | "coolingSetpoint", "coolDown", "coolUp", 264 | "batteryDisplay","lowBattery","keypadLockout", 265 | "refresh"]) 266 | } 267 | preferences { 268 | input "holdType", "enum", title: "Hold Type", description: "When changing temperature, use Temporary (Until next transition -> default) or Permanent hold-> TURNS OFF SENSI SCHEDULED CHANGES", required: false, options:["Temporary", "Permanent"] 269 | } 270 | 271 | 272 | } 273 | 274 | void installed() { 275 | // The device refreshes every 5 minutes by default so if we miss 2 refreshes we can consider it offline 276 | // Using 12 minutes because in testing, device health team found that there could be "jitter" 277 | sendEvent(name: "checkInterval", value: 60 * 12, data: [protocol: "cloud"], displayed: false) 278 | 279 | //send initial default events to populate the tiles. 280 | updated() 281 | 282 | } 283 | 284 | void updated() { 285 | TRACE("Updating with default values in ${location.temperatureScale}") 286 | sendEvent(name:"thermostatMode",value:"auto") 287 | sendEvent(name:"thermostatFanMode",value:"auto") 288 | sendEvent(name:"thermostatOperatingState",value:"idle") 289 | sendEvent(name:"checkInterval",value:"720") 290 | sendEvent(name:"thermostatHoldMode", value: "off") 291 | sendEvent(name: "thermostatFanState", value: "off") 292 | 293 | if(location.temperatureScale == "C") { 294 | sendEvent(name:"temperature",value:"20",unit:location.temperatureScale) 295 | sendEvent(name:"humidity",value:"24",unit:location.temperatureScale) 296 | sendEvent(name:"heatingSetpoint",value:"20",unit:location.temperatureScale) 297 | sendEvent(name:"coolingSetpoint",value:"22",unit:location.temperatureScale) 298 | sendEvent(name:"thermostatSetpoint",value:"20",unit:location.temperatureScale) 299 | 300 | sendEvent(name:"thermostatStatus",value:"?") 301 | sendEvent(name:"maxHeatingSetpoint",value:"37",unit:location.temperatureScale) 302 | sendEvent(name:"minHeatingSetpoint",value:"7",unit:location.temperatureScale) 303 | sendEvent(name:"maxCoolingSetpoint",value:"37",unit:location.temperatureScale) 304 | sendEvent(name:"minCoolingSetpoint",value:"7",unit:location.temperatureScale) 305 | 306 | } else if (location.temperatureScale == "F") { 307 | 308 | sendEvent(name:"temperature",value:"68",unit:location.temperatureScale) 309 | sendEvent(name:"humidity",value:"24") 310 | sendEvent(name:"heatingSetpoint",value:"68",unit:location.temperatureScale) 311 | sendEvent(name:"coolingSetpoint",value:"75",unit:location.temperatureScale) 312 | sendEvent(name:"thermostatSetpoint",value:"68",unit:location.temperatureScale) 313 | 314 | sendEvent(name:"thermostatStatus",value:"?") 315 | sendEvent(name:"maxHeatingSetpoint",value:"99",unit:location.temperatureScale) 316 | sendEvent(name:"minHeatingSetpoint",value:"45",unit:location.temperatureScale) 317 | sendEvent(name:"maxCoolingSetpoint",value:"99",unit:location.temperatureScale) 318 | sendEvent(name:"minCoolingSetpoint",value:"45",unit:location.temperatureScale) 319 | } 320 | } 321 | //I don't have any idea if this is set up right now not for Sensi 322 | // Device Watch will ping the device to proactively determine if the device has gone offline 323 | // If the device was online the last time we refreshed, trigger another refresh as part of the ping. 324 | def ping() { 325 | def isAlive = device.currentValue("deviceAlive") == "true" ? true : false 326 | if (isAlive) { 327 | refresh() 328 | } 329 | } 330 | 331 | // parse events into attributes 332 | def parse(String description) { 333 | log.debug "Parsing '${description}'" 334 | } 335 | 336 | def refresh() { 337 | 338 | poll() 339 | log.debug "refresh completed" 340 | } 341 | 342 | void poll() { 343 | TRACE("Refresh $device.name") 344 | parent.pollChild(device.deviceNetworkId) 345 | } 346 | 347 | def generateEvent(results) { 348 | if(results) { 349 | //This should break down into 1 or more of the following. ON first Poll after new subscription they will all be present. On existing subscription they will only be present if 350 | // they contain Updated information 351 | //1 Capabilities 352 | //2 EnvironmentControls 353 | //3 OperationalStatus 354 | //4 Product 355 | //5 Schedule 356 | //6 Settings 357 | try{ 358 | results.each { name, value -> 359 | //TRACE("name:${name} , value: ${value}") 360 | if(name == "Capabilities") { 361 | parseCapabilities(value) 362 | } else if( name == "EnvironmentControls") { 363 | parseEnvironmentControls(value) 364 | } else if( name == "OperationalStatus") { 365 | parseOperationalStatus(value) 366 | } else if( name == "Product") { 367 | parseProduct(value) 368 | } else if( name == "Schedule") { 369 | parseSchedule(value) 370 | } else if( name == "Settings") { 371 | parseSettings(value) 372 | 373 | } 374 | } 375 | }catch(e) { 376 | log.info "No new data" 377 | } 378 | } 379 | generateSetpointEvent() 380 | generateStatusEvent() 381 | return null 382 | } 383 | 384 | def parseCapabilities(capabilities) { 385 | checkSendEvent("capabilities",capabilities,null,null,false) 386 | try{ 387 | capabilities.each { name, value -> 388 | TRACE("Capabilities.name: $name, value: $value") 389 | if(name=="HeatLimits") { 390 | if( location.temperatureScale == "F") { 391 | checkSendEvent("maxHeatingSetpoint", value?.Max?.F,null, location.temperatureScale) 392 | checkSendEvent("minHeatingSetpoint", value?.Min?.F,null,location.temperatureScale) 393 | } else { 394 | checkSendEvent("maxHeatingSetpoint", value?.Max?.C,null, location.temperatureScale) 395 | checkSendEvent("minHeatingSetpoint", value?.Min?.C,null,location.temperatureScale) 396 | } 397 | } else if(name=="CoolLimits") { 398 | if( location.temperatureScale == "F") { 399 | checkSendEvent("maxCoolingSetpoint", value?.Max?.F, null, location.temperatureScale) 400 | checkSendEvent("minCoolingSetpoint", value?.Min?.F, null, location.temperatureScale) 401 | } else { 402 | checkSendEvent("maxCoolingSetpoint", value?.Max?.C,null, location.temperatureScale) 403 | checkSendEvent("minCoolingSetpoint", value?.Min?.C, null, location.temperatureScale) 404 | } 405 | 406 | } 407 | } 408 | TRACE("Updated Capabilities") 409 | } catch (e) { 410 | checkSendEvent("refresh","error",null,null,false) 411 | log.warn "Failed to update capabilities $e" 412 | } 413 | } 414 | def parseEnvironmentControls(controls) { 415 | checkSendEvent("environmentControls",controls,null,null,false) 416 | try{ 417 | controls.each{ name, value -> 418 | TRACE("Control.name: $name, value: $value") 419 | 420 | //Check for CoolSetPoint 421 | if(name == "CoolSetpoint") { 422 | def cMode = device.currentValue("thermostatMode") == null ? "off" : device.currentValue("thermostatMode") 423 | def sensiMode = device.currentValue("sensiThermostatMode") == null ? "off" : device.currentValue("sensiThermostatMode") 424 | 425 | def sendValue = 0 426 | sendValue = location.temperatureScale == "C"? value.C : value.F 427 | checkSendEvent("coolingSetpoint",sendValue, "${device.label} Cooling set to ${sendValue}",location.temperatureScale) 428 | 429 | if( (sensiMode == "autocool") || (cMode == "cool")) { 430 | checkSendEvent("thermostatSetpoint", sendValue, "${device.label} Cooling set to ${sendValue}",location.temperatureScale) 431 | } 432 | 433 | } 434 | if(name =="HeatSetpoint") { 435 | def cMode = device.currentValue("thermostatMode") == null ? "off" : device.currentValue("thermostatMode") 436 | def sensiMode = device.currentValue("sensiThermostatMode") == null ? "off" : device.currentValue("sensiThermostatMode") 437 | 438 | def sendValue = location.temperatureScale == "C"? value.C :value.F 439 | checkSendEvent("heatingSetpoint", sendValue,"${device.label} Heating set to ${sendValue}", location.temperatureScale) 440 | if( (sensiMode == "autoheat") || (cMode =="heat")) { 441 | checkSendEvent("thermostatSetpoint",sendValue,"${device.label} Heating set to ${sendValue}",location.temperatureScale) 442 | } 443 | 444 | } 445 | if(name == "FanMode") { 446 | def sendValue = value.toLowerCase() 447 | checkSendEvent("thermostatFanMode",sendValue, "${device.name} Fan Mode ${sendValue}") 448 | } 449 | if(name == "HoldMode") { 450 | //off: means Schedule is Running, Temporary means off schedule until next state on: means Hold indifinitely 451 | def sendValue = value.toLowerCase() 452 | def scheduleMode = controls?.ScheduleMode?.toLowerCase() == null ? "on" : controls?.ScheduleMode?.toLowerCase() 453 | TRACE("HoldMode: $sendValue scheduleMode: $scheduleMode") 454 | if(scheduleMode == "off") { sendValue = "on" } 455 | checkSendEvent("thermostatHoldMode", sendValue, "${device.label} Hold Mode ${sendValue}") 456 | } 457 | if(name == "SystemMode") { 458 | def currentMode = value.toLowerCase() 459 | checkSendEvent("thermostatMode", currentMode, "${device.label} mode set to ${currentMode}") 460 | } 461 | 462 | } 463 | TRACE("Completed Parsing EnvironmentControls") 464 | 465 | } catch (e) { 466 | checkSendEvent("refresh","error",null,null,false) 467 | log.warn "Error $e in parseEnvironmentControls()" 468 | } 469 | } 470 | def parseOperationalStatus(status) { 471 | checkSendEvent("operationalStatus",status,null,null,false) 472 | def currentFanMode = device.currentValue("thermostatFanMode") == null? "auto" : device.currentValue("thermostatFanMode") 473 | try{ 474 | status.each{ name, value -> 475 | TRACE("Status: $name, value: $value") 476 | if(name=="OperatingMode") { 477 | def currentMode = value.toLowerCase() 478 | checkSendEvent("sensiThermostatMode",currentMode,"${device.label} Sensi mode set to ${currentMode}") 479 | } 480 | if(name=="Running") { 481 | def sendValue = value.Mode.toLowerCase() 482 | if ((sendValue == "off") || (sendValue == "null")) { 483 | sendValue = "idle" 484 | if(currentFanMode == "auto") { 485 | checkSendEvent("thermostatFanState","off") 486 | } 487 | } else if (sendValue == "heat") { 488 | sendValue = "heating" 489 | checkSendEvent("thermostatFanState","on") 490 | } else if (sendValue == "cool") { 491 | sendValue = "cooling" 492 | checkSendEvent("thermostatFanState", "on") 493 | } 494 | TRACE( "Running contains Operating State: ${sendValue} and fan is ${currentFanMode} ") 495 | checkSendEvent("thermostatOperatingState", sendValue, "${device.label} mode set to ${sendValue}") 496 | } 497 | if(name=="Temperature") { 498 | def sendValue = location.temperatureScale == "C"? value.C.toInteger() : value.F.toInteger() 499 | checkSendEvent("temperature", sendValue, "${device.label} mode set to ${sendValue}",location.temperatureScale,true) 500 | } 501 | if(name=="Humidity") { 502 | def sendValue = value 503 | checkSendEvent("humidity", sendValue, "${device.label} humidity ${sendValue}","%") 504 | } 505 | if(name=="BatteryVoltage") { 506 | //def sendValue = value //In milliVolts 507 | def sendValue = value/1000 // in Volts 508 | TRACE("BV is $value and sensiBV is ${sendValue.toFloat().round(1)}") 509 | checkSendEvent("sensiBatteryVoltage", sendValue.toFloat().round(1), "${device.label} BatteryVoltage ${sendValue}",null,false) 510 | 511 | } 512 | if(name == "LowPower") { 513 | def sendValue = value 514 | checkSendEvent("sensiLowPower", sendValue, "${device.label} LowPower ${sendValue}",null,sendValue) 515 | if (sendValue == true) { 516 | checkSendEvent("battery", 0, "${device.label} Battery is Low", null, true) 517 | } else { 518 | checkSendEvent("battery", 100, "${device.label} Battery is Not Low", null, false) 519 | } 520 | } 521 | } 522 | } catch (e) { 523 | checkSendEvent("refresh","error",null,null,false) 524 | log.debug "Error $e in parseOperationalStatus()" 525 | } 526 | } 527 | def parseProduct(product) { 528 | checkSendEvent("product", product,null,null,false) 529 | //Basically not doing anything with product information 530 | 531 | } 532 | def parseSchedule(schedule) { 533 | checkSendEvent("schedule", schedule,null,null,false) 534 | //Doing nothing with schedule information-> Can be viewed in the device under the schedule attribute 535 | } 536 | def parseSettings(settings) { 537 | checkSendEvent("settings",settings,null,null,false) 538 | 539 | //Added the ability to parse keypadLockout 540 | try{ 541 | 542 | settings.each{ name, value -> 543 | TRACE("settings.name: $name, value: $value") 544 | if(name=="KeypadLockout") { 545 | def curKeypadLockout = value.toLowerCase() 546 | checkSendEvent("keypadLockout",curKeypadLockout,"${device.label} keypad lockout to ${curKeypadLockout}") 547 | } 548 | } 549 | } catch (e) { 550 | log.debug "Error $e in parseStatus()" 551 | } 552 | } 553 | def parseMode(cMode) { 554 | //Sensi Thermostat returns Auto as AutoHeat or AutoCool? -> I did this in winter. Not sure how AutoCool looks. 555 | 556 | if(cMode == "autoheat") return "auto" 557 | if(cMode == "autocool") return "auto" 558 | return cMode 559 | 560 | } 561 | def checkSendEvent(evtName,evtValue,evtDescription=null,evtUnit=null,evtDisplayed=true) { 562 | //TRACE("Updating: name: ${evtName}, value: ${evtValue}, descriptionText: ${evtDescription}, unit: ${evtUnit}") 563 | try { 564 | def checkVal = device.currentValue(evtName) == null ? " " : device.currentValue(evtName) 565 | def myMap = [] 566 | if (checkVal != evtValue) { 567 | if(evtDisplayed == true) { 568 | log.info "Updating: name: ${evtName}, value: ${evtValue}, descriptionText: ${evtDescription}, unit: ${evtUnit}" 569 | } 570 | if((evtDescription == null) && (evtUnit == null)) { 571 | myMap = [name: evtName, value: evtValue, displayed: evtDisplayed] 572 | } else if (evtUnit == null) { 573 | myMap = [name: evtName, value: evtValue, descriptionText: evtDescription, displayed: evtDisplayed] 574 | } else if (evtDescription == null) { 575 | myMap = [name: evtName, value: evtValue, unit: evtUnit, displayed: evtDisplayed] 576 | } else { 577 | myMap = [name: evtName, value: evtValue, descriptionText: evtDescription, unit: evtUnit, displayed: evtDisplayed] 578 | } 579 | if(evtName != "refresh") { sendEvent(name:"refresh",value:"normal",displayed:false) } 580 | //log.debug "Sending Check Event: ${myMap}" 581 | sendEvent(myMap) 582 | } else { 583 | //log.debug "${evtName}:${evtValue} is the same" 584 | } 585 | } catch (e) { 586 | log.debug "checkSendEvent $evtName $evtValue $e" 587 | } 588 | } 589 | 590 | void heatUp() { 591 | log.debug "Heat Up" 592 | def mode = device.currentValue("thermostatMode") 593 | if (mode == "off" ) { 594 | heat() 595 | } 596 | def heatingSetpoint = device.currentValue("heatingSetpoint") 597 | 598 | def targetvalue = heatingSetpoint + 1 599 | 600 | sendEvent(name:"heatingSetpoint", "value":targetvalue, "unit":location.temperatureScale, displayed: false) 601 | 602 | runIn(5, setDataHeatingSetpoint,[data: [value: targetvalue], overwrite: true]) //when user click button this runIn will be overwrite 603 | } 604 | 605 | void heatDown() { 606 | log.debug "Heat Down" 607 | def mode = device.currentValue("thermostatMode") 608 | if (mode == "off" ) { 609 | //heat() 610 | } 611 | def heatingSetpoint = device.currentValue("heatingSetpoint") 612 | 613 | def targetvalue = heatingSetpoint - 1 614 | 615 | sendEvent(name:"heatingSetpoint", "value":targetvalue, "unit":location.temperatureScale, displayed: false) 616 | 617 | runIn(5, setDataHeatingSetpoint,[data: [value: targetvalue], overwrite: true]) //when user click button this runIn will be overwrite 618 | 619 | } 620 | 621 | void coolUp() { 622 | log.debug "Cool Up" 623 | def mode = device.currentValue("thermostatMode") 624 | if (mode == "off" ) { 625 | //cool() 626 | } 627 | def coolingSetpoint = device.currentValue("coolingSetpoint") 628 | 629 | def targetvalue = coolingSetpoint + 1 630 | 631 | sendEvent(name:"coolingSetpoint", "value":targetvalue, "unit":location.temperatureScale, displayed: false) 632 | 633 | runIn(5, setDataCoolingSetpoint,[data: [value: targetvalue], overwrite: true]) //when user click button this runIn will be overwrite 634 | 635 | } 636 | 637 | void coolDown() { 638 | log.debug "Cool Down" 639 | def mode = device.currentValue("thermostatMode") 640 | if (mode == "off" ) { 641 | cool() 642 | } 643 | def coolingSetpoint = device.currentValue("coolingSetpoint") 644 | 645 | def targetvalue = coolingSetpoint - 1 646 | 647 | sendEvent(name:"coolingSetpoint", "value":targetvalue, "unit":location.temperatureScale, displayed: false) 648 | 649 | runIn(5, setDataCoolingSetpoint,[data: [value: targetvalue], overwrite: true]) //when user click button this runIn will be overwrite 650 | 651 | } 652 | void setDataHeatingSetpoint(setpoint) { 653 | log.debug "Set heating setpoint $setpoint.value" 654 | setHeatingSetpoint(setpoint.value) 655 | //runIn(5, "poll") 656 | } 657 | void setDataCoolingSetpoint(setpoint) { 658 | log.debug "Set Cooling setpoint $setpoint.value" 659 | setCoolingSetpoint(setpoint.value) 660 | //runIn(5, "poll") 661 | } 662 | void setHeatingSetpoint(setpoint) { 663 | TRACE( "***heating setpoint $setpoint") 664 | def cmdString = "set" 665 | 666 | def heatingSetpoint = setpoint.toInteger() 667 | 668 | def coolingSetpoint = device.currentValue("coolingSetpoint") 669 | def deviceId = device.deviceNetworkId 670 | def maxHeatingSetpoint = device.currentValue("maxHeatingSetpoint") 671 | def minHeatingSetpoint = device.currentValue("minHeatingSetpoint") 672 | def thermostatMode = device.currentValue("thermostatMode") 673 | 674 | //enforce limits of heatingSetpoint 675 | if (heatingSetpoint > maxHeatingSetpoint) { 676 | heatingSetpoint = maxHeatingSetpoint 677 | } else if (heatingSetpoint < minHeatingSetpoint) { 678 | heatingSetpoint = minHeatingSetpoint 679 | } 680 | 681 | //enforce limits of heatingSetpoint vs coolingSetpoint 682 | if (heatingSetpoint >= coolingSetpoint) { 683 | coolingSetpoint = heatingSetpoint 684 | } 685 | 686 | 687 | 688 | if ( thermostatMode == "auto") { 689 | cmdString = "SetAutoHeat" 690 | //log.debug "Is AUTHO heat ${cmdString}" 691 | } else if( (thermostatMode == "heat") || (thermostatMode == "aux") ) { 692 | cmdString = "SetHeat" 693 | //log.debug "Is Reg Heat ${cmdString}" 694 | } 695 | log.debug "Sending heatingSetpoint: ${heatingSetpoint} mode: ${thermostatMode} string: ${cmdString}" 696 | sendEvent("name":"heatingSetpoint", "value":heatingSetpoint, "unit":location.temperatureScale) 697 | if (parent.setTempCmd(deviceId, cmdString, heatingSetpoint)) { 698 | 699 | //"on" means the schedule will not run 700 | //"temporary" means do nothing special" 701 | //"off" means do nothing special 702 | def currentHoldMode = getDataByName("thermostatHoldMode") 703 | def desiredHoldType = holdType == null ? "temporary" : holdType 704 | //log.debug "holdType is: ${holdType} des Hold type is: ${desiredHoldType}" 705 | if( (desiredHoldType == "Permanent") && (currentHoldMode != "on")) { 706 | parent.setStringCmd(deviceId, "SetScheduleMode", "Off") 707 | sendEvent(name:"thermostatHoldMode", value: "on") 708 | } else { 709 | sendEvent(name:"thermostatHoldMode", value: "temporary") 710 | } 711 | //log.debug "Done setHeatingSetpoint: ${heatingSetpoint}" 712 | 713 | } else { 714 | log.error "Error setHeatingSetpoint(setpoint)" 715 | } 716 | 717 | } 718 | 719 | void setCoolingSetpoint(setpoint) { 720 | TRACE( "***cooling setpoint $setpoint") 721 | def cmdString = "set" 722 | def heatingSetpoint = device.currentValue("heatingSetpoint") 723 | 724 | def coolingSetpoint = setpoint.toInteger() 725 | 726 | def deviceId = device.deviceNetworkId 727 | def maxCoolingSetpoint = device.currentValue("maxCoolingSetpoint") 728 | def minCoolingSetpoint = device.currentValue("minCoolingSetpoint") 729 | def thermostatMode = device.currentValue("thermostatMode") 730 | if (coolingSetpoint > maxCoolingSetpoint) { 731 | coolingSetpoint = maxCoolingSetpoint 732 | } else if (coolingSetpoint < minCoolingSetpoint) { 733 | coolingSetpoint = minCoolingSetpoint 734 | } 735 | 736 | //enforce limits of heatingSetpoint vs coolingSetpoint 737 | if (heatingSetpoint >= coolingSetpoint) { 738 | heatingSetpoint = coolingSetpoint 739 | } 740 | 741 | // def coolingValue = location.temperatureScale == "C"? convertCtoF(coolingSetpoint) : coolingSetpoint 742 | // def heatingValue = location.temperatureScale == "C"? convertCtoF(heatingSetpoint) : heatingSetpoint 743 | if ( thermostatMode == "auto") { 744 | cmdString = "SetAutoCool" 745 | //log.debug "Set Auto Cool" 746 | } else if( thermostatMode == "cool" ) { 747 | cmdString = "SetCool" 748 | //log.debug "set Cool" 749 | } 750 | def sendHoldType = getDataByName("thermostatHoldMode") 751 | log.debug "Sending CoolingSetpoint: ${coolingSetpoint} mode: ${thermostatMode} string: ${cmdString}" 752 | sendEvent("name":"coolingSetpoint", "value":coolingSetpoint, "unit":location.temperatureScale) 753 | if (parent.setTempCmd(deviceId, cmdString, coolingSetpoint)) { 754 | //"on" means the schedule will not run 755 | //"temporary" means do nothing special" 756 | //"off" means do nothing special 757 | def currentHoldMode = getDataByName("thermostatHoldMode") 758 | def desiredHoldType = holdType == null ? "temporary" : holdType 759 | //log.debug "holdType is: ${holdType} des Hold type is: ${desiredHoldType}" 760 | if( (desiredHoldType == "Permanent") && (currentHoldMode != "on")) { 761 | parent.setStringCmd(deviceId, "SetScheduleMode", "Off") 762 | sendEvent(name:"thermostatHoldMode", value: "on") 763 | } else { 764 | sendEvent(name:"thermostatHoldMode", value: "temporary") 765 | } 766 | } else { 767 | log.error "Error setCoolingSetpoint(setpoint)" 768 | } 769 | } 770 | 771 | void resumeProgram() { 772 | log.debug "resumeProgram() is called" 773 | sendEvent("name":"thermostatStatus", "value":"resuming schedule", "description":statusText, displayed: false) 774 | def deviceId = device.deviceNetworkId 775 | def currentMode = getDataByName("thermostatHoldMode") 776 | if (currentMode == "temporary") { 777 | parent.setStringCmd(deviceId, "SetHoldMode", "Off") 778 | } 779 | if (parent.setStringCmd(deviceId,"SetScheduleMode","On")) { 780 | sendEvent("name":"thermostatStatus", "value":"setpoint is updating", "description":statusText, displayed: false) 781 | //runIn(5, "poll") 782 | //log.debug "resumeProgram() is done" 783 | } else { 784 | sendEvent("name":"thermostatStatus", "value":"failed resume click refresh", "description":statusText, displayed: false) 785 | log.error "Error resumeProgram() check parent.resumeProgram(deviceId)" 786 | } 787 | 788 | } 789 | void stopSchedule() { 790 | //log.debug "stopSchedule() is called" 791 | sendEvent("name":"thermostatStatus", "value":"stopping schedule", "description":statusText, displayed: false) 792 | def deviceId = device.deviceNetworkId 793 | if (parent.setStringCmd(deviceId,"SetScheduleMode","Off")) { 794 | sendEvent("name":"thermostatStatus", "value":"setpoint is updating", "description":statusText, displayed: false) 795 | sendEvent(name:"thermostatHoldMode", value: "on") 796 | //runIn(5, "poll") 797 | } else { 798 | sendEvent("name":"thermostatStatus", "value":"failed resume click refresh", "description":statusText, displayed: false) 799 | log.error "Error resumeProgram() check parent.resumeProgram(deviceId)" 800 | } 801 | 802 | } 803 | def modes() { 804 | 805 | if (state.modes) { 806 | //log.debug "Modes = ${state.modes}" 807 | return state.modes 808 | } 809 | else { 810 | state.modes = parent.availableModes(this) 811 | log.debug "Modes = ${state.modes}" 812 | return state.modes 813 | } 814 | } 815 | 816 | def fanModes() { 817 | ["on", "auto"] 818 | } 819 | 820 | def switchMode() { 821 | //log.debug "in switchMode" 822 | def currentMode = device.currentState("thermostatMode")?.value 823 | def lastTriedMode = state.lastTriedMode ?: currentMode ?: "off" 824 | def modeOrder = modes() 825 | def next = { modeOrder[modeOrder.indexOf(it) + 1] ?: modeOrder[0] } 826 | def nextMode = next(lastTriedMode) 827 | switchToMode(nextMode) 828 | } 829 | 830 | def switchToMode(nextMode) { 831 | //log.debug "In switchToMode = ${nextMode}" 832 | if (nextMode in modes()) { 833 | nextMode = nextMode.toLowerCase() 834 | state.lastTriedMode = nextMode 835 | "$nextMode"() 836 | } else { 837 | log.debug("no mode method '$nextMode'") 838 | } 839 | } 840 | 841 | def switchFanMode() { 842 | def currentFanMode = device.currentState("thermostatFanMode")?.value 843 | //log.debug "switching fan from current mode: $currentFanMode" 844 | def returnCommand 845 | 846 | switch (currentFanMode) { 847 | case "on": 848 | returnCommand = switchToFanMode("auto") 849 | break 850 | case "auto": 851 | returnCommand = switchToFanMode("on") 852 | break 853 | 854 | } 855 | if(!currentFanMode) { returnCommand = switchToFanMode("auto") } 856 | returnCommand 857 | } 858 | 859 | def switchToFanMode(nextMode) { 860 | //log.debug "switching to fan mode: $nextMode" 861 | def returnCommand 862 | 863 | if(nextMode == "auto") { 864 | returnCommand = fanAuto() 865 | 866 | } else if(nextMode == "on") { 867 | 868 | returnCommand = fanOn() 869 | 870 | } 871 | 872 | returnCommand 873 | } 874 | 875 | def getDataByName(String name) { 876 | state[name] ?: device.getDataValue(name) 877 | } 878 | 879 | def setThermostatMode(String mode) { 880 | //log.debug "setThermostatMode($mode)" 881 | mode = mode.toLowerCase() 882 | switchToMode(mode) 883 | } 884 | 885 | def setThermostatFanMode(String mode) { 886 | //log.debug "setThermostatFanMode($mode)" 887 | mode = mode.toLowerCase() 888 | switchToFanMode(mode) 889 | } 890 | 891 | def generateModeEvent(mode) { 892 | sendEvent(name: "thermostatMode", value: mode, descriptionText: "$device.displayName is in ${mode} mode", displayed: true) 893 | } 894 | 895 | def generateFanModeEvent(fanMode) { 896 | sendEvent(name: "thermostatFanMode", value: fanMode, descriptionText: "$device.displayName fan is in ${fanMode} mode", displayed: true) 897 | } 898 | 899 | def generateOperatingStateEvent(operatingState) { 900 | sendEvent(name: "thermostatOperatingState", value: operatingState, descriptionText: "$device.displayName is ${operatingState}", displayed: true) 901 | } 902 | 903 | def off() { 904 | //log.debug "off" 905 | def deviceId = device.deviceNetworkId 906 | if (parent.setStringCmd (deviceId,"SetSystemMode","Off")) { 907 | generateModeEvent("off") 908 | //runIn(5, "poll") 909 | } 910 | else { 911 | log.debug "Error setting new mode." 912 | def currentMode = device.currentState("thermostatMode")?.value 913 | //generateModeEvent(currentMode) // reset the tile back 914 | } 915 | generateSetpointEvent() 916 | generateStatusEvent() 917 | } 918 | 919 | def heat() { 920 | //log.debug "heat" 921 | def deviceId = device.deviceNetworkId 922 | if (parent.setStringCmd (deviceId,"SetSystemMode","Heat")){ 923 | generateModeEvent("heat") 924 | //runIn(5, "poll") 925 | } 926 | else { 927 | log.debug "Error setting new mode." 928 | def currentMode = device.currentState("thermostatMode")?.value 929 | //generateModeEvent(currentMode) // reset the tile back 930 | } 931 | generateSetpointEvent() 932 | generateStatusEvent() 933 | } 934 | 935 | def emergencyHeat() { 936 | auxHeatOnly() 937 | } 938 | def aux() { 939 | auxHeatOnly() 940 | } 941 | def auxHeatOnly() { 942 | //log.debug "auxHeatOnly" 943 | def deviceId = device.deviceNetworkId 944 | if (parent.setStringCmd (deviceId,"SetSystemMode","Aux")) { 945 | generateModeEvent("aux") 946 | //runIn(5, "poll") 947 | } 948 | else { 949 | log.debug "Error setting new mode." 950 | def currentMode = device.currentState("thermostatMode")?.value 951 | //generateModeEvent(currentMode) // reset the tile back 952 | } 953 | generateSetpointEvent() 954 | generateStatusEvent() 955 | } 956 | 957 | def cool() { 958 | //log.debug "cool" 959 | def deviceId = device.deviceNetworkId 960 | if (parent.setStringCmd (deviceId,"SetSystemMode","Cool")){ 961 | generateModeEvent("cool") 962 | //runIn(5, "poll") 963 | } 964 | else { 965 | log.debug "Error setting new mode." 966 | def currentMode = device.currentState("thermostatMode")?.value 967 | //generateModeEvent(currentMode) // reset the tile back 968 | } 969 | generateSetpointEvent() 970 | generateStatusEvent() 971 | } 972 | 973 | def auto() { 974 | //log.debug "auto" 975 | def deviceId = device.deviceNetworkId 976 | if (parent.setStringCmd (deviceId,"SetSystemMode","Auto")) { 977 | generateModeEvent("auto") 978 | //runIn(5, "poll") 979 | } 980 | else { 981 | log.debug "Error setting new mode." 982 | def currentMode = device.currentState("thermostatMode")?.value 983 | //generateModeEvent(currentMode) // reset the tile back 984 | } 985 | 986 | generateSetpointEvent() 987 | generateStatusEvent() 988 | } 989 | 990 | def fanOn() { 991 | //log.debug "fanOn" 992 | def cmdVal = "On" 993 | def deviceId = device.deviceNetworkId 994 | def cmdString = "SetFanMode" 995 | if (parent.setStringCmd( deviceId,cmdString,cmdVal)) { 996 | sendEvent([name: "thermostatFanMode", value: "on", descriptionText: "${device.name} sent ${cmdString} ${cmdVal}"]) 997 | } else { 998 | log.debug "Error setting new mode." 999 | def currentFanMode = device.currentState("thermostatFanMode")?.value 1000 | //sendEvent([name: "thermostatFanMode", value: currentFanMode, descriptionText: "${device.name} sent ${cmdString} ${cmdVal}"]) 1001 | } 1002 | 1003 | } 1004 | def setKeypadLockoutOn() { 1005 | TRACE( "setKeypadLockoutOn()") 1006 | def cmdVal = "On" 1007 | def deviceId = device.deviceNetworkId 1008 | def cmdSetting = "ChangeSetting" 1009 | def cmdString = "KeypadLockout" 1010 | if (parent.setSettingsStringCmd( deviceId,cmdSetting,cmdString,cmdVal)) { 1011 | sendEvent([name: "keypadLockout", value: "On", descriptionText: "${device.name} sent ${cmdSetting} ${cmdString} ${cmdVal}"]) 1012 | } else { 1013 | log.debug "Error setting keypad lockout." 1014 | def currentKeypadLockout = device.currentState("keypadLockout")?.value 1015 | //sendEvent([name: "thermostatFanMode", value: currentFanMode, descriptionText: "${device.name} sent ${cmdString} ${cmdVal}"]) 1016 | } 1017 | } 1018 | 1019 | def setKeypadLockoutOff() { 1020 | TRACE( "setKeypadLockoutOff()") 1021 | def cmdVal = "Off" 1022 | def deviceId = device.deviceNetworkId 1023 | def cmdSetting = "ChangeSetting" 1024 | def cmdString = "KeypadLockout" 1025 | if (parent.setSettingsStringCmd( deviceId,cmdSetting,cmdString,cmdVal)) { 1026 | sendEvent([name: "keypadLockout", value: "Off", descriptionText: "${device.name} sent ${cmdSetting} ${cmdString} ${cmdVal}"]) 1027 | } else { 1028 | log.debug "Error setting keypad lockout." 1029 | def currentKeypadLockout = device.currentState("keypadLockout")?.value 1030 | //sendEvent([name: "thermostatFanMode", value: currentFanMode, descriptionText: "${device.name} sent ${cmdString} ${cmdVal}"]) 1031 | } 1032 | } 1033 | 1034 | def fanAuto() { 1035 | TRACE("fanAuto()") 1036 | def cmdVal = "Auto" 1037 | def deviceId = device.deviceNetworkId 1038 | def cmdString = "SetFanMode" 1039 | if (parent.setStringCmd(deviceId,cmdString,cmdVal)) { 1040 | sendEvent([name: "thermostatFanMode", value: "auto", descriptionText: "${device.name} sent ${cmdString} ${cmdVal}"]) 1041 | 1042 | } else { 1043 | log.debug "Error setting new mode." 1044 | def currentFanMode = device.currentState("thermostatFanMode")?.value 1045 | //sendEvent([name: "thermostatFanMode", value: currentFanMode, descriptionText: "${device.name} sent ${cmdString} ${cmdVal}"]) 1046 | } 1047 | 1048 | 1049 | } 1050 | 1051 | def generateSetpointEvent() { 1052 | //log.debug "Generate SetPoint Event" 1053 | 1054 | def mode = device.currentValue("thermostatMode") 1055 | def sensiMode = device.currentValue("sensiThermostatMode") 1056 | def operatingState = device.currentValue("thermostatOperatingState") 1057 | def heatingSetpoint = device.currentValue("heatingSetpoint") 1058 | def coolingSetpoint = device.currentValue("coolingSetpoint") 1059 | 1060 | TRACE( "Current Mode = ${mode} sensiMode: ${sensiMode}") 1061 | TRACE( "Heating Setpoint = ${heatingSetpoint}") 1062 | TRACE( "Cooling Setpoint = ${coolingSetpoint}") 1063 | 1064 | 1065 | 1066 | if (mode == "heat") { 1067 | sendEvent("name":"thermostatSetpoint", "value":heatingSetpoint, "unit":location.temperatureScale) 1068 | } 1069 | else if (mode == "cool") { 1070 | sendEvent("name":"thermostatSetpoint", "value":coolingSetpoint, "unit":location.temperatureScale) 1071 | } else if (mode == "auto") { 1072 | if(sensiMode =="AutoHeat") { 1073 | sendEvent("name":"thermostatSetpoint", "value":heatingSetpoint, "unit":location.temperatureScale) 1074 | } else if(sensiMode =="AutoCool") { 1075 | sendEvent("name":"thermostatSetpoint", "value":coolingSetpoint, "unit":location.temperatureScale) 1076 | } 1077 | } else if (mode == "off") { 1078 | sendEvent("name":"thermostatSetpoint", "value":"Off") 1079 | } else if (mode == "aux") { 1080 | sendEvent("name":"thermostatSetpoint", "value":heatingSetpoint, "unit":location.temperatureScale) 1081 | } 1082 | } 1083 | 1084 | void raiseSetpoint() { 1085 | def mode = device.currentValue("thermostatMode") 1086 | 1087 | def targetvalue 1088 | def maxHeatingSetpoint = device.currentValue("maxHeatingSetpoint") 1089 | def maxCoolingSetpoint = device.currentValue("maxCoolingSetpoint") 1090 | def sensiMode = device.currentValue("sensiThermostatMode") 1091 | if (mode == "off" ) { 1092 | log.warn "this mode: $mode does not allow raiseSetpoint" 1093 | } else { 1094 | if(mode == "auto") { 1095 | //The Sensi thermostat shares if it is in Auto Heating or Auto Cooling, so a raise should be able to go off the of operating mode ? 1096 | if (sensiMode =="autoheat") { mode = "heat" } 1097 | else if (sensiMode =="autocool") { mode = "cool" } 1098 | } 1099 | 1100 | def heatingSetpoint = device.currentValue("heatingSetpoint") 1101 | def coolingSetpoint = device.currentValue("coolingSetpoint") 1102 | def thermostatSetpoint = device.currentValue("thermostatSetpoint") 1103 | 1104 | targetvalue = thermostatSetpoint ? thermostatSetpoint : 0 1105 | targetvalue = targetvalue + 1 1106 | 1107 | if ((mode == "heat" || mode == "aux") && targetvalue > maxHeatingSetpoint) { 1108 | targetvalue = maxHeatingSetpoint 1109 | } else if (mode == "cool" && targetvalue > maxCoolingSetpoint) { 1110 | targetvalue = maxCoolingSetpoint 1111 | } 1112 | 1113 | sendEvent("name":"thermostatSetpoint", "value":targetvalue, "unit":location.temperatureScale, displayed: false) 1114 | log.info "In mode $mode raiseSetpoint() to $targetvalue" 1115 | 1116 | runIn(3, "alterSetpoint", [data: [value:targetvalue], overwrite: true]) //when user click button this runIn will be overwrite 1117 | } 1118 | } 1119 | 1120 | //called by tile when user hit raise temperature button on UI 1121 | void lowerSetpoint() { 1122 | def mode = device.currentValue("thermostatMode") 1123 | def targetvalue 1124 | def minHeatingSetpoint = device.currentValue("minHeatingSetpoint") 1125 | def minCoolingSetpoint = device.currentValue("minCoolingSetpoint") 1126 | def sensiMode = device.currentValue("sensiThermostatMode") 1127 | if (mode == "off" ) { 1128 | log.warn "this mode: $mode does not allow lowerSetpoint()" 1129 | } else { 1130 | if(mode == "auto") { 1131 | //The Sensi thermostat shares if it is in Auto Heating or Auto Cooling, so a raise should be able to go off the of operating mode ? 1132 | if (sensiMode =="autoheat") { mode = "heat" } 1133 | else if (sensiMode =="autocool") { mode = "cool" } 1134 | } 1135 | def heatingSetpoint = device.currentValue("heatingSetpoint") 1136 | def coolingSetpoint = device.currentValue("coolingSetpoint") 1137 | def thermostatSetpoint = device.currentValue("thermostatSetpoint") 1138 | 1139 | targetvalue = thermostatSetpoint ? thermostatSetpoint : 0 1140 | targetvalue = targetvalue - 1 1141 | 1142 | if ((mode == "heat" || mode == "aux") && targetvalue < minHeatingSetpoint) { 1143 | targetvalue = minHeatingSetpoint 1144 | } else if (mode == "cool" && targetvalue < minCoolingSetpoint) { 1145 | targetvalue = minCoolingSetpoint 1146 | } 1147 | 1148 | sendEvent("name":"thermostatSetpoint", "value":targetvalue, "unit":location.temperatureScale, displayed: false) 1149 | log.info "In mode $mode lowerSetpoint() to $targetvalue" 1150 | 1151 | runIn(3, "alterSetpoint", [data: [value:targetvalue], overwrite: true]) //when user click button this runIn will be overwrite 1152 | } 1153 | } 1154 | 1155 | //called by raiseSetpoint() and lowerSetpoint() 1156 | void alterSetpoint(temp) { 1157 | def deviceId = device.deviceNetworkId 1158 | def mode = device.currentValue("thermostatMode") 1159 | def sensiMode = device.currentValue("sensiThermostatMode") 1160 | //"on" means the schedule will not run 1161 | //"temporary" means do nothing special" 1162 | //"off" means do nothing special 1163 | def currentHoldMode = getDataByName("thermostatHoldMode") 1164 | def desiredHoldType = holdType == null ? "temporary" : holdType 1165 | // log.debug "holdType is: ${holdType} des Hold type is: ${desiredHoldType}" 1166 | if( (desiredHoldType == "Permanent") && (currentHoldMode != "on")) { 1167 | parent.setStringCmd(deviceId, "SetScheduleMode", "Off") 1168 | sendEvent(name:"thermostatHoldMode", value: "on") 1169 | } else { 1170 | sendEvent(name:"thermostatHoldMode", value: "temporary") 1171 | } 1172 | if (mode == "off" ) { 1173 | log.warn "this mode: $mode does not allow alterSetpoint" 1174 | } else { 1175 | def heatingSetpoint = device.currentValue("heatingSetpoint") 1176 | def coolingSetpoint = device.currentValue("coolingSetpoint") 1177 | 1178 | 1179 | def targetHeatingSetpoint 1180 | def targetCoolingSetpoint 1181 | def thermostatSetpoint 1182 | def modeNum = 0 1183 | def temperatureScaleHasChanged = false 1184 | 1185 | if (location.temperatureScale == "C") { 1186 | if ( heatingSetpoint > 40.0 || coolingSetpoint > 40.0 ) { 1187 | temperatureScaleHasChanged = true 1188 | } 1189 | } else { 1190 | if ( heatingSetpoint < 40.0 || coolingSetpoint < 40.0 ) { 1191 | temperatureScaleHasChanged = true 1192 | } 1193 | } 1194 | def cmdString 1195 | //step1: check thermostatMode, enforce limits before sending request to cloud 1196 | if ((mode == "heat") || (mode == "aux") || (sensiMode == "autoheat")){ 1197 | modeNum = 1 1198 | if((mode == "heat") || (mode == "aux")) { cmdString = "SetHeat"} 1199 | else if(sensiMode == "autoheat") { cmdString = "SetAutoHeat" } 1200 | if (temp.value > coolingSetpoint){ 1201 | targetHeatingSetpoint = temp.value 1202 | //targetCoolingSetpoint = temp.value 1203 | } else { 1204 | targetHeatingSetpoint = temp.value 1205 | //targetCoolingSetpoint = coolingSetpoint 1206 | } 1207 | } else if ((mode == "cool") || (sensiMode == "autocool") ) { 1208 | modeNum = 2 1209 | if(mode == "cool") { cmdString = "SetCool" } 1210 | else if (sensiMode == "autocool") { cmdString = "SetAutoCool" } 1211 | //enforce limits before sending request to cloud 1212 | if (temp.value < heatingSetpoint){ 1213 | //targetHeatingSetpoint = temp.value 1214 | targetCoolingSetpoint = temp.value 1215 | } else { 1216 | //targetHeatingSetpoint = heatingSetpoint 1217 | targetCoolingSetpoint = temp.value 1218 | } 1219 | } 1220 | 1221 | TRACE( "alterSetpoint >> in mode ${mode} trying to change heatingSetpoint to $targetHeatingSetpoint " + 1222 | "coolingSetpoint to $targetCoolingSetpoint with holdType : ${holdType}") 1223 | 1224 | def sendHoldType = getDataByName("thermostatHoldMode") 1225 | if (parent.setTempCmd(deviceId,cmdString, temp.value)) { 1226 | sendEvent("name": "thermostatSetpoint", "value": temp.value, displayed: false) 1227 | if(modeNum == 1) { sendEvent("name": "heatingSetpoint", "value": targetHeatingSetpoint, "unit": location.temperatureScale) } 1228 | else if (modeNum == 2) { sendEvent("name": "coolingSetpoint", "value": targetCoolingSetpoint, "unit": location.temperatureScale) } 1229 | log.debug "alterSetpoint in mode $mode succeed change setpoint to= ${temp.value}" 1230 | } else { 1231 | log.error "Error alterSetpoint()" 1232 | if (mode == "heat" || mode == "aux" || sensiMode == "autoheat"){ 1233 | //sendEvent("name": "thermostatSetpoint", "value": heatingSetpoint.toString(), displayed: false) 1234 | } else if (mode == "cool" || sensiMode == "autocool") { 1235 | //sendEvent("name": "thermostatSetpoint", "value": coolingSetpoint.toString(), displayed: false) 1236 | } 1237 | } 1238 | //runIn(5, "poll") 1239 | 1240 | if ( temperatureScaleHasChanged ) 1241 | generateSetpointEvent() 1242 | generateStatusEvent() 1243 | } 1244 | } 1245 | 1246 | def generateStatusEvent() { 1247 | def mode = device.currentValue("thermostatMode") 1248 | def operatingMode = device.currentValue("thermostatOperatingState") 1249 | def heatingSetpoint = device.currentValue("heatingSetpoint") 1250 | def coolingSetpoint = device.currentValue("coolingSetpoint") 1251 | def temperature = device.currentValue("temperature") 1252 | def statusText 1253 | 1254 | // log.debug "Generate Status Event for Mode = ${mode} in state: ${operatingMode}" 1255 | // log.debug "Temperature = ${temperature}" 1256 | // log.debug "Heating set point = ${heatingSetpoint}" 1257 | // log.debug "Cooling set point = ${coolingSetpoint}" 1258 | // log.debug "HVAC Mode = ${mode}" 1259 | 1260 | if(operatingMode == "heat") { 1261 | statusText = "Heating to ${heatingSetpoint} ${location.temperatureScale}" 1262 | } 1263 | else if (operatingMode == "cool") { 1264 | statusText = "Cooling to ${coolingSetpoint} ${location.temperatureScale}" 1265 | } 1266 | else if (operatingMode == "aux") { 1267 | statusText = "Emergency Heat" 1268 | } 1269 | else if (operatingMode == "off") { 1270 | statusText = "Idle" 1271 | }else { 1272 | statusText = "?" 1273 | } 1274 | 1275 | // log.debug "Generate Status Event = ${statusText}" 1276 | sendEvent("name":"thermostatStatus", "value":statusText, "description":statusText, displayed: true) 1277 | } 1278 | 1279 | def generateActivityFeedsEvent(notificationMessage) { 1280 | sendEvent(name: "notificationMessage", value: "$device.displayName $notificationMessage", descriptionText: "$device.displayName $notificationMessage", displayed: true) 1281 | } 1282 | 1283 | def roundC (tempC) { 1284 | return (Math.round(tempC.toDouble() * 2))/2 1285 | } 1286 | 1287 | def convertFtoC (tempF) { 1288 | return ((Math.round(((tempF - 32)*(5/9)) * 2))/2).toDouble() 1289 | } 1290 | 1291 | def convertCtoF (tempC) { 1292 | return (Math.round(tempC * (9/5)) + 32).toInteger() 1293 | } 1294 | 1295 | 1296 | private def TRACE(message) { 1297 | log.debug message 1298 | } --------------------------------------------------------------------------------