├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── apps ├── jason0x43-circadian.groovy ├── jason0x43-garage_door_manager.groovy ├── jason0x43-nest_integration.groovy ├── jason0x43-scene.groovy ├── jason0x43-scene_manager.groovy └── jason0x43-wemo_connect.groovy ├── drivers ├── jason0x43-3_button_remote.groovy ├── jason0x43-darksky.groovy ├── jason0x43-garage_door_controller.groovy ├── jason0x43-hue_dimmer_switch.groovy ├── jason0x43-nest_thermostat.groovy ├── jason0x43-pushover.groovy ├── jason0x43-virtual_momentary_switch.groovy ├── jason0x43-wemo_dimmer.groovy ├── jason0x43-wemo_insight_switch.groovy ├── jason0x43-wemo_maker.groovy ├── jason0x43-wemo_motion.groovy └── jason0x43-wemo_switch.groovy ├── hubitat ├── packageManifest-wemo.json ├── repository.json └── src ├── index.ts ├── lib ├── commands │ ├── events.ts │ ├── info.ts │ ├── install.ts │ ├── list.ts │ ├── log.ts │ ├── pull.ts │ ├── push.ts │ └── run.ts ├── common.ts ├── log.ts ├── manifest.ts ├── request.ts └── resource.ts ├── package-lock.json ├── package.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | HUBITAT_HOST=10.0.1.99 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | manifest.json 3 | .env 4 | .repos 5 | src/**/*.js 6 | src/**/*.js.map 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hubitat stuff 2 | 3 | This repository contains the local drivers and apps that are currently loaded 4 | onto my Hubitat, along with a script to interact with the Hubitat. Some of the 5 | apps and drivers are my own, some are from other sources, and a few are ones 6 | from other sources that I cleaned up and/or modified. 7 | 8 | ## WeMo support 9 | 10 | This repo contains an application and drivers supporting several WeMo devices: 11 | 12 | - Dimmer 13 | - Insight Switch 14 | - Switch 15 | - Motion 16 | - Maker (still in development) 17 | 18 | To use the WeMo drivers: 19 | 20 | 1. Add `apps/jason0x43-wemo_connect.groovy` to Apps Code 21 | 2. Add all of the `drivers/jason0x43-wemo_*.groovy` files (or at least the ones 22 | for your devices) to Drivers Code 23 | 3. In Apps, click Add User App and select WeMo Connect 24 | 4. In the Device Discovery page, wait for the app to detect any WeMo devices on 25 | your network. This can take anywhere from a few seconds to a couple of 26 | minutes. 27 | 5. Select the WeMo devices you want to use with Hubitat, and click the Done 28 | button at the bottom of the page. 29 | 30 | After clicking Done, new Hubitat devices will be created for each of the 31 | selected WeMo devices. By default their labels will be the same as their WeMo 32 | names, and their types will start with Wemo. 33 | 34 | ## Nest Integration 35 | 36 | This repo contains an integration app and driver to control Nest thermostats. 37 | See the [Nest Integration app source](./apps/jason0x43-nest.groovy) for setup 38 | instructions. 39 | 40 | ## hubitat script 41 | 42 | The `hubitat` application is TypeScript application that allows some degree of 43 | interaction with the Hubitat through the command line. The `hubitat` script in 44 | the root of this repo is a small bash script that will run the `hubitat` 45 | application, rebuilding it as necessary. 46 | 47 | At the moment, `hubitat` supports 4 commands: 48 | 49 | - **list** - List apps, devices, or drivers 50 | - **log** - Log messages emitted by apps or devices 51 | - **pull** - Pull code from the hubitat into the local repo 52 | - **push** - Push code from the local repo to the hubitat 53 | 54 | # License 55 | 56 | Unless otherwise stated in a given file, everything in here uses the 57 | [The Unlicense](./LICENSE). 58 | -------------------------------------------------------------------------------- /apps/jason0x43-circadian.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Circadian 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2018-05-28, 11:14:28-0400 6 | * 7 | * Set light color temperature throughout the day. 8 | */ 9 | 10 | definition( 11 | name: 'Circadian', 12 | namespace: 'jason0x43', 13 | author: 'j.cheatham@gmail.com', 14 | singleInstance: true, 15 | description: 'Set light colors throughout the day.', 16 | iconUrl: 'https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png', 17 | iconX2Url: 'https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png' 18 | ) 19 | 20 | preferences { 21 | page(name: 'mainPage') 22 | } 23 | 24 | def mainPage() { 25 | dynamicPage(name: 'mainPage', uninstall: true, install: true) { 26 | section('Select lights to manage:') { 27 | input( 28 | name: 'lights', 29 | type: 'capability.colorTemperature', 30 | multiple: true 31 | ) 32 | } 33 | 34 | section('Options') { 35 | input( 36 | name: 'maxTemp', 37 | title: 'Maximum temperature, used at noon', 38 | type: 'number', 39 | defaultValue: 6500, 40 | required: true, 41 | range: '2200..6500' 42 | ) 43 | input( 44 | name: 'minTemp', 45 | title: 'Minimum temperature, used at night', 46 | type: 'number', 47 | defaultValue: 2200, 48 | required: true, 49 | range: '2200..6500' 50 | ) 51 | input( 52 | name: 'midTemp', 53 | title: 'Base temperature, used at dawn and dusk', 54 | type: 'number', 55 | defaultValue: 2700, 56 | required: true, 57 | range: '2200..6500' 58 | ) 59 | } 60 | } 61 | } 62 | 63 | def installed() { 64 | log.debug "Installed with settings: ${settings}" 65 | initialize() 66 | } 67 | 68 | def uninstalled() { 69 | log.debug 'Uninstalled' 70 | unsubscribe() 71 | } 72 | 73 | def updated() { 74 | log.debug "Updated with settings: ${settings}" 75 | unsubscribe() 76 | initialize() 77 | } 78 | 79 | def initialize() { 80 | subscribeToAll() 81 | updateColorTemp() 82 | runEvery5Minutes(updateColorTemp) 83 | } 84 | 85 | /** 86 | * Update the current color temp used for all lights 87 | */ 88 | def updateColorTemp() { 89 | log.debug 'Updating color temp' 90 | 91 | def times = getSunriseAndSunset(sunriseOffset: 0, sunsetOffset: 0) 92 | def sunrise = times.sunrise.time 93 | def sunset = times.sunset.time 94 | def midday = sunrise + ((sunset - sunrise) / 2) 95 | def now = new Date().time 96 | 97 | def maxTemp = settings.maxTemp 98 | def minTemp = settings.minTemp 99 | def tempRange = maxTemp - minTemp 100 | 101 | log.trace "sunrise: ${sunrise}, sunset: ${sunset}, midday: ${midday}" 102 | 103 | if (now > sunset && now < sunrise) { 104 | state.colorTemp = minTemp 105 | } else { 106 | def temp 107 | 108 | if (now > midday) { 109 | temp = maxTemp - ((now - midday) / (sunset - midday) * tempRange) 110 | } else { 111 | temp = minTemp + ((now - sunrise) / (midday - sunrise) * tempRange) 112 | } 113 | 114 | state.colorTemp = temp.toInteger() 115 | } 116 | 117 | log.debug "New colorTemp is ${state.colorTemp}" 118 | 119 | // Update the color temp of any lights that are currently on 120 | for (light in lights) { 121 | if (light.currentSwitch == 'on') { 122 | light.setColorTemperature(state.colorTemp) 123 | } 124 | } 125 | } 126 | 127 | /** 128 | * Update the color temp of a light when it's turned on 129 | */ 130 | def setLightTemp(evt) { 131 | def device = evt.getDevice() 132 | log.debug "Setting color temp for ${device} to ${state.colorTemp}" 133 | evt.getDevice().setColorTemperature(state.colorTemp) 134 | } 135 | 136 | private subscribeToAll() { 137 | for (light in lights) { 138 | subscribe(light, 'switch.on', setLightTemp) 139 | log.trace 'Subscribed to switch state for ' + light 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /apps/jason0x43-garage_door_manager.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Garage Door Manager 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2018-05-28, 10:39:15-0400 6 | */ 7 | 8 | definition( 9 | name: 'Garage Door Manager', 10 | namespace: 'jason0x43', 11 | author: 'j.cheatham@gmail.com', 12 | description: 'Manage a composite garage door opener', 13 | singleInstance: true, 14 | iconUrl: 'https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png', 15 | iconX2Url: 'https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png' 16 | ) 17 | 18 | preferences { 19 | page(name: 'mainPage') 20 | } 21 | 22 | def mainPage() { 23 | def canInstall = true 24 | 25 | if ( 26 | (contactSensor == null && threeAxisSensor == null) 27 | || relay == null 28 | ) { 29 | canInstall = false 30 | } 31 | 32 | dynamicPage(name: 'mainPage', uninstall: true, install: canInstall) { 33 | section('Relay') { 34 | paragraph( 35 | 'The relay is the actuator that actually controls the door.' + 36 | 'This device MUST be configured to auto-shutoff.' 37 | ) 38 | 39 | input( 40 | name: 'relay', 41 | type: 'capability.switch', 42 | title: 'Garage Door Relay', 43 | submitOnChange: true, 44 | required: true 45 | ) 46 | } 47 | 48 | section('State Sensor') { 49 | paragraph( 50 | 'A contact or acceleration sensor is used to tell if the ' + 51 | 'door is open or closed. One of these is required.' 52 | ) 53 | 54 | input( 55 | name: 'contactSensor', 56 | type: 'capability.contactSensor', 57 | title: 'Contact Sensor', 58 | submitOnChange: true, 59 | required: false 60 | ) 61 | 62 | input( 63 | name: 'threeAxisSensor', 64 | type: 'capability.threeAxis', 65 | title: 'Three Axis Sensor', 66 | submitOnChange: true, 67 | required: false 68 | ) 69 | 70 | if (threeAxisSensor) { 71 | paragraph "Current value: ${threeAxisSensor.currentThreeAxis}" 72 | input( 73 | name: 'closedX', 74 | type: 'number', 75 | title: 'Closed x', 76 | required: true 77 | ) 78 | input( 79 | name: 'closedY', 80 | type: 'number', 81 | title: 'Closed y', 82 | required: true 83 | ) 84 | input( 85 | name: 'closedZ', 86 | type: 'number', 87 | title: 'Closed z', 88 | required: true 89 | ) 90 | } 91 | } 92 | 93 | section('Activity Sensor') { 94 | paragraph( 95 | 'This sensor can be used to tell if the door is in motion.' 96 | ) 97 | 98 | input( 99 | name: 'accelerationSensor', 100 | type: 'capability.accelerationSensor', 101 | title: 'Acceleration Sensor', 102 | submitOnChange: true, 103 | required: false 104 | ) 105 | } 106 | } 107 | } 108 | 109 | def installed() { 110 | init() 111 | } 112 | 113 | def updated() { 114 | log.trace('Unsubscribing...') 115 | unsubscribe() 116 | init() 117 | } 118 | 119 | def close() { 120 | if (state.current == 'opening') { 121 | // stop and restart 122 | relay.on() 123 | runIn(5, pushRelay) 124 | } else if (state.current == 'open') { 125 | // start 126 | relay.on() 127 | } 128 | } 129 | 130 | def handleAcceleration(evt) { 131 | def value = evt.value 132 | log.trace "Handling acceleration event: ${value}" 133 | 134 | updateState([acceleration: value]) 135 | state.previousAcceleration = value 136 | } 137 | 138 | def handleContact(evt) { 139 | def value = evt.value 140 | log.trace "Handling contact event: ${value}" 141 | 142 | updateState([contact: value]) 143 | state.previousContact = value 144 | } 145 | 146 | def handleThreeAxis(evt) { 147 | def value = parseThreeAxis(evt.value) 148 | log.trace "Handling theeAxis event: ${value}" 149 | 150 | updateState([threeAxis: value]) 151 | state.previousThreeAxis = value 152 | 153 | if (evt) { 154 | // If we see a three axis event, it means the sensor is moving. Call a 155 | // handler to indicate that we've stopped a few seconds after the last 156 | // threeAxis event. 157 | runIn(5, threeAxisStop) 158 | } 159 | } 160 | 161 | def open() { 162 | if (state.current == 'closing') { 163 | // stop and restart 164 | relay.on() 165 | runIn(5, pushRelay) 166 | } else if (state.current == 'closed') { 167 | // start 168 | relay.on() 169 | } 170 | } 171 | 172 | def pushRelay() { 173 | relay.on() 174 | } 175 | 176 | def refresh() { 177 | try { 178 | accelerationSensor.refresh() 179 | } catch (error) { 180 | // ignore 181 | } 182 | try { 183 | contactSensor.refresh() 184 | } catch (error) { 185 | // ignore 186 | } 187 | try { 188 | threeAxisSensor.refresh() 189 | } catch (error) { 190 | // ignore 191 | } 192 | try { 193 | relay.refresh() 194 | } catch (error) { 195 | // ignore 196 | } 197 | } 198 | 199 | def threeAxisStop() { 200 | if (isMoving()) { 201 | handleAcceleration(value: 'inactive') 202 | } 203 | } 204 | 205 | private getController() { 206 | def children = getChildDevices() 207 | return children.size == 0 ? null : children[0] 208 | } 209 | 210 | private getDistance(a, b) { 211 | def x2 = (a.x - b.x) * (a.x - b.x) 212 | def y2 = (a.y - b.y) * (a.y - b.y) 213 | def z2 = (a.z - b.z) * (a.z - b.z) 214 | def dist = Math.sqrt(x2 + y2 + z2) 215 | return dist 216 | } 217 | 218 | private getThreeAxisState(current) { 219 | if (!current) { 220 | current = parseThreeAxis(threeAxisSensor.currentThreeAxis) 221 | } 222 | // log.trace "Getting threeAxis state for current value ${current}" 223 | 224 | def closed = [x: closedX, y: closedY, z: closedZ]; 225 | return getDistance(current, closed) < 10 ? 'closed' : 'open' 226 | } 227 | 228 | // Get the direction based on three-axis info. This is somewhat unreliable. 229 | private getThreeAxisDirection(current) { 230 | def last = state.previousThreeAxis 231 | if (last == null) { 232 | return 'unknown' 233 | } 234 | 235 | if (!current) { 236 | current = parseThreeAxis(threeAxisSensor.currentThreeAxis) 237 | } 238 | log.trace "Getting threeAxis direction for current value ${current}" 239 | 240 | def closed = [x: closedX, y: closedY, z: closedZ]; 241 | 242 | // log.trace 'Getting last dist from open' 243 | def lastDist = getDistance(last, closed); 244 | 245 | // log.trace 'Getting current dist from open' 246 | def currentDist = getDistance(current, closed); 247 | 248 | // Only consider distances over 10 units to prevent bounce-related 249 | // direction changes 250 | if ((lastDist - currentDist).abs() > 10) { 251 | if (lastDist < currentDist) { 252 | log.trace "Opening (${lastDist}, ${currentDist})" 253 | return 'opening' 254 | } 255 | 256 | if (lastDist > currentDist) { 257 | log.trace "Closing (${lastDist}, ${currentDist})" 258 | return 'closing' 259 | } 260 | } 261 | 262 | return 'unknown'; 263 | } 264 | 265 | private init() { 266 | if (!getController()) { 267 | log.trace "Creating child device..." 268 | def child = addChildDevice( 269 | 'jason0x43', 270 | 'Garage Door Controller', 271 | 'garage-door-controller', 272 | null, 273 | [ 'label': 'Garage door' ] 274 | ) 275 | log.trace "Created ${child.displayName} with id ${child.id}" 276 | } 277 | 278 | if (accelerationSensor) { 279 | subscribe(accelerationSensor, 'acceleration', handleAcceleration); 280 | log.trace 'Subscribed to acceleration' 281 | } 282 | 283 | if (contactSensor) { 284 | subscribe(contactSensor, 'contact', handleContact); 285 | log.trace 'Subscribed to contact' 286 | } 287 | 288 | if (threeAxisSensor) { 289 | subscribe(threeAxisSensor, 'threeAxis', handleThreeAxis); 290 | log.trace 'Subscribed to threeAxis' 291 | } 292 | } 293 | 294 | private isMoving() { 295 | return state.current == 'closing' || state.current == 'opening'; 296 | } 297 | 298 | private parseThreeAxis(val) { 299 | def matcher = val =~ /\[x:([^,]+),\s*y:([^,]+),\s*z:([^,]+)\]/ 300 | if (matcher.matches()) { 301 | def x = matcher[0][1].toInteger() 302 | def y = matcher[0][2].toInteger() 303 | def z = matcher[0][3].toInteger() 304 | return [x: x, y: y, z: z] 305 | } 306 | } 307 | 308 | private updateState(evt) { 309 | log.trace "updateState(${evt})" 310 | 311 | if (evt.contact) { 312 | if (evt.contact == 'closed') { 313 | state.current = 'closed' 314 | } else { 315 | state.current = 'opening' 316 | } 317 | } else if (evt.acceleration) { 318 | if (evt.acceleration == 'active') { 319 | // Door has started to move; guess the current direction based on 320 | // the current and last states 321 | if (state.current == 'open') { 322 | if (state.lastDirection == 'closing') { 323 | state.current = 'opening' 324 | } else { 325 | state.current = 'closing' 326 | } 327 | } else { 328 | state.current = 'opening' 329 | } 330 | } else { 331 | // Door has stopped. Use three-axis value if we have it, else wait 332 | // for contact event. 333 | if (threeAxisSensor) { 334 | state.current = getThreeAxisState() 335 | } 336 | } 337 | } else { 338 | // Don't pay attention to three-axis movement values if there is a contact 339 | // sensor and it's state is 'closed' or if there is an acceleration 340 | // sensor and it's state is 'inactive'. 341 | if ( 342 | (!contactSensor || contactSensor.currentContact == 'open') && 343 | (!accelerationSensor || accelerationSensor.currentAcceleration == 'active') 344 | ) { 345 | def dir = getThreeAxisDirection(evt.threeAxis) 346 | if ( 347 | dir == 'unknown' && 348 | !(state.current == 'opening' || state.current == 'closing') 349 | ) { 350 | state.current = dir 351 | } 352 | } 353 | } 354 | 355 | if (state.current == 'opening' || state.current == 'closing') { 356 | state.lastDirection = state.current; 357 | } 358 | 359 | getController().setDoorState(door: state.current) 360 | } 361 | -------------------------------------------------------------------------------- /apps/jason0x43-nest_integration.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Manager app for Nest thermostat 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2018-12-13, 08:29:11-0500 6 | * 7 | * To use this app you first need to create an OAuth client on 8 | * https://developers.nest.com. The properties should look like: 9 | * 10 | * Client name: whatever you want 11 | * Description: again, whatever you want 12 | * Categories: "home automation" seems reasonable 13 | * Users: "Individual" 14 | * Support URL: whatever you want 15 | * Default OAuth redirect URI: leave this blank 16 | * Additional OAuth redirect URIs: leave these blank 17 | * Permissions: 18 | * - Thermostat read/write -- Nest will make you type a 19 | * description for the permission; something like " needs to 20 | * read and write the state of your Nest" will work. 21 | * - Other Permissions - Away read/write (same caveat as above) 22 | * 23 | * Once you have the OAuth client, install this app and enter the 'Client ID' 24 | * and 'Client Secret' values from your Nest OAuth client in the this app's 25 | * inputs, then follow the instructions this app shows you to finish setting it 26 | * up. 27 | */ 28 | 29 | definition( 30 | name: 'Nest Integration', 31 | namespace: 'jason0x43', 32 | author: 'jason0x43', 33 | singleInstance: true, 34 | description: 'Setup and manage a Nest thermostat', 35 | iconUrl: 'https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png', 36 | iconX2Url: 'https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png' 37 | ) 38 | 39 | preferences { 40 | page(name: 'mainPage') 41 | page(name: 'authStartPage') 42 | page(name: 'authFinishPage') 43 | page(name: 'deauthorizePage') 44 | } 45 | 46 | def mainPage() { 47 | def readyToInstall = state.initialized 48 | 49 | dynamicPage( 50 | name: 'mainPage', 51 | submitOnChange: true, 52 | install: readyToInstall, 53 | uninstall: true 54 | ) { 55 | def authState = isAuthorized() ? '[Connected]\n' : '[Not Connected]\n' 56 | 57 | section() { 58 | paragraph( 59 | '

Setup your Nest

' + 60 | '

First, you\'ll need to create ' + 62 | 'an oauth client with Nest. The properties should look ' + 63 | 'like:

' + 64 | '
    ' + 65 | '
  • Client name: whatever you want
  • ' + 66 | '
  • Description: again, whatever you want
  • ' + 67 | '
  • Categories: "home automation" seems reasonable
  • ' + 68 | '
  • Users: "Individual"
  • ' + 69 | '
  • Support URL: whatever you want
  • ' + 70 | '
  • Default OAuth redirect URI: leave this blank
  • ' + 71 | '
  • Additional OAuth redirect URIs: leave these blank
  • ' + 72 | '
  • Permissions' + 73 | '
      ' + 74 | '
    • Thermostat read/write: Nest will make you type a ' + 75 | 'description for the permission; something like "<your app ' + 76 | 'name> needs to read and write the state of your Nest" will ' + 77 | 'work.
    • ' + 78 | '
    • Other Permissions: Away read/write (same caveat as ' + 79 | 'above)
    • ' + 80 | '
    ' + 81 | '
  • ' + 82 | '
' + 83 | '

Once you have the OAuth client, ' + 84 | 'enter the "Client ID" and "Client ' + 85 | 'Secret" values from your Nest OAuth client in the this app\'s' + 86 | 'inputs, then follow the instructions this app shows you to ' + 87 | 'finish setting it up.

' 88 | ) 89 | 90 | input( 91 | name: 'clientId', 92 | title: 'Client ID', 93 | type: 'text', 94 | submitOnChange: true 95 | ) 96 | input( 97 | name: 'clientSecret', 98 | title: 'Client secret', 99 | type: 'text', 100 | submitOnChange: true 101 | ) 102 | 103 | if (settings.clientId && settings.clientSecret && !isAuthorized()) { 104 | href( 105 | 'authStartPage', 106 | title: 'Nest API authorization', 107 | description: "${authState}Tap to connect to Nest" 108 | ) 109 | } 110 | 111 | if (isAuthorized()) { 112 | def structures = getStructures() 113 | 114 | input( 115 | name: 'structure', 116 | title: 'Select which structure to manage', 117 | type: 'enum', 118 | required: false, 119 | multiple: false, 120 | description: 'Tap to choose', 121 | options: structures, 122 | submitOnChange: true 123 | ) 124 | } 125 | 126 | if (isAuthorized() && settings.structure) { 127 | def thermostats = getThermostats() 128 | 129 | input( 130 | name: 'thermostats', 131 | title: 'Select which thermostats to use with Hubitat', 132 | type: 'enum', 133 | required: false, 134 | multiple: true, 135 | description: 'Tap to choose', 136 | options: thermostats, 137 | submitOnChange: true 138 | ) 139 | 140 | input( 141 | name: 'watchMode', 142 | title: 'Use Hubitat mode for home/away?', 143 | type: 'bool', 144 | required: false, 145 | submitOnChange: true 146 | ) 147 | } 148 | 149 | if (isAuthorized()) { 150 | href( 151 | 'deauthorizePage', 152 | title: 'Log out', 153 | description: "Log out of Nest. You'll need to login again " + 154 | 'to control your Nest devices.' 155 | ) 156 | } 157 | } 158 | } 159 | } 160 | 161 | def deauthorizePage() { 162 | disconnect() 163 | 164 | return dynamicPage( 165 | name: 'deauthorizePage', 166 | title: 'Log out', 167 | nextPage: 'mainPage' 168 | ) { 169 | section() { 170 | paragraph 'You have successfully logged out.' 171 | } 172 | } 173 | } 174 | 175 | def authStartPage() { 176 | def params = [ 177 | client_id: settings.clientId, 178 | state: 'STATE' 179 | ] 180 | def uri = "https://home.nest.com/login/oauth2?${stringifyQuery(params)}" 181 | 182 | return dynamicPage( 183 | name: 'authStartPage', 184 | title: 'OAuth Initialization', 185 | nextPage: 'authFinishPage' 186 | ) { 187 | section() { 188 | href( 189 | 'authorizeApp', 190 | title: 'Authorize', 191 | description: 'Click here to open the Nest authorization page in a new ' + 192 | 'tab. Obtain a PIN, then enter it below', 193 | style: 'external', 194 | url: uri 195 | ) 196 | input(name: 'oauthPin', title: 'PIN', type: 'text') 197 | } 198 | } 199 | } 200 | 201 | def authFinishPage() { 202 | log.debug 'Finishing login' 203 | 204 | def status; 205 | def installable; 206 | 207 | try { 208 | httpPost( 209 | uri: "https://api.home.nest.com/oauth2/access_token", 210 | body: [ 211 | grant_type: 'authorization_code', 212 | code: settings.oauthPin, 213 | client_id: settings.clientId, 214 | client_secret: settings.clientSecret 215 | ] 216 | ) { resp -> 217 | log.debug "oauthData: ${resp.data}" 218 | state.accessToken = resp.data.access_token 219 | } 220 | 221 | status = 'Nest was successfully authorized' 222 | installable = true 223 | } catch (error) { 224 | log.error error 225 | status = 'There was a problem authorizing your Nest' 226 | installable = false 227 | } 228 | 229 | return dynamicPage( 230 | name: 'authFinishPage', 231 | nextPage: 'mainPage', 232 | install: installable, 233 | uninstall: true 234 | ) { 235 | section() { 236 | paragraph(status) 237 | } 238 | } 239 | } 240 | 241 | def installed() { 242 | log.debug "Installed with settings: ${settings}" 243 | initialize() 244 | } 245 | 246 | def updated() { 247 | log.debug "Updating with settings: ${settings}" 248 | initialize() 249 | } 250 | 251 | def initialize() { 252 | log.debug 'Initializing' 253 | 254 | state.initialized = true 255 | state.reAttemptInterval = 15 256 | 257 | settings.thermostats.collect { id -> 258 | def dni = "${app.id}.${id}" 259 | def d = getChildDevice(dni) 260 | def label = "Nest: ${state.thermostatNames[id]}" 261 | 262 | if (!d) { 263 | // log.trace 'Adding device for thermostat ' + dni 264 | addChildDevice( 265 | 'jason0x43', 266 | 'Nest Thermostat', 267 | dni, 268 | null, 269 | [ 270 | label: label, 271 | completedSetup: true, 272 | data: [ 273 | nestId: id 274 | ] 275 | ] 276 | ) 277 | } else { 278 | // log.trace "Updating thermostat ${dni} with label ${label} and id ${id}" 279 | d.label = label 280 | d.updateDataValue('nestId', id) 281 | d 282 | } 283 | } 284 | 285 | unsubscribe() 286 | 287 | if (settings.watchMode) { 288 | // log.trace 'Subscribing to mode changes' 289 | subscribe(location, 'mode', handleModeChange) 290 | // Update the away state based on the current mode 291 | handleModeChange([value: location.mode]) 292 | } 293 | } 294 | 295 | def handleModeChange(event) { 296 | // log.trace "Handling mode change to ${event.value}" 297 | def mode = event.value 298 | def away = mode == 'Away' 299 | if (away != isAway()) { 300 | // log.trace "Saw mode change to '${event.value}', updating Nest presence" 301 | setAway(away) 302 | settings.thermostats.each { id -> 303 | // log.trace "Refreshing thermostat ${id}" 304 | def dni = "${app.id}.${id}" 305 | def d = getChildDevice(dni) 306 | d.refresh(away) 307 | } 308 | } 309 | } 310 | 311 | def isAway() { 312 | def data = nestGet("/structures/${settings.structure}") 313 | return data.away == 'away' 314 | } 315 | 316 | def setAway(isAway) { 317 | if (isAway != true && isAway != false) { 318 | log.error "Invalid away value ${isAway}" 319 | return 320 | } 321 | nestPut("/structures/${settings.structure}", [away: isAway ? 'away' : 'home']) 322 | } 323 | 324 | /** 325 | * Called by child apps to get data from Nest 326 | */ 327 | def nestGet(path) { 328 | def responseData 329 | 330 | // log.trace "Getting ${path} from Nest" 331 | 332 | try { 333 | httpGet( 334 | uri: 'https://developer-api.nest.com', 335 | path: path, 336 | headers: [ 337 | Authorization: "Bearer ${state.accessToken}" 338 | ] 339 | ) { resp -> 340 | responseData = resp.data 341 | } 342 | 343 | return responseData 344 | } catch (error) { 345 | // A request was unauthorized, the token is no longer good 346 | if (error.toString() =~ /Unauthorized/) { 347 | disconnect() 348 | } 349 | 350 | throw error; 351 | } 352 | } 353 | 354 | /** 355 | * Called by child apps to set data to Nest 356 | */ 357 | def nestPut(path, data) { 358 | def responseData 359 | def json = new groovy.json.JsonBuilder(data).toString() 360 | def token = state.accessToken 361 | 362 | // log.trace "Putting ${json} to ${path}" 363 | 364 | httpPutJson( 365 | uri: 'https://developer-api.nest.com', 366 | path: path, 367 | body: json, 368 | headers: [ 369 | Authorization: "Bearer ${token}" 370 | ] 371 | ) { resp -> 372 | if (resp.status == 307) { 373 | def location = resp.headers.Location 374 | // log.trace "Redirected to ${location}" 375 | httpPutJson( 376 | uri: location, 377 | body: json, 378 | headers: [ 379 | Authorization: "Bearer ${token}" 380 | ] 381 | ) { rsp -> 382 | responseData = rsp.data 383 | } 384 | } else { 385 | responseData = resp.data 386 | } 387 | } 388 | 389 | return responseData 390 | } 391 | 392 | private getStructures() { 393 | log.debug 'Getting list of Nest structures' 394 | 395 | def names = [:] 396 | 397 | try { 398 | def data = nestGet('/') 399 | // log.trace "Got Nest response: ${data}" 400 | 401 | def structures = data.structures; 402 | state.structureData = structures; 403 | state.numStructures = structures.size() 404 | 405 | // log.trace "Found ${state.numStructures} structures" 406 | 407 | structures.each { id, st -> 408 | names[id] = st.name 409 | } 410 | } catch(Exception e) { 411 | log.error 'Error getting nests: ' + e 412 | } 413 | 414 | state.structureNames = names 415 | return names.sort { it.value } 416 | } 417 | 418 | private getThermostats() { 419 | log.debug 'Getting list of Nest thermostats' 420 | 421 | def names = [:] 422 | 423 | try { 424 | def data = nestGet('/') 425 | // log.trace "Got Nest response: ${data}" 426 | 427 | def devices = data.devices.thermostats; 428 | state.thermostatData = devices; 429 | state.numThermostats = devices.size() 430 | 431 | // log.trace "Found ${state.numThermostats} thermostats" 432 | def structureId = settings.structure 433 | 434 | devices.findAll { id, therm -> 435 | therm.structure_id == structureId 436 | }.each { id, therm -> 437 | names[id] = therm.name 438 | } 439 | } catch(Exception e) { 440 | log.error 'Error getting nests: ' + e 441 | } 442 | 443 | state.thermostatNames = names 444 | return names.sort { it.value } 445 | } 446 | 447 | private isAuthorized() { 448 | // log.trace "Is authorized? token=${state.accessToken}" 449 | if (state.accessToken == null) { 450 | // log.trace "No token" 451 | return false 452 | } 453 | 454 | return true 455 | } 456 | 457 | private disconnect() { 458 | log.debug 'Disconnected from Nest. User must reauthorize.' 459 | 460 | state.connected = 'lost' 461 | state.accessToken = null 462 | } 463 | 464 | private isConnected() { 465 | if (state.connected == null) { 466 | state.connected = 'warn' 467 | } 468 | return state.connected?.toString() ?: 'lost' 469 | } 470 | 471 | private connected() { 472 | state.connected = 'full' 473 | state.reAttemptPoll = 0 474 | } 475 | 476 | private stringifyQuery(params) { 477 | return params.collect { k, v -> 478 | "${k}=${URLEncoder.encode(v.toString())}" 479 | }.sort().join("&") 480 | } 481 | -------------------------------------------------------------------------------- /apps/jason0x43-scene.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Scene 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2018-09-28, 23:25:31-0400 6 | * Version: 1.0 7 | * 8 | * Based on Scene Machine by Todd Wackford 9 | * https://github.com/twack/smarthings-apps/blob/master/scene-machine.app.groovy 10 | * 11 | * This is a customized version of Scene Machine that automatically creates a 12 | * switch to trigger the scene and that can handle color temperature as well as 13 | * light level. 14 | * 15 | * Use License: Non-Profit Open Software License version 3.0 (NPOSL-3.0) 16 | * http://opensource.org/licenses/NPOSL-3.0 17 | */ 18 | 19 | definition( 20 | name: 'Scene', 21 | namespace: 'jason0x43', 22 | parent: 'jason0x43:Scene Manager', 23 | author: 'j.cheatham@gmail.com', 24 | description: 'Create a scene from a set of switches.', 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 | page(name: 'mainPage') 31 | } 32 | 33 | def mainPage() { 34 | dynamicPage(name: 'mainPage', uninstall: true, install: true) { 35 | section { 36 | paragraph( 37 | 'Create a scene by selecting groups of lights and choosing ' + 38 | 'how they should be configured.' 39 | ) 40 | 41 | paragraph( 42 | 'When a group is turned "on", the color temp OR hue+' + 43 | 'sat may be set, along with the level. If both the color ' + 44 | 'temp and hue+sat are configured, color temp will take priority.' 45 | ) 46 | label(title: 'Scene name', type: 'string', required: true) 47 | } 48 | 49 | getLightGroups().each { 50 | def name = it 51 | def id = name.substring(10) 52 | 53 | // Need to use sprintf when generating property names so they'll be 54 | // regular strings 55 | def state = sprintf('sceneSwitch%s', id) 56 | def level = sprintf('sceneLevel%s', id) 57 | def colorTemperature = sprintf('sceneColorTemperature%s', id) 58 | def hue = sprintf('sceneHue%s', id) 59 | def saturation = sprintf('sceneSaturation%s', id) 60 | 61 | section('Lights') { 62 | input( 63 | name: name, 64 | type: 'capability.switch', 65 | multiple: true, 66 | submitOnChange: true 67 | ) 68 | 69 | input( 70 | name: state, 71 | title: 'On/off', 72 | type: 'bool', 73 | submitOnChange: true 74 | ) 75 | 76 | if (settings[state]) { 77 | if (supportsLevel(name)) { 78 | input(name: level, title: 'Level', type: 'number') 79 | } 80 | 81 | if (supportsColorTemp(name)) { 82 | input(name: colorTemperature, title: 'Color temperature', type: 'number') 83 | } 84 | 85 | if (supportsColor(name)) { 86 | input(name: hue, title: 'Color hue', type: 'number', range: '0..100') 87 | input(name: saturation, title: 'Color saturation', type: 'number', range: '0..100') 88 | } 89 | } 90 | } 91 | } 92 | 93 | def name = sprintf('sceneLight%d', getNextId()) 94 | 95 | section('Lights') { 96 | input(name: name, type: 'capability.switch', multiple: true, submitOnChange: true) 97 | } 98 | 99 | section('Button') { 100 | input( 101 | name: 'sceneButton', 102 | type: 'capability.pushableButton', 103 | title: 'Button device to press when activating scene', 104 | submitOnChange: true 105 | ) 106 | 107 | if (settings.sceneButton != null) { 108 | def numButtons = sceneButton.currentNumberOfButtons 109 | if (numButtons != null) { 110 | def options = (1..numButtons).collect { it.toString() } 111 | input( 112 | name: 'sceneButtonIndex', 113 | title: 'Which device button? (No selection means all buttons)', 114 | type: 'enum', 115 | options: options 116 | ) 117 | } else { 118 | paragraph( 119 | "This device didn't report a number of buttons. You can " + 120 | 'manually enter a numeric index here. If you provide a ' + 121 | 'value, the scene will push that button when activated. If ' + 122 | 'not, it will push all the device buttons.' 123 | ) 124 | input( 125 | name: 'sceneButtonIndex', 126 | title: 'Which button?', 127 | type: 'number' 128 | ) 129 | } 130 | } 131 | } 132 | 133 | section('Alternate triggers') { 134 | paragraph( 135 | 'Optionally choose a button and/or momentary switch that can ' + 136 | 'trigger the scene in addition to the default virtual switch.' 137 | ) 138 | input( 139 | name: 'triggerSwitch', 140 | title: 'Switch', 141 | type: 'capability.switch', 142 | submitOnChange: true 143 | ) 144 | input( 145 | name: 'triggerButton', 146 | title: 'Button device', 147 | type: 'capability.pushableButton', 148 | submitOnChange: true 149 | ) 150 | 151 | if (settings.triggerButton != null) { 152 | def numButtons = triggerButton.currentNumberOfButtons 153 | if (numButtons != null) { 154 | def options = (1..numButtons).collect { it.toString() } 155 | input( 156 | name: 'triggerButtonIndex', 157 | title: 'Which button? (No selection means any button)', 158 | type: 'enum', 159 | options: options 160 | ) 161 | } else { 162 | paragraph( 163 | "This switch didn't report a number of buttons. You can " + 164 | 'manually enter a numeric index here. If you provide a ' + 165 | 'value, the scene will subscribe to that button. If ' + 166 | 'not, it will respond to any button press.' 167 | ) 168 | input( 169 | name: 'triggerButtonIndex', 170 | title: 'Which button?', 171 | type: 'number' 172 | ) 173 | } 174 | } 175 | } 176 | } 177 | } 178 | 179 | def installed() { 180 | log.debug "Installed with settings: ${settings}" 181 | init() 182 | } 183 | 184 | def uninstalled() { 185 | log.debug "Uninstalled with settings: ${settings}" 186 | 187 | def childId = getDeviceID() 188 | unsubscribe() 189 | deleteChildDevice(childId) 190 | } 191 | 192 | def updated() { 193 | log.debug "Updated with settings: ${settings}" 194 | unsubscribe() 195 | init() 196 | } 197 | 198 | def init() { 199 | log.debug "Initting..." 200 | 201 | // Create a switch to activate the scene 202 | def child = createChildDevice(app.label) 203 | 204 | subscribe(child, 'switch.on', setScene) 205 | log.trace "Subscribed to scene switch" 206 | 207 | if (triggerButton) { 208 | if (triggerButtonIndex != null) { 209 | subscribe(triggerButton, "pushed.${triggerButtonIndex}", setScene) 210 | log.trace "Subscribed to button ${triggerButtonIndex}" 211 | } else { 212 | subscribe(triggerButton, 'pushed', setScene) 213 | log.trace "Subscribed to all buttons" 214 | } 215 | } 216 | 217 | if (triggerSwitch) { 218 | subscribe(triggerSwitch, 'switch.on', setScene) 219 | log.trace "Subscribed to switch" 220 | } 221 | } 222 | 223 | def setScene(evt) { 224 | log.debug 'Setting scene' 225 | 226 | getLightGroups().each { 227 | def name = it 228 | def state = getLightState(name) 229 | def group = settings[name] 230 | 231 | group.each { 232 | def light = it 233 | def currentSwitch = light.currentSwitch 234 | 235 | log.trace "state for ${light}: ${state}" 236 | 237 | if (state.switch) { 238 | if (currentSwitch != 'on') { 239 | light.on() 240 | } 241 | 242 | if (state.level != null && state.hue != null && state.saturation != null) { 243 | light.setColor(hue: state.hue, level: state.level, saturation: state.saturation) 244 | } 245 | if (state.level != null) { 246 | light.setLevel(state.level) 247 | } 248 | 249 | if (state.hue != null) { 250 | light.setHue(state.hue) 251 | } 252 | 253 | if (state.saturation != null) { 254 | light.setSaturation(state.saturation) 255 | } 256 | 257 | if (state.colorTemperature != null) { 258 | light.setColorTemperature(state.colorTemperature) 259 | } 260 | } else if (currentSwitch == 'on') { 261 | light.off() 262 | } 263 | } 264 | } 265 | 266 | if (settings.sceneButton != null) { 267 | if (settings.sceneButtonIndex != null) { 268 | sceneButton.push(sceneButtonIndex) 269 | } else { 270 | sceneButton.push() 271 | } 272 | log.trace 'Pushed scene button' 273 | } 274 | 275 | log.trace 'Done setting scene' 276 | } 277 | 278 | private createChildDevice(deviceLabel) { 279 | app.updateLabel(deviceLabel) 280 | def child = getChildDevice(getDeviceID()) 281 | 282 | if (!child) { 283 | child = addChildDevice( 284 | 'jason0x43', 285 | 'Virtual Momentary Switch', 286 | getDeviceID(), 287 | null, 288 | [name: getDeviceID(), label: deviceLabel, completedSetup: true] 289 | ) 290 | log.info "Created switch [${child}]" 291 | } else { 292 | child.label = app.label 293 | child.name = app.label 294 | log.info "Switch renamed to [${app.label}]" 295 | } 296 | 297 | return child 298 | } 299 | 300 | private getDeviceID() { 301 | return "SBSW_${app.id}" 302 | } 303 | 304 | private getGroupId(name) { 305 | return name.substring(10) 306 | } 307 | 308 | private getLightGroups() { 309 | def entries = settings.findAll { k, v -> k.startsWith('sceneLight') } 310 | return entries.keySet() 311 | } 312 | 313 | private getNextId() { 314 | def groups = getLightGroups() 315 | def nextId 316 | if (groups.size() > 0) { 317 | def ids = groups.collect { getGroupId(it).toInteger() } 318 | def maxId = ids.max() 319 | nextId = maxId + 1 320 | } else { 321 | nextId = 0 322 | } 323 | } 324 | 325 | private getLightState(name) { 326 | def id = getGroupId(name) 327 | def state = [ switch: settings["sceneSwitch${id}"] ] 328 | if (state) { 329 | state.level = settings["sceneLevel${id}"] 330 | state.hue = settings["sceneHue${id}"] 331 | state.saturation = settings["sceneSaturation${id}"] 332 | state.colorTemperature = settings["sceneColorTemperature${id}"] 333 | } 334 | return state 335 | } 336 | 337 | private supportsColor(name) { 338 | return settings[name].any { 339 | it.supportedCommands.any { it.name == 'setHue' } 340 | } 341 | } 342 | 343 | private supportsColorTemp(name) { 344 | return settings[name].any { 345 | it.supportedCommands.any { it.name == 'setColorTemperature' } 346 | } 347 | } 348 | 349 | private supportsLevel(name) { 350 | return settings[name].any { 351 | it.supportedCommands.any { it.name == 'setLevel' } 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /apps/jason0x43-scene_manager.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Scene Manager 3 | * 4 | * Author: Jason Cheatham 5 | * Date: 2018-03-24 6 | * Version: 1.0 7 | * 8 | * Manage Scenes 9 | */ 10 | 11 | definition( 12 | name: "Scene Manager", 13 | namespace: "jason0x43", 14 | author: "Jason Cheatham", 15 | description: "Create scenes", 16 | singleInstance: true, 17 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-BigButtonsAndSwitches.png", 18 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-BigButtonsAndSwitches@2x.png", 19 | iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-BigButtonsAndSwitches@2x.png" 20 | ) 21 | 22 | preferences { 23 | page( 24 | name: 'mainPage', 25 | title: 'Installed Scenes', 26 | install: true, 27 | uninstall: true, 28 | submitOnChange: true 29 | ) { 30 | section { 31 | app( 32 | name: 'scenes', 33 | appName: 'Scene', 34 | namespace: 'jason0x43', 35 | title: 'New Scene', 36 | multiple: true 37 | ) 38 | } 39 | } 40 | } 41 | 42 | def installed() { 43 | log.debug "Installed with settings: ${settings}" 44 | initialize() 45 | } 46 | 47 | def updated() { 48 | log.debug "Updated with settings: ${settings}" 49 | unsubscribe() 50 | initialize() 51 | } 52 | 53 | def initialize() { 54 | log.debug "There are ${childApps.size()} child smartapps" 55 | childApps.each { child -> 56 | log.debug("child app: ${child.label}") 57 | } 58 | } -------------------------------------------------------------------------------- /apps/jason0x43-wemo_connect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * WeMo Connect 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2021-03-21, 17:07:01-0400 6 | * 7 | * Based on the original Wemo (Connect) Advanced app by SmartThings, updated by 8 | * superuser-ule 2016-02-24 9 | * 10 | * Original Copyright 2015 SmartThings 11 | * 12 | * Licensed under the Apache License, Version 2.0 (the 'License'); you may not 13 | * use this file except in compliance with the License. You may obtain a copy 14 | * of the License at: 15 | * 16 | * http://www.apache.org/licenses/LICENSE-2.0 17 | * 18 | * Unless required by applicable law or agreed to in writing, software 19 | * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT 20 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 21 | * License for the specific language governing permissions and limitations 22 | * under the License. 23 | */ 24 | 25 | definition( 26 | name: 'WeMo Connect', 27 | namespace: 'jason0x43', 28 | author: 'Jason Cheatham', 29 | 30 | description: 'Allows you to integrate your WeMo devices with Hubitat.', 31 | singleInstance: true, 32 | iconUrl: 'https://s3.amazonaws.com/smartapp-icons/Partner/wemo.png', 33 | iconX2Url: 'https://s3.amazonaws.com/smartapp-icons/Partner/wemo@2x.png', 34 | importUrl: 'https://raw.githubusercontent.com/jason0x43/hubitat/master/apps/jason0x43-wemo_connect.groovy' 35 | ) 36 | 37 | preferences { 38 | page(name: 'mainPage') 39 | } 40 | 41 | import hubitat.helper.HexUtils 42 | 43 | def mainPage() { 44 | debugLog("mainPage: Rendering main with state: ${state}") 45 | 46 | // Reset the refresh state if the last refresh was more than 60 seconds ago 47 | if ( 48 | !state.refreshCount 49 | || !state.lastRefresh 50 | || (now() - state.lastRefresh) > 60000 51 | ) { 52 | debugLog("mainPage: Resetting refresh count and discovered devices") 53 | state.refreshCount = 0 54 | state.discoveredDevices = [:] 55 | } 56 | 57 | state.minDriverVersion = 4 58 | 59 | def refreshCount = state.refreshCount 60 | 61 | state.refreshCount = refreshCount + 1 62 | state.lastRefresh = now() 63 | 64 | // ssdp request every 30 seconds 65 | discoverAllWemoTypes() 66 | 67 | def devices = getKnownDevices() 68 | debugLog("mainPage: Known devices: ${devices}") 69 | def deviceLabels = [:] 70 | devices.each { mac, data -> deviceLabels[mac] = data.label } 71 | 72 | dynamicPage( 73 | name: 'mainPage', 74 | install: true, 75 | uninstall: true, 76 | refreshInterval: 30 77 | ) { 78 | section('

Device Discovery

') { 79 | paragraph( 80 | 'Device discovery messages are being broadcast every 30 ' + 81 | 'seconds. Any devices on the local network should show up ' + 82 | 'within a minute or two.' 83 | ) 84 | 85 | input( 86 | 'selectedDevices', 87 | 'enum', 88 | required: false, 89 | title: "Select Wemo Devices \n(${devices.size() ?: 0} found)", 90 | multiple: true, 91 | options: deviceLabels 92 | ) 93 | } 94 | 95 | section('

Options

') { 96 | input( 97 | 'interval', 98 | 'number', 99 | title: 'How often should WeMo devices be refreshed? ' + 100 | '(minutes, default is 5, max is 59)', 101 | defaultValue: 5 102 | ) 103 | 104 | input( 105 | 'debugLogging', 106 | 'bool', 107 | title: 'Enable debug logging', 108 | defaultValue: false, 109 | submitOnChange: true 110 | ) 111 | } 112 | } 113 | } 114 | 115 | def installed() { 116 | log.info('Installed') 117 | initialize() 118 | } 119 | 120 | def updated() { 121 | log.info('Updated') 122 | initialize() 123 | } 124 | 125 | def uninstalled() { 126 | log.info('Uninstalling') 127 | // Remove any child devices created by this app 128 | getChildDevices().each { device -> 129 | log.info("Removing child device ${device}") 130 | deleteChildDevice(device.deviceNetworkId) 131 | } 132 | } 133 | 134 | def initialize() { 135 | log.info('Initializing') 136 | unschedule() 137 | if (selectedDevices) { 138 | initDevices() 139 | } 140 | 141 | def interval = Math.min(settings.interval ?: 5, 59) 142 | 143 | // cron fields: 144 | // seconds 145 | // minutes 146 | // hours 147 | // day of month 148 | // month 149 | // day of week 150 | // year 151 | debugLog("initialize: scheduling discovery for every ${interval} minutes") 152 | schedule("0 0/${interval} * * * ?", refreshDevices) 153 | } 154 | 155 | def refreshDevices() { 156 | log.info('Refreshing Wemo devices') 157 | getChildDevices().each { device -> device.refresh() } 158 | discoverAllWemoTypes() 159 | } 160 | 161 | def childGetHostAddress(device) { 162 | debugLog("childGetHostAddress: getting address for ${device}") 163 | def hexIp = device.getDataValue('ip') 164 | def hexPort = device.getDataValue('port') 165 | debugLog("childGetHostAddress: hexIp = ${hexIp}") 166 | debugLog("childGetHostAddress: hexPort = ${hexPort}") 167 | try { 168 | return toDecimalAddress("${hexIp}:${hexPort}") 169 | } catch (Throwable t) { 170 | info.warn("Error parsing child address: $t"); 171 | return null 172 | } 173 | } 174 | 175 | def childGetBinaryState(child) { 176 | log.info("Getting state for ${child}") 177 | debugLog( 178 | "childGetBinaryState: sending request to ${childGetHostAddress(child)}" 179 | ) 180 | new hubitat.device.HubSoapAction( 181 | path: '/upnp/control/basicevent1', 182 | urn: 'urn:Belkin:service:basicevent:1', 183 | action: 'GetBinaryState', 184 | headers: [ 185 | HOST: childGetHostAddress(child) 186 | 187 | ] 188 | ) 189 | } 190 | 191 | def childResubscribe(child) { 192 | log.info("Resubscribing ${child}") 193 | def sid = child.getDataValue('subscriptionId') 194 | if (sid == null) { 195 | debugLog( 196 | "childResubscribe: No existing subscription for ${child} -- " + 197 | "subscribing" 198 | ) 199 | return childSubscribe(child) 200 | } 201 | 202 | debugLog("childResubscribe: renewing ${child} subscription to ${sid}") 203 | 204 | // Clear the existing SID -- it should be set if the resubscribe succeeds 205 | debugLog('childReubscribe: clearing existing sid') 206 | child.updateDataValue('subscriptionId', null) 207 | 208 | debugLog( 209 | "childResubscribe: sending request to ${childGetHostAddress(child)}" 210 | ) 211 | new hubitat.device.HubAction([ 212 | method: 'SUBSCRIBE', 213 | path: '/upnp/event/basicevent1', 214 | headers: [ 215 | HOST: childGetHostAddress(child), 216 | TIMEOUT: "Second-${getSubscriptionTimeout()}", 217 | SID: "uuid:${sid}" 218 | ] 219 | ], child.deviceNetworkId) 220 | } 221 | 222 | def childSetBinaryState(child, state, brightness = null) { 223 | log.info("Setting binary state for ${child}") 224 | def body = [ BinaryState: "$state" ] 225 | 226 | if (brightness != null) { 227 | body.brightness = "$brightness" 228 | } 229 | 230 | debugLog( 231 | "childSetBinaryState: sending binary state request to " + 232 | "${childGetHostAddress(child)}" 233 | ) 234 | new hubitat.device.HubSoapAction( 235 | path: '/upnp/control/basicevent1', 236 | urn: 'urn:Belkin:service:basicevent:1', 237 | action: 'SetBinaryState', 238 | body: body, 239 | headers: [ 240 | Host: childGetHostAddress(child) 241 | ] 242 | ) 243 | } 244 | 245 | def childSubscribe(child) { 246 | log.info("Subscribing to events for ${child}") 247 | 248 | // Clear out any current subscription ID; will be reset when the 249 | // subscription completes 250 | debugLog('childSubscribe: clearing existing sid') 251 | child.updateDataValue('subscriptionId', '') 252 | 253 | debugLog( 254 | "childSubscribe: sending subscribe request to " + 255 | "${childGetHostAddress(child)}" 256 | ) 257 | new hubitat.device.HubAction([ 258 | method: 'SUBSCRIBE', 259 | path: '/upnp/event/basicevent1', 260 | headers: [ 261 | HOST: childGetHostAddress(child), 262 | CALLBACK: "", 263 | NT: 'upnp:event', 264 | TIMEOUT: "Second-${getSubscriptionTimeout()}" 265 | ] 266 | ], child.deviceNetworkId) 267 | } 268 | 269 | def childSubscribeIfNecessary(child) { 270 | debugLog("childSubscribeIfNecessary: checking subscription for ${child}") 271 | 272 | def sid = child.getDataValue('subscriptionId') 273 | if (sid == null) { 274 | debugLog( 275 | "childSubscribeIfNecessary: no active subscription -- subscribing" 276 | ) 277 | childSubscribe(child) 278 | } else { 279 | debugLog("childSubscribeIfNecessary: active subscription -- skipping") 280 | } 281 | } 282 | 283 | def childSyncTime(child) { 284 | debugLog("childSyncTime: requesting sync for ${child}") 285 | 286 | def now = new Date(); 287 | def tz = location.timeZone; 288 | def offset = tz.getOffset(now.getTime()) 289 | def offsetHours = (offset / 1000 / 60 / 60).intValue() 290 | def tzOffset = (offsetHours < 0 ? '-' : '') + 291 | String.format('%02d.00', Math.abs(offsetHours)) 292 | def isDst = tz.inDaylightTime(now) 293 | def hasDst = tz.observesDaylightTime() 294 | 295 | debugLog( 296 | "childSyncTime: sending sync request to ${childGetHostAddress(child)}" 297 | ) 298 | new hubitat.device.HubSoapAction( 299 | path: '/upnp/control/timesync1', 300 | url: 'urn:Belkin:service:timesync:1', 301 | action: 'TimeSync', 302 | body: [ 303 | UTC: getTime(), 304 | TimeZone: tzOffset, 305 | dst: isDst ? 1 : 0, 306 | DstSupported: hasDst ? 1 : 0 307 | ], 308 | headers: [ 309 | HOST: childGetHostAddress(child) 310 | ] 311 | ) 312 | } 313 | 314 | def childUnsubscribe(child) { 315 | debugLog("childUnsubscribe: unsubscribing ${child}") 316 | 317 | def sid = child.getDataValue('subscriptionId') 318 | 319 | // Clear out the current subscription ID 320 | debugLog('childUnsubscribe: clearing existing sid') 321 | child.updateDataValue('subscriptionId', '') 322 | 323 | debugLog( 324 | "childUnsubscribe: sending unsubscribe request to " + 325 | "${childGetHostAddress(child)}" 326 | ) 327 | new hubitat.device.HubAction([ 328 | method: 'UNSUBSCRIBE', 329 | path: '/upnp/event/basicevent1', 330 | headers: [ 331 | HOST: childGetHostAddress(child), 332 | SID: "uuid:${sid}" 333 | ] 334 | ], child.deviceNetworkId) 335 | } 336 | 337 | def childUpdateSubscription(message, child) { 338 | def headerString = message.header 339 | 340 | if (isSubscriptionHeader(headerString)) { 341 | def sid = getSubscriptionId(headerString) 342 | debugLog( 343 | "childUpdateSubscription: updating subscriptionId for ${child} " + 344 | "to ${sid}" 345 | ) 346 | child.updateDataValue('subscriptionId', sid) 347 | } 348 | } 349 | 350 | def getSubscriptionId(header) { 351 | def sid = (header =~ /SID: uuid:.*/) ? 352 | (header =~ /SID: uuid:.*/)[0] : 353 | '0' 354 | sid -= 'SID: uuid:'.trim() 355 | return sid; 356 | } 357 | 358 | def getSubscriptionTimeout() { 359 | return 60 * (settings.interval ?: 5) 360 | } 361 | 362 | def getTime() { 363 | // This is essentially System.currentTimeMillis()/1000, but System is 364 | // disallowed by the sandbox. 365 | ((new GregorianCalendar().time.time / 1000l).toInteger()).toString() 366 | } 367 | 368 | /** 369 | * Handle the setup.xml data for a device 370 | * 371 | * The device descriptor in body.device should match up, more or less, with 372 | * the device descriptor returned by parseDiscoveryMessage. 373 | */ 374 | def handleSetupXml(response) { 375 | def body = response.xml 376 | def device = body.device 377 | def deviceType = "${device.deviceType}" 378 | def friendlyName = "${device.friendlyName}" 379 | 380 | debugLog( 381 | "handleSetupXml: Handling setup.xml for ${deviceType}" + 382 | " (friendly name is '${friendlyName}')" 383 | ) 384 | 385 | if ( 386 | deviceType.startsWith('urn:Belkin:device:controllee:1') || 387 | deviceType.startsWith('urn:Belkin:device:insight:1') || 388 | deviceType.startsWith('urn:Belkin:device:Maker:1') || 389 | deviceType.startsWith('urn:Belkin:device:sensor') || 390 | deviceType.startsWith('urn:Belkin:device:lightswitch') || 391 | deviceType.startsWith('urn:Belkin:device:dimmer') 392 | ) { 393 | def entry = getDiscoveredDevices().find { 394 | it.key.contains("${device.UDN}") 395 | } 396 | 397 | if (entry) { 398 | def dev = entry.value 399 | debugLog("handleSetupXml: updating ${dev}") 400 | dev.name = friendlyName 401 | dev.verified = true 402 | } else { 403 | log.error("/setup.xml returned a wemo device that doesn't exist") 404 | } 405 | } 406 | } 407 | 408 | def handleSsdpEvent(evt) { 409 | def description = evt.description 410 | def hub = evt?.hubId 411 | 412 | def parsedEvent = parseDiscoveryMessage(description) 413 | parsedEvent << ['hub': hub] 414 | debugLog("handleSsdpEvent: Parsed discovery message: ${parsedEvent}") 415 | 416 | def usn = parsedEvent.ssdpUSN.toString() 417 | def device = getDiscoveredDevice(usn) 418 | 419 | if (device) { 420 | debugLog("handleSsdpEvent: Found cached device data for ${usn}") 421 | 422 | // Ensure the cached ip and port agree with what's in the discovery 423 | // event 424 | device.ip = parsedEvent.ip 425 | device.port = parsedEvent.port 426 | 427 | def child = getChildDevice(device.mac) 428 | if (child != null) { 429 | debugLog( 430 | "handleSsdpEvent: Updating IP address for" + 431 | " ${child} [${device.mac}]" 432 | ) 433 | updateChildAddress(child, device.ip, device.port) 434 | } 435 | } else { 436 | debugLog( 437 | "handleSsdpEvent: Adding ${parsedEvent.mac} to list of" + 438 | " known devices" 439 | ) 440 | def id = parsedEvent.ssdpUSN.toString() 441 | device = parsedEvent 442 | state.discoveredDevices[id] = device 443 | } 444 | 445 | if (!device.verified) { 446 | debugLog("handleSsdpEvent: Verifying ${device}") 447 | getSetupXml("${device.ip}:${device.port}") 448 | } 449 | } 450 | 451 | private hexToInt(hex) { 452 | Integer.parseInt(hex, 16) 453 | } 454 | 455 | private hexToIp(hex) { 456 | [ 457 | hexToInt(hex[0..1]), 458 | hexToInt(hex[2..3]), 459 | hexToInt(hex[4..5]), 460 | hexToInt(hex[6..7]) 461 | ].join('.') 462 | } 463 | 464 | private debugLog(message) { 465 | if (settings.debugLogging) { 466 | log.debug message 467 | } 468 | } 469 | 470 | private discoverAllWemoTypes() { 471 | def targets = [ 472 | 'urn:Belkin:device:insight:1', 473 | 'urn:Belkin:device:Maker:1', 474 | 'urn:Belkin:device:controllee:1', 475 | 'urn:Belkin:device:sensor:1', 476 | 'urn:Belkin:device:lightswitch:1', 477 | 'urn:Belkin:device:dimmer:1' 478 | ] 479 | 480 | def targetStr = "${targets}" 481 | 482 | if (state.subscribed != targetStr) { 483 | targets.each { target -> 484 | subscribe(location, "ssdpTerm.${target}", handleSsdpEvent) 485 | debugLog('discoverAllWemoTypes: subscribed to ' + target) 486 | } 487 | state.subscribed = targetStr 488 | } 489 | 490 | debugLog("discoverAllWemoTypes: Sending discovery message for ${targets}") 491 | sendHubCommand( 492 | new hubitat.device.HubAction( 493 | "lan discovery ${targets.join('/')}", 494 | hubitat.device.Protocol.LAN 495 | ) 496 | ) 497 | } 498 | 499 | private getCallbackAddress() { 500 | def hub = location.hubs[0]; 501 | def localIp = hub.getDataValue('localIP') 502 | def localPort = hub.getDataValue('localSrvPortTCP') 503 | "${localIp}:${localPort}" 504 | } 505 | 506 | private getKnownDevices() { 507 | debugLog('getKnownDevices: Creating list of known devices') 508 | 509 | // Known devices are a combination of existing (child) devices and newly 510 | // discovered devices. 511 | def knownDevices = [:] 512 | 513 | // First, populate the known devices list with existing devices 514 | def existingDevices = getChildDevices() 515 | existingDevices.each { device -> 516 | def mac = device.deviceNetworkId 517 | def name = device.label ?: device.name 518 | knownDevices[mac] = [ 519 | mac: mac, 520 | name: name, 521 | ip: device.getDataValue('ip'), 522 | port: device.getDataValue('port'), 523 | typeName: device.typeName, 524 | needsUpdate: device.getDriverVersion() < state.minDriverVersion 525 | ] 526 | debugLog( 527 | "getKnownDevices: Added already-installed device ${mac}:${name}" 528 | ) 529 | } 530 | 531 | // Next, populate the list with verified devices (those from which a 532 | // setup.xml has been retrieved). 533 | def verifiedDevices = getDiscoveredDevices(true) 534 | debugLog("getKnownDevices: verified devices: ${verifiedDevices}") 535 | verifiedDevices.each { key, device -> 536 | def mac = device.mac 537 | if (knownDevices.containsKey(mac)) { 538 | def knownDevice = knownDevices[mac] 539 | // If there's a verified device corresponding to an already- 540 | // installed device, update the installed device's name based 541 | // on the name of the verified device. 542 | def name = device.name 543 | if (name != null && name != knownDevice.name) { 544 | knownDevice.name = 545 | "${name} (installed as ${knownDevice.name})" 546 | debugLog("getKnownDevices: Updated name for ${mac} to ${name}") 547 | } 548 | } else { 549 | def name = device.name ?: 550 | "WeMo device ${device.ssdpUSN.split(':')[1][-3..-1]}" 551 | knownDevices[mac] = [ 552 | mac: mac, 553 | name: name, 554 | ip: device.ip, 555 | port: device.port, 556 | // The ssdpTerm and hub will be used if a new child device is 557 | // created 558 | ssdpTerm: device.ssdpTerm, 559 | hub: device.hub 560 | ] 561 | debugLog( 562 | "getKnownDevices: Added discovered device ${knownDevices[mac]}" 563 | ) 564 | } 565 | } 566 | 567 | debugLog("getKnownDevices: Known devices: ${knownDevices}") 568 | 569 | knownDevices.each { mac, device -> 570 | def address 571 | try { 572 | address = "${hexToIp(device.ip)}:${hexToInt(device.port)}" 573 | } catch (Throwable t) { 574 | address = "" 575 | log.warn("Error parsing device address: $t"); 576 | } 577 | 578 | def text = "${device.name} [MAC: ${mac}, IP: ${address}" 579 | 580 | if (device.typeName) { 581 | def needsUpdate = device.needsUpdate 582 | ? '  << Driver needs update >>' 583 | : '' 584 | text += ", Driver: ${device.typeName}] ${needsUpdate}" 585 | } else { 586 | text += ']' 587 | } 588 | knownDevices[mac].label = "
  • ${text}
  • " 589 | } 590 | 591 | return knownDevices 592 | } 593 | 594 | private getSetupXml(hexIpAddress) { 595 | def hostAddress 596 | try { 597 | hostAddress = toDecimalAddress(hexIpAddress) 598 | } catch (Throwable t) { 599 | log.warn("Error parsing address ${hexIpAddress}: $t") 600 | return 601 | } 602 | 603 | debugLog("getSetupXml: requesting setup.xml from ${hostAddress}") 604 | sendHubCommand( 605 | new hubitat.device.HubAction( 606 | [ 607 | method: 'GET', 608 | path: '/setup.xml', 609 | headers: [ HOST: hostAddress ], 610 | ], 611 | null, 612 | [ callback: handleSetupXml ] 613 | ) 614 | ) 615 | } 616 | 617 | private getDiscoveredDevices(isVerified = false) { 618 | debugLog( 619 | "getDiscoveredDevices: Getting discovered" + 620 | "${isVerified ? ' and verified' : ''} devices" 621 | ) 622 | 623 | if (!state.discoveredDevices) { 624 | state.discoveredDevices = [:] 625 | } 626 | 627 | if (isVerified) { 628 | debugLog( 629 | "getDiscoveredDevices: Finding verified devices in " + 630 | "${state.discoveredDevices}" 631 | ) 632 | return state.discoveredDevices.findAll { it.value?.verified } 633 | } 634 | return state.discoveredDevices 635 | } 636 | 637 | private getDiscoveredDevice(usn) { 638 | debugLog("getDiscoveredDevice: Getting discovered device with USN ${usn}") 639 | return getDiscoveredDevices()[usn] 640 | } 641 | 642 | private initDevices() { 643 | debugLog('initDevices: Initializing devices') 644 | 645 | def knownDevices = getKnownDevices() 646 | 647 | selectedDevices.each { dni -> 648 | debugLog( 649 | "initDevices: Looking for selected device ${dni} in known " + 650 | "devices..." 651 | ) 652 | 653 | def selectedDevice = knownDevices[dni] 654 | 655 | if (selectedDevice) { 656 | debugLog( 657 | "initDevices: Found device; looking for existing child with " + 658 | "dni ${dni}" 659 | ) 660 | def child = getChildDevice(dni) 661 | 662 | if (!child) { 663 | def driverName 664 | def namespace = 'jason0x43' 665 | debugLog( 666 | "initDevices: Creating WeMo device for ${selectedDevice}" 667 | ) 668 | 669 | switch (selectedDevice.ssdpTerm) { 670 | case ~/.*insight.*/: 671 | driverName = 'Wemo Insight Switch' 672 | break 673 | 674 | // The Light Switch and Switch use the same driver 675 | case ~/.*lightswitch.*/: 676 | case ~/.*controllee.*/: 677 | driverName = 'Wemo Switch' 678 | break 679 | 680 | case ~/.*sensor.*/: 681 | driverName = 'Wemo Motion' 682 | break 683 | 684 | case ~/.*dimmer.*/: 685 | driverName = 'Wemo Dimmer' 686 | break 687 | 688 | case ~/.*Maker.*/: 689 | driverName = 'Wemo Maker' 690 | break 691 | } 692 | 693 | if (driverName) { 694 | child = addChildDevice( 695 | namespace, 696 | driverName, 697 | selectedDevice.mac, 698 | selectedDevice.hub, 699 | [ 700 | 'label': selectedDevice.name ?: 'Wemo Device', 701 | 'data': [ 702 | 'mac': selectedDevice.mac, 703 | 'ip': selectedDevice.ip, 704 | 'port': selectedDevice.port 705 | ] 706 | ] 707 | ) 708 | log.info( 709 | "initDevices: Created ${child.displayName} with id: " + 710 | "${child.id}, MAC: ${child.deviceNetworkId}" 711 | ) 712 | } else { 713 | log.warn("initDevices: No driver for ${selectedDevice})") 714 | } 715 | } else { 716 | debugLog( 717 | "initDevices: Updating IP address for ${child}" 718 | ) 719 | updateChildAddress( 720 | child, 721 | selectedDevice.ip, 722 | selectedDevice.port 723 | ) 724 | } 725 | 726 | if (child) { 727 | debugLog('initDevices: Setting up device subscription...') 728 | child.refresh() 729 | } 730 | } else { 731 | log.warn( 732 | "initDevices: Could not find device ${dni} in ${knownDevices}" 733 | ) 734 | } 735 | } 736 | } 737 | 738 | private isSubscriptionHeader(header) { 739 | if (header == null) { 740 | return false; 741 | } 742 | header.contains("SID: uuid:") && header.contains('TIMEOUT:'); 743 | } 744 | 745 | /** 746 | * Parse a discovery message, returning a device descriptor 747 | */ 748 | private parseDiscoveryMessage(description) { 749 | def device = [:] 750 | def parts = description.split(',') 751 | 752 | debugLog("parseDiscoveryMessage: Parsing discovery message: $description") 753 | 754 | parts.each { part -> 755 | part = part.trim() 756 | def valueStr; 757 | 758 | switch (part) { 759 | case { it .startsWith('devicetype:') }: 760 | valueString = part.split(':')[1].trim() 761 | device.deviceType = valueString 762 | break 763 | case { it.startsWith('mac:') }: 764 | valueString = part.split(':')[1].trim() 765 | if (valueString) { 766 | device.mac = valueString 767 | } 768 | break 769 | case { it.startsWith('networkAddress:') }: 770 | valueString = part.split(':')[1].trim() 771 | if (valueString) { 772 | device.ip = valueString 773 | } 774 | break 775 | case { it.startsWith('deviceAddress:') }: 776 | valueString = part.split(':')[1].trim() 777 | if (valueString) { 778 | device.port = valueString 779 | } 780 | break 781 | case { it.startsWith('ssdpPath:') }: 782 | valueString = part.split(':')[1].trim() 783 | if (valueString) { 784 | device.ssdpPath = valueString 785 | } 786 | break 787 | case { it.startsWith('ssdpUSN:') }: 788 | part -= 'ssdpUSN:' 789 | valueString = part.split('::')[0].trim() 790 | if (valueString) { 791 | device.ssdpUSN = valueString 792 | } 793 | break 794 | case { it.startsWith('ssdpTerm:') }: 795 | part -= 'ssdpTerm:' 796 | valueString = part.trim() 797 | if (valueString) { 798 | device.ssdpTerm = valueString 799 | } 800 | break 801 | case { it.startsWith('headers:') }: 802 | part -= 'headers:' 803 | valueString = part.trim() 804 | if (valueString) { 805 | device.headers = valueString 806 | } 807 | break 808 | case { it.startsWith('body:') }: 809 | part -= 'body:' 810 | valueString = part.trim() 811 | if (valueString) { 812 | device.body = valueString 813 | } 814 | break 815 | } 816 | } 817 | 818 | device 819 | } 820 | 821 | private toDecimalAddress(address) { 822 | debugLog("toDecimalAddress: converting ${address}") 823 | def parts = address.split(':') 824 | ip = parts[0] 825 | port = parts[1] 826 | "${hexToIp(ip)}:${hexToInt(port)}" 827 | } 828 | 829 | private updateChildAddress(child, ip, port) { 830 | debugLog( 831 | "updateChildAddress: Updating address of ${child} to ${ip}:${port}" 832 | ) 833 | def address = "${ip}:${port}" 834 | 835 | def decimalAddress 836 | try { 837 | decimalAddress = toDecimalAddress(address) 838 | } catch (Throwable t) { 839 | log.warn("Error parsing address ${address}: $t") 840 | return 841 | } 842 | 843 | log.info( 844 | "Verifying that IP for ${child} is set to ${decimalAddress}" 845 | ) 846 | 847 | def existingIp = child.getDataValue('ip') 848 | if (ip && existingIp && ip != existingIp) { 849 | try { 850 | debugLog( 851 | "childSync: Updating IP from ${hexToIp(existingIp)} to " + 852 | "${hexToIp(ip)}" 853 | ) 854 | } catch (Throwable t) { 855 | log.warn("Error parsing addresses $existingIp, $ip: $t") 856 | debugLog("childSync: Updating IP from ${existingIp} to ${ip}") 857 | } 858 | child.updateDataValue('ip', ip) 859 | } 860 | 861 | def existingPort = child.getDataValue('port') 862 | if (port != null && existingPort != null && port != existingPort) { 863 | try { 864 | debugLog( 865 | "childSync: Updating port from ${hexToInt(existingPort)} to " + 866 | "${hexToInt(port)}" 867 | ) 868 | } catch (Throwable t) { 869 | log.warn("Error parsing ports $existingPort, $port: $t") 870 | debugLog( 871 | "childSync: Updating port from ${existingPort} to ${port}" 872 | ) 873 | } 874 | child.updateDataValue('port', port) 875 | } 876 | 877 | childSubscribe(child) 878 | } 879 | -------------------------------------------------------------------------------- /drivers/jason0x43-3_button_remote.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Zigbee 3 button remote (Smartenit ZBWS3B) 3 | * 4 | * Original code by GilbertChan, modified by obycode to add 'numButtons' 5 | * Thanks to Seth Jansen @sjansen for original contributions 6 | * 7 | * Modified by Jason Cheatham for Hubitat compatibility, 2018-03-24. 8 | */ 9 | 10 | metadata { 11 | definition(name: '3 Button Remote', namespace: 'jason0x43', author: 'Jason Cheatham') { 12 | capability 'PushableButton' 13 | capability 'Battery' 14 | capability 'Configuration' 15 | capability 'Refresh' 16 | 17 | attribute 'button2', 'enum', ['released', 'pressed'] 18 | attribute 'button3', 'enum', ['released', 'pressed'] 19 | attribute 'numberOfButtons', 'number' 20 | 21 | fingerprint( 22 | endpointId: '03', 23 | profileId: '0104', 24 | deviceId: '0000', 25 | deviceVersion: '00', 26 | inClusters: '03 0000 0003 0007', 27 | outClusters: '01 0006' 28 | ) 29 | } 30 | 31 | preferences { 32 | input( 33 | name: 'numberOfButtons', 34 | type: 'number', 35 | title: 'Number of buttons', 36 | required: true 37 | ) 38 | } 39 | } 40 | 41 | def updated() { 42 | sendEvent( 43 | name: 'numberOfButtons', 44 | value: settings.numberOfButtons 45 | ) 46 | } 47 | 48 | def parse(description) { 49 | log.trace "Parse description ${description}" 50 | 51 | def button = null 52 | 53 | if (description.startsWith('catchall:')) { 54 | return parseCatchAllMessage(description) 55 | } else if (description.startsWith('read attr - ')) { 56 | return parseReportAttributeMessage(description) 57 | } 58 | } 59 | 60 | def refresh() { 61 | log.debug 'Refreshing...' 62 | return [ 63 | // Request battery voltage from cluster 0x20; should be the 64 | // measured battery voltage in 100mv increments 65 | "st rattr 0x${device.deviceNetworkId} 0x01 0x0001 0x0020" 66 | //zigbee.readAttribute(0x0001, 0x0020) 67 | ] 68 | } 69 | 70 | def configure() { 71 | log.info 'Configuring...' 72 | 73 | def configCmds = [ 74 | // Switch control 75 | "zdo bind 0x${device.deviceNetworkId} 0x01 0x01 0x0006 {${device.zigbeeId}} {}", 76 | 'delay 500', 77 | "zdo bind 0x${device.deviceNetworkId} 0x02 0x01 0x0006 {${device.zigbeeId}} {}", 78 | 'delay 500', 79 | "zdo bind 0x${device.deviceNetworkId} 0x03 0x01 0x0006 {${device.zigbeeId}} {}", 80 | 'delay 1500', 81 | ] + 82 | zigbee.configureReporting(0x0001, 0x0020, 0x20, 30, 21600, 0x01) 83 | //zigbee.batteryConfig() 84 | //+ refresh() 85 | 86 | return configCmds 87 | } 88 | 89 | /** 90 | * Store mode and settings 91 | */ 92 | def updateState(name, value) { 93 | state[name] = value 94 | } 95 | 96 | private parseCatchAllMessage(description) { 97 | def cluster = zigbee.parseDescriptionAsMap(description) 98 | if (cluster.profileId == '0104' && cluster.clusterInt == 6) { 99 | return [getButtonEvent(cluster.sourceEndpoint.toInteger())] 100 | } 101 | } 102 | 103 | private getButtonEvent(button) { 104 | def event = createEvent( 105 | name: 'pushed', 106 | value: button, 107 | descriptionText: "${device.displayName} button ${button} was pushed", 108 | isStateChange: true 109 | ) 110 | log.debug event.descriptionText 111 | return event 112 | } 113 | 114 | private parseReportAttributeMessage(description) { 115 | def cluster = zigbee.parseDescriptionAsMap(description) 116 | // Battery voltage is cluster 0x0001, attribute 0x20 117 | if (cluster.clusterInt == 1 && cluster.attrInt == 32) { 118 | return [getBatteryEvent(Integer.parseInt(cluster.value, 16))] 119 | } 120 | } 121 | 122 | private getBatteryEvent(rawValue) { 123 | log.trace "Battery rawValue = ${rawValue}" 124 | 125 | def event = [ 126 | name: 'battery', 127 | value: '--', 128 | translatable: true 129 | ] 130 | 131 | // 0 and 0xff are invalid 132 | if (rawValue == 0 || rawValue == 255) { 133 | return createEvent(event) 134 | } 135 | 136 | // Raw value is in 100mV units 137 | def volts = rawValue / 10 138 | 139 | // Assumes sensor's working floor is 2.1V 140 | def minVolts = 2.1 141 | def maxVolts = 3.0 142 | def pct = (volts - minVolts) / (maxVolts - minVolts) 143 | def roundedPct = Math.round(pct * 100) 144 | if (roundedPct <= 0) { 145 | roundedPct = 1 146 | } 147 | 148 | event.value = Math.min(100, roundedPct) 149 | event.descriptionText = "${device.displayName} battery is at ${event.value}%" 150 | 151 | log.debug event.descriptionText 152 | return createEvent(event) 153 | } 154 | -------------------------------------------------------------------------------- /drivers/jason0x43-darksky.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * DarkSky weather driver 3 | */ 4 | 5 | metadata { 6 | definition(name: 'DarkSky', namespace: 'jason0x43', author: 'Jason Cheatham') { 7 | capability('Sensor') 8 | capability('Temperature Measurement') 9 | capability('Illuminance Measurement') 10 | capability('Relative Humidity Measurement') 11 | capability('Refresh') 12 | 13 | attribute('temperatureHi', 'number') 14 | attribute('temperatureHiTime', 'number') 15 | attribute('temperatureLo', 'number') 16 | attribute('temperatureLoTime', 'number') 17 | attribute('temperature', 'number') 18 | attribute('precipChance', 'number') 19 | attribute('cloudCover', 'number') 20 | } 21 | } 22 | 23 | preferences { 24 | input( 25 | name: 'apiKey', 26 | type: 'text', 27 | title: 'Dark Sky API Key:', 28 | description: '', 29 | required: true 30 | ) 31 | input( 32 | name: 'pollInterval', 33 | type: 'enum', 34 | title: 'Poll interval', 35 | defaultValue: '30 Minutes', 36 | options: ['10 Minutes', '30 Minutes', '1 Hour'] 37 | ) 38 | } 39 | 40 | def installed() { 41 | init() 42 | } 43 | 44 | def updated() { 45 | init() 46 | } 47 | 48 | def init() { 49 | unschedule() 50 | 51 | if (settings.apiKey) { 52 | def pollInterval = (settings.pollInterval ?: '30 Minutes').replace(' ', '') 53 | "runEvery${pollInterval}"(poll) 54 | log.debug "Scheduled poll for every ${pollInterval}" 55 | // poll() 56 | log.debug "${location.hubs[0]}" 57 | } 58 | } 59 | 60 | def poll() { 61 | try { 62 | log.debug "Requesting data..." 63 | def latitude = location.getLatitude() 64 | def longitude = location.getLongitude() 65 | httpGet( 66 | "https://api.darksky.net/forecast/${settings.apiKey}/${latitude},${longitude}" 67 | ) { resp -> 68 | def data = resp.data 69 | def now = data.currently; 70 | sendEvent(name: 'temperature', value: now.temperature, unit: 'F') 71 | sendEvent(name: 'precipChance', value: now.precipProbability, unit: '%') 72 | sendEvent(name: 'cloudCover', value: now.cloudCover, unit: '%') 73 | 74 | def today = data.daily.data[0]; 75 | sendEvent(name: 'temperatureHi', value: today.temperatureHigh, unit: 'F') 76 | sendEvent(name: 'temperatureHiTime', value: today.temperatureHighTime, unit: 's') 77 | sendEvent(name: 'temperatureLo', value: today.temperatureLow, unit: 'F') 78 | sendEvent(name: 'temperatureLoTime', value: today.temperatureLowTime, unit: 's') 79 | } 80 | } catch (e) { 81 | log.error "Error updating: ${e}" 82 | } 83 | } 84 | 85 | def refresh() { 86 | poll() 87 | } 88 | -------------------------------------------------------------------------------- /drivers/jason0x43-garage_door_controller.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Garage Door Controller 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2018-05-28, 09:39:51-0400 6 | */ 7 | 8 | metadata { 9 | definition(name: 'Garage Door Controller', namespace: 'jason0x43', author: 'jason0x43') { 10 | capability 'Actuator' 11 | capability 'Door Control' 12 | capability 'Garage Door Control' 13 | capability 'Sensor' 14 | capability 'Refresh' 15 | } 16 | } 17 | 18 | def open() { 19 | log.debug 'open()' 20 | parent.open() 21 | } 22 | 23 | def close() { 24 | log.debug 'close()' 25 | parent.close() 26 | } 27 | 28 | def refresh() { 29 | log.debug 'refresh()' 30 | parent.refresh() 31 | } 32 | 33 | def setDoorState(newState) { 34 | if (newState.door != device.currentDoor) { 35 | log.debug "setDoorState(${newState})" 36 | sendEvent(name: 'door', value: newState.door) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /drivers/jason0x43-hue_dimmer_switch.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Hue Dimmer Switch 3 | * 4 | * Based on the driver by Stephen McLaughlin 5 | * 6 | * Modified by Jason Cheatham for Hubitat compatibility, 2018-03-24 7 | */ 8 | 9 | metadata { 10 | definition (name: 'Hue Dimmer Switch', namespace: 'jason0x43', author: 'Jason Cheatham') { 11 | capability 'Configuration' 12 | capability 'Battery' 13 | capability 'Refresh' 14 | capability 'PushableButton' 15 | capability 'HoldableButton' 16 | capability 'Sensor' 17 | 18 | fingerprint( 19 | profileId: '0104', 20 | endpointId: '02', 21 | application:'02', 22 | outClusters: '0019', 23 | inClusters: '0000,0001,0003,000F,FC00', 24 | manufacturer: 'Philips', 25 | model: 'RWL020', 26 | deviceJoinName: 'Hue Dimmer Switch' 27 | ) 28 | 29 | attribute 'lastAction', 'string' 30 | } 31 | } 32 | 33 | def parse(description) { 34 | log.debug 'Parsing ' + description 35 | 36 | def msg = zigbee.parse(description) 37 | 38 | // TODO: handle 'numberOfButtons' attribute 39 | 40 | if (description?.startsWith('catchall:')) { 41 | def map = parseCatchAllMessage(description) 42 | return createEvent(map) 43 | } else if (description?.startsWith('enroll request')) { 44 | def cmds = enrollResponse() 45 | return cmds?.collect { new hubitat.device.HubAction(it) } 46 | } else if (description?.startsWith('read attr -')) { 47 | return parseReportAttributeMessage(description).each { 48 | createEvent(it) 49 | } 50 | } 51 | } 52 | 53 | def refresh() { 54 | log.debug 'Refresh' 55 | 56 | def refreshCmds = [] 57 | 58 | // Fetches battery from 0x02 59 | refreshCmds += "st rattr 0x${device.deviceNetworkId} 0x02 0x0001 0x0020" 60 | 61 | // motion, confirmed 62 | // configCmds += zigbee.configureReporting(0x406,0x0000, 0x18, 30, 600, null) 63 | 64 | // refreshCmds += zigbee.configureReporting(0x000F, 0x0055, 0x10, 30, 30, null) 65 | // refreshCmds += 'zdo bind 0xDAD6 0x01 0x02 0x000F {00178801103317AA} {}' 66 | // refreshCmds += 'delay 2000' 67 | // refreshCmds += 'st cr 0xDAD6 0x02 0x000F 0x0055 0x10 0x001E 0x001E {}' 68 | // refreshCmds += 'delay 2000' 69 | 70 | // refreshCmds += zigbee.configureReporting(0x000F, 0x006F, 0x18, 0x30, 0x30) 71 | // refreshCmds += "zdo bind 0x${device.deviceNetworkId} 0x02 0x02 0xFC00 {${device.zigbeeId}} {}" 72 | // refreshCmds += 'delay 2000' 73 | // refreshCmds += "st cr 0x${device.deviceNetworkId} 0x02 0xFC00 0x0000 0x18 0x001E 0x001E {}" 74 | // refreshCmds += 'delay 2000' 75 | // log.debug refreshCmds 76 | 77 | return refreshCmds 78 | } 79 | 80 | def configure() { 81 | log.debug 'Configuring' 82 | 83 | // def zigbeeId = swapEndianHex(device.hub.zigbeeId) 84 | // log.debug 'Configuring Reporting and Bindings.' 85 | def configCmds = [] 86 | 87 | // Configure Button Count 88 | sendEvent(name: 'numberOfButtons', value: 4, displayed: false) 89 | 90 | // Monitor Buttons 91 | // TODO: This could be 92 | // zigbee.configureReporting(0xFC00, 0x0000, 0x18, 0x001e, 0x001e); 93 | // but no idea how to point it at a different endpoint 94 | configCmds += "zdo bind 0x${device.deviceNetworkId} 0x02 0x02 0xFC00 {${device.zigbeeId}} {}" 95 | configCmds += 'delay 2000' 96 | configCmds += "st cr 0x${device.deviceNetworkId} 0x02 0xFC00 0x0000 0x18 0x001E 0x001E {}" 97 | configCmds += 'delay 2000' 98 | 99 | // Monitor Battery 100 | // TODO: This could be zigbee.batteryConfig(); but no idea how to point it 101 | // at a different endpoint 102 | configCmds += "zdo bind 0x${device.deviceNetworkId} 0x02 0x02 0x0001 {${device.zigbeeId}} {}" 103 | configCmds += 'delay 2000' 104 | configCmds += "st cr 0x${device.deviceNetworkId} 0x02 0x0001 0x0020 0x20 0x001E 0x0258 {}" 105 | // configCmds += 'st cr 0x${device.deviceNetworkId} 0x02 0x0001 0x0020 0x20 0x001E 0x001e {}' 106 | configCmds += 'delay 2000' 107 | 108 | return configCmds + refresh() 109 | } 110 | 111 | def configureHealthCheck() { 112 | def hcIntervalMinutes = 12 113 | refresh() 114 | sendEvent( 115 | name: 'checkInterval', 116 | value: hcIntervalMinutes * 60, 117 | displayed: false, 118 | data: [protocol: 'zigbee', hubHardwareId: device.hub.hardwareID] 119 | ) 120 | } 121 | 122 | def updated() { 123 | log.debug 'Updated' 124 | configureHealthCheck() 125 | } 126 | 127 | private parseReportAttributeMessage(description) { 128 | log.trace 'Parsing report attribute message ' + description 129 | 130 | def descMap = (description - 'read attr - ').split(',').inject([:]) { map, param -> 131 | def nameAndValue = param.split(':') 132 | map += [(nameAndValue[0].trim()):nameAndValue[1].trim()] 133 | } 134 | 135 | def result = [] 136 | 137 | // Battery 138 | if (descMap.cluster == '0001' && descMap.attrId == '0020') { 139 | result << getBatteryResult(Integer.parseInt(descMap.value, 16)) 140 | } 141 | 142 | return result 143 | } 144 | 145 | private shouldProcessMessage(cluster) { 146 | log.debug 'Checking if should process message' 147 | 148 | // 0x0B is default response indicating message got through 149 | def ignoredMessage = cluster.profileId != 0x0104 || 150 | cluster.command == 0x0B || 151 | (cluster.data.size() > 0 && cluster.data.first() == 0x3e) 152 | 153 | return !ignoredMessage 154 | } 155 | 156 | private getBatteryResult(rawValue) { 157 | // TODO: needs calibration 158 | log.debug "Battery rawValue = ${rawValue}" 159 | 160 | def result = [ 161 | name: 'battery', 162 | value: '--', 163 | translatable: true 164 | ] 165 | 166 | def volts = rawValue / 10 167 | 168 | if (!(rawValue == 0 || rawValue == 255)) { 169 | if (volts > 3.5) { 170 | result.descriptionText = 171 | "{{ device.displayName }} battery has too much power: (> 3.5) volts." 172 | } else if (device.getDataValue('manufacturer') == 'SmartThings') { 173 | // For the batteryMap to work the key needs to be an int 174 | volts = rawValue 175 | def batteryMap = [ 176 | 28:100, 177 | 27:100, 178 | 26:100, 179 | 25:90, 180 | 24:90, 181 | 23:70, 182 | 22:70, 183 | 21:50, 184 | 20:50, 185 | 19:30, 186 | 18:30, 187 | 17:15, 188 | 16:1, 189 | 15:0 190 | ] 191 | def minVolts = 15 192 | def maxVolts = 28 193 | 194 | if (volts < minVolts) { 195 | volts = minVolts 196 | } else if (volts > maxVolts) { 197 | volts = maxVolts 198 | } 199 | 200 | def pct = batteryMap[volts] 201 | if (pct != null) { 202 | result.value = pct 203 | result.descriptionText = 204 | "${device.displayName} battery was ${value}%" 205 | } 206 | } else { 207 | def minVolts = 2.1 208 | def maxVolts = 3.0 209 | def pct = (volts - minVolts) / (maxVolts - minVolts) 210 | def roundedPct = Math.round(pct * 100) 211 | if (roundedPct <= 0) { 212 | roundedPct = 1 213 | } 214 | result.value = Math.min(100, roundedPct) 215 | result.descriptionText = 216 | "${device.displayName} battery was ${value}%" 217 | } 218 | } 219 | 220 | return result 221 | } 222 | 223 | private getButtonResult(rawValue) { 224 | log.trace 'Getting button for ' + rawValue 225 | 226 | def result = [ 227 | name: 'button', 228 | value: '--', 229 | translatable: true 230 | ] 231 | def button = rawValue[0] 232 | def buttonState = rawValue[4] 233 | def buttonHoldTime = rawValue[6] 234 | 235 | // This is the state in the HUE api 236 | def hueStatus = (button as String) + '00' + (buttonState as String) 237 | 238 | log.error "Button: ${button}, Hue code: ${hueStatus}, hold hime: ${buttonHoldTime}" 239 | result.data = ['buttonNumber': button] 240 | result.value = 'pushed' 241 | 242 | if (buttonState == 2) { 243 | result = createEvent( 244 | name: 'pushed', 245 | value: button, 246 | descriptionText: '${device.displayName} button ${button} was pushed', 247 | isStateChange: true 248 | ) 249 | sendEvent(name: 'lastAction', value: button + ' pushed') 250 | } else if (buttonState == 3) { 251 | result = createEvent( 252 | name: 'held', 253 | value: button, 254 | descriptionText: "${device.displayName} button ${button} was held", 255 | isStateChange: true 256 | ) 257 | sendEvent(name: 'lastAction', value: button + ' held') 258 | } 259 | 260 | return result 261 | } 262 | 263 | private parseCatchAllMessage(description) { 264 | log.trace 'Parsing catchall message' 265 | 266 | def resultMap = [:] 267 | def cluster = zigbee.parse(description) 268 | 269 | if (shouldProcessMessage(cluster)) { 270 | switch (cluster.clusterId) { 271 | case 0x0001: 272 | // 0x07 - configure reporting 273 | if (cluster.command != 0x07) { 274 | resultMap = getBatteryResult(cluster.data.last()) 275 | } 276 | break 277 | 278 | case 0xFC00: 279 | if (cluster.command == 0x00) { 280 | resultMap = getButtonResult(cluster.data) 281 | } 282 | break 283 | } 284 | } 285 | 286 | return resultMap 287 | } 288 | -------------------------------------------------------------------------------- /drivers/jason0x43-nest_thermostat.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * A simple Nest thermostat driver 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2018-12-13, 08:23:39-0500 6 | */ 7 | 8 | metadata { 9 | definition(name: 'Nest Thermostat', namespace: 'jason0x43', author: 'jason0x43') { 10 | capability 'Relative Humidity Measurement' 11 | capability 'Thermostat' 12 | capability 'Temperature Measurement' 13 | capability 'Sensor' 14 | capability 'Refresh' 15 | 16 | command 'eco' 17 | command 'sunblockOn' 18 | command 'sunblockOff' 19 | command 'away' 20 | command 'home' 21 | command 'setScale', ['enum'] 22 | 23 | attribute 'away', 'boolean' 24 | attribute 'sunblockEnabled', 'boolean' 25 | attribute 'sunblockActive', 'boolean' 26 | attribute 'scale', 'enum', ['f', 'c'] 27 | } 28 | 29 | preferences { 30 | input( 31 | name: 'pollInterval', 32 | type: 'enum', 33 | title: 'Update interval (in minutes)', 34 | options: ['5', '10', '30'], 35 | required: true 36 | ) 37 | } 38 | } 39 | 40 | def auto() { 41 | log.debug 'auto()' 42 | nestPut([hvac_mode: 'heat-cool']) 43 | refresh() 44 | } 45 | 46 | def away() { 47 | log.debug 'away()' 48 | parent.setAway(true) 49 | refresh(true) 50 | } 51 | 52 | def cool() { 53 | log.debug 'cool()' 54 | nestPut([hvac_mode: 'cool']) 55 | refresh() 56 | } 57 | 58 | def eco() { 59 | log.debug 'eco()' 60 | nestPut([hvac_mode: 'eco']) 61 | refresh() 62 | } 63 | 64 | def emergencyHeat() { 65 | log.debug 'emergencyHeat is not implemented' 66 | } 67 | 68 | def fanAuto() { 69 | log.debug 'fanAuto()' 70 | nestPut([hvac_mode: 'heat']) 71 | refresh() 72 | } 73 | 74 | def fanCirculate() { 75 | log.debug 'fanCirculate()' 76 | nestPut([ 77 | fan_timer_duration: 15, 78 | fan_timer_active: true 79 | ]) 80 | refresh() 81 | } 82 | 83 | def fanOn() { 84 | log.debug 'Nest only supports "auto" and "circulate"' 85 | } 86 | 87 | def heat() { 88 | log.debug 'heat()' 89 | nestPut([hvac_mode: 'heat']) 90 | refresh() 91 | } 92 | 93 | def home() { 94 | log.debug 'home()' 95 | parent.setAway(false) 96 | refresh(false) 97 | } 98 | 99 | def off() { 100 | log.debug 'off()' 101 | nestPut([hvac_mode: 'off']) 102 | refresh() 103 | } 104 | 105 | def setCoolingSetpoint(target) { 106 | log.debug "setCoolingSetpoint(${target})" 107 | setTargetTemp(target, 'cool') 108 | } 109 | 110 | def setHeatingSetpoint(target) { 111 | log.debug "setHeatingSetpoint(${target})" 112 | setTargetTemp(target, 'heat') 113 | } 114 | 115 | def setScale(scale) { 116 | log.debug "setScale(${scale})" 117 | scale = scale ? scale.toLowerCase() : null 118 | if (scale != 'f' && scale != 'c') { 119 | log.error "Invalid scale ${scale}" 120 | return 121 | } 122 | 123 | nestPut([temperature_scale: scale]) 124 | refresh() 125 | } 126 | 127 | def setSchedule(schedule) { 128 | log.debug "setSchedule(${schedule})" 129 | } 130 | 131 | def setThermostatFanMode(mode) { 132 | log.debug "setThermostatFanMode(${mode})" 133 | } 134 | 135 | def setThermostatMode(mode) { 136 | log.debug "setThermostatMode(${mode})" 137 | } 138 | 139 | def sunblockOff() { 140 | log.debug 'sunblockOff()' 141 | nestPut([sunlight_correction_enabled: false]) 142 | refresh() 143 | } 144 | 145 | def sunblockOn() { 146 | log.debug 'sunblockOn()' 147 | nestPut([sunlight_correction_enabled: true]) 148 | refresh() 149 | } 150 | 151 | def updated() { 152 | log.debug 'Updated' 153 | 154 | // log.trace('Unscheduling poll timer') 155 | unschedule() 156 | 157 | if (pollInterval == '5') { 158 | // log.trace "Polling every 5 minutes" 159 | runEvery5Minutes(refresh) 160 | } else if (pollInterval == '10') { 161 | // log.trace "Polling every 10 minutes" 162 | runEvery10Minutes(refresh) 163 | } else if (pollInterval == '30') { 164 | // log.trace "Polling every 30 minutes" 165 | runEvery30Minutes(refresh) 166 | } 167 | } 168 | 169 | def parse(description) { 170 | log.debug 'Received event: ' + description 171 | } 172 | 173 | def refresh(isAway) { 174 | log.debug 'Refreshing' 175 | if (isAway != null) { 176 | updateState([away: isAway, triesLeft: 3]) 177 | } else { 178 | updateState() 179 | } 180 | } 181 | 182 | private nestPut(data) { 183 | def id = getDataValue('nestId') 184 | parent.nestPut("/devices/thermostats/${id}", data) 185 | } 186 | 187 | private setTargetTemp(temp, heatOrCool) { 188 | def id = getDataValue('nestId') 189 | 190 | def mode = device.currentValue('thermostatMode') 191 | if (mode != heatOrCool && mode != 'heat-cool') { 192 | log.debug "Not ${heatOrCool}ing" 193 | return 194 | } 195 | 196 | def value = temp.toInteger() 197 | def scale = device.currentValue('scale') 198 | 199 | if (mode == 'heat-cool') { 200 | if (heatOrCool == 'cool') { 201 | nestPut(["target_temperature_high_${scale}": value]) 202 | } else { 203 | nestPut(["target_temperature_low_${scale}": value]) 204 | } 205 | } else { 206 | nestPut(["target_temperature_${scale}": value]) 207 | } 208 | 209 | refresh() 210 | } 211 | 212 | private updateState(args) { 213 | def id = getDataValue('nestId') 214 | def data = parent.nestGet("/devices/thermostats/${id}") 215 | 216 | if (data == null) { 217 | log.error 'Got null data from parent' 218 | return 219 | } 220 | 221 | // If the thermostat mode doesn't agree with the 'away' state, wait a few 222 | // seconds and update again 223 | if ( 224 | args && 225 | ( 226 | args.away == true && data.hvac_mode != 'eco' || 227 | args.away == false && data.hvac_mode == 'eco' 228 | ) && 229 | args.triesLeft > 0 230 | ) { 231 | // log.trace "Device hasn't updated for away yet, retrying" 232 | runIn(3, 'updateState', [data: [ 233 | away: args.away, 234 | triesLeft: args.triesLeft - 1 235 | ]]) 236 | return 237 | } 238 | 239 | def scale = data.temperature_scale.toLowerCase() 240 | def away = parent.isAway() 241 | 242 | // log.trace "data: ${data}" 243 | 244 | sendEvent(name: 'away', value: away) 245 | sendEvent(name: 'thermostatMode', value: data.hvac_mode) 246 | sendEvent(name: 'humidity', value: data.humidity) 247 | sendEvent( 248 | name: 'thermostatFanMode', 249 | value: data.fan_timer_active ? 'circulate' : 'auto' 250 | ) 251 | sendEvent(name: 'scale', value: scale) 252 | sendEvent(name: 'sunblockEnabled', value: data.sunlight_correction_enabled) 253 | sendEvent(name: 'sunblockActive', value: data.sunlight_correction_active) 254 | sendEvent(name: 'temperature', value: data["ambient_temperature_${scale}"]) 255 | sendEvent(name: 'temperatureUnit', value: data.temperature_scale) 256 | sendEvent(name: 'nestPresence', value: away ? 'away' : 'home') 257 | sendEvent(name: 'hasLeaf', value: data.has_leaf) 258 | 259 | def state = data.hvac_state == 'off' ? 'idle' : data.hvac_state 260 | sendEvent(name: 'thermostatOperatingState', value: state) 261 | 262 | // log.trace "thermostatMode: ${data.hvac_mode}" 263 | 264 | if (data.hvac_mode == 'heat') { 265 | // log.trace 'setting heating setpoint to ' + data["target_temperature_${scale}"] 266 | sendEvent(name: 'heatingSetpoint', value: data["target_temperature_${scale}"]) 267 | } else if (data.hvac_mode == 'cool') { 268 | // log.trace 'setting cooling setpoint to ' + data["target_temperature_${scale}"] 269 | sendEvent(name: 'coolingSetpoint', value: data["target_temperature_${scale}"]) 270 | } else if (data.hvac_mode == 'eco') { 271 | sendEvent(name: 'heatingSetpoint', value: data["eco_temperature_low_${scale}"]) 272 | sendEvent(name: 'coolingSetpoint', value: data["eco_temperature_high_${scale}"]) 273 | } else if (data.hvac_mode == 'heat-cool') { 274 | sendEvent(name: 'heatingSetpoint', value: data["target_temperature_low_${scale}"]) 275 | sendEvent(name: 'coolingSetpoint', value: data["target_temperature_high_${scale}"]) 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /drivers/jason0x43-pushover.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Pushover Driver 3 | * 4 | * Inspired by original work for SmartThings by: Zachary Priddy, 5 | * https://zpriddy.com, me@zpriddy.com 6 | */ 7 | 8 | preferences { 9 | input('apiKey', 'text', title: 'API Key:', description: 'Pushover API Key') 10 | input('userKey', 'text', title: 'User Key:', description: 'Pushover User Key') 11 | 12 | if (validate()) { 13 | input( 14 | 'deviceName', 15 | 'enum', 16 | title: 'Device Name (Blank = All Devices):', 17 | description: '', 18 | multiple: true, 19 | required: false, 20 | options: validate('deviceList') 21 | ) 22 | input( 23 | 'priority', 24 | 'enum', 25 | title: 'Default Message Priority (Blank = NORMAL):', 26 | description: '', 27 | defaultValue: '0', 28 | options:[['-1':'LOW'], 29 | ['0':'NORMAL'], 30 | ['1':'HIGH']] 31 | ) 32 | input( 33 | 'sound', 34 | 'enum', 35 | title: 'Notification Sound (Blank = App Default):', 36 | description: '', 37 | options: getSoundOptions() 38 | ) 39 | input( 40 | 'url', 41 | 'text', 42 | title: 'Supplementary URL:', 43 | description: '' 44 | ) 45 | input( 46 | 'urlTitle', 47 | 'text', 48 | title: 'URL Title:', 49 | description: '' 50 | ) 51 | } 52 | } 53 | 54 | metadata { 55 | definition(name: 'Pushover', namespace: 'jason0x43', author: 'Jason Cheatham') { 56 | capability 'Notification' 57 | capability 'Actuator' 58 | capability 'Speech Synthesis' 59 | } 60 | } 61 | 62 | def installed() { 63 | } 64 | 65 | def updated() { 66 | } 67 | 68 | def validate(type) { 69 | if (type == 'deviceList') { 70 | log.debug 'Generating Device List...' 71 | } else { 72 | log.debug 'Validating Keys...' 73 | } 74 | 75 | def validated = false 76 | def params = [ 77 | uri: 'https://api.pushover.net/1/users/validate.json', 78 | body: [ 79 | token: apiKey, 80 | user: userKey, 81 | device: '' 82 | ] 83 | ] 84 | 85 | if (apiKey =~ /[A-Za-z0-9]{30}/ && userKey =~ /[A-Za-z0-9]{30}/) { 86 | try { 87 | httpPost(params) { response -> 88 | if (response.status != 200) { 89 | handleError(response) 90 | } else { 91 | if (type == 'deviceList') { 92 | log.debug 'Device list generated' 93 | deviceOptions = response.data.devices 94 | } else { 95 | log.debug 'Keys validated' 96 | validated = true 97 | } 98 | } 99 | } 100 | } catch (Exception e) { 101 | log.error "An invalid key was probably entered. ${e}" 102 | } 103 | } else { 104 | // Do not sendPush() here, the user may have intentionally set up bad keys for testing. 105 | log.error "API key '${apiKey}' or User key '${userKey}' is not properly formatted!" 106 | } 107 | 108 | return type == 'deviceList' ? deviceOptions : validated 109 | } 110 | 111 | def getSoundOptions() { 112 | log.debug 'Generating Notification List...' 113 | 114 | def myOptions = [] 115 | httpGet( 116 | uri: "https://api.pushover.net/1/sounds.json?token=${apiKey}" 117 | ) { response -> 118 | if (response.status != 200) { 119 | handleError(response) 120 | } else { 121 | log.debug 'Notification List Generated' 122 | mySounds = response.data.sounds 123 | mySounds.each { eachSound -> 124 | myOptions << [(eachSound.key): eachSound.value] 125 | } 126 | } 127 | } 128 | 129 | return myOptions 130 | } 131 | 132 | def speak(message) { 133 | deviceNotification(message) 134 | } 135 | 136 | def deviceNotification(message) { 137 | if (message.startsWith('[L]')) { 138 | customPriority = '-1' 139 | message = message.drop(3) 140 | } 141 | 142 | if (message.startsWith('[N]')) { 143 | customPriority = '0' 144 | message = message.drop(3) 145 | } 146 | 147 | if (message.startsWith('[H]')) { 148 | customPriority = '1' 149 | message = message.drop(3) 150 | } 151 | 152 | if (customPriority) { 153 | priority = customPriority 154 | } 155 | 156 | if (deviceName) { 157 | log.debug "Sending Message: ${message} Priority: ${priority} to Device: ${deviceName}" 158 | } else { 159 | log.debug "Sending Message: [${message}] Priority: [${priority}] to [All Devices]" 160 | } 161 | 162 | // Prepare the package to be sent 163 | def params = [ 164 | uri: 'https://api.pushover.net/1/messages.json', 165 | body: [ 166 | token: apiKey, 167 | user: userKey, 168 | message: message, 169 | priority: priority, 170 | sound: sound, 171 | url: url, 172 | device: deviceName, 173 | url_title: urlTitle 174 | ] 175 | ] 176 | 177 | if (apiKey =~ /[A-Za-z0-9]{30}/ && userKey =~ /[A-Za-z0-9]{30}/) { 178 | httpPost(params) { response -> 179 | if (response.status != 200) { 180 | handleError(response) 181 | } else { 182 | log.debug 'Message Received by Pushover server' 183 | } 184 | } 185 | } else { 186 | // Do not sendPush() here, the user may have intentionally set up bad keys for testing. 187 | log.error "API key '${apiKey}' or User key '${userKey}' is not properly formatted!" 188 | } 189 | } 190 | 191 | def handleError(response) { 192 | sendPush( 193 | "ERROR: Pushover received HTTP error ${response.status}. Check your keys!" 194 | ) 195 | log.error "Received HTTP error ${response.status}. Check your keys!" 196 | } 197 | -------------------------------------------------------------------------------- /drivers/jason0x43-virtual_momentary_switch.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Virtual Momentary Switch 3 | * 4 | * Author: Jason Cheatham 5 | * Date: 2018-03-24 6 | */ 7 | 8 | metadata { 9 | definition(name: 'Virtual Momentary Switch', namespace: 'jason0x43', author: 'jason0x43') { 10 | capability 'Actuator' 11 | capability 'Switch' 12 | capability 'Sensor' 13 | } 14 | 15 | preferences { 16 | input( 17 | name: 'delayNum', 18 | type: 'number', 19 | title: 'Delay before switching off (default is 3s)', 20 | required: true, 21 | defaultValue: 3 22 | ) 23 | } 24 | } 25 | 26 | def parse(String description) { 27 | } 28 | 29 | def on() { 30 | sendEvent(name: "switch", value: "on", isStateChange: true, displayed: false) 31 | runIn(delayNum ?: 3, off, [overwrite: false]) 32 | } 33 | 34 | def off() { 35 | sendEvent(name: "switch", value: "off", isStateChange: true, displayed: false) 36 | } 37 | -------------------------------------------------------------------------------- /drivers/jason0x43-wemo_dimmer.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * WeMo Dimmer driver 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2021-03-21, 17:11:06-0400 6 | * 7 | * Based on the original Wemo Switch driver by Juan Risso at SmartThings, 8 | * 2015-10-11. 9 | * 10 | * Copyright 2015 SmartThings 11 | * 12 | * Dimmer-specific information is from kris2k's wemo-dimmer-light-switch 13 | * driver: 14 | * 15 | * https://github.com/kris2k2/SmartThingsPublic/blob/master/devicetypes/kris2k2/wemo-dimmer-light-switch.src/wemo-dimmer-light-switch.groovy 16 | * 17 | * Licensed under the Apache License, Version 2.0 (the 'License'); you may not 18 | * use this file except in compliance with the License. You may obtain a copy 19 | * of the License at: 20 | * 21 | * http://www.apache.org/licenses/LICENSE-2.0 22 | * 23 | * Unless required by applicable law or agreed to in writing, software 24 | * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT 25 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 26 | * License for the specific language governing permissions and limitations 27 | * under the License. 28 | */ 29 | 30 | metadata { 31 | definition( 32 | name: 'Wemo Dimmer', 33 | namespace: 'jason0x43', 34 | author: 'Jason Cheatham', 35 | importUrl: 'https://raw.githubusercontent.com/jason0x43/hubitat/master/drivers/jason0x43-wemo_dimmer.groovy' 36 | ) { 37 | capability 'Actuator' 38 | capability 'Switch' 39 | capability 'Switch Level' 40 | capability 'Polling' 41 | capability 'Refresh' 42 | capability 'Sensor' 43 | 44 | command 'subscribe' 45 | command 'unsubscribe' 46 | command 'resubscribe' 47 | } 48 | } 49 | 50 | def getDriverVersion() { 51 | 4 52 | } 53 | 54 | def on() { 55 | log.info('Turning on') 56 | parent.childSetBinaryState(device, 1) 57 | } 58 | 59 | def off() { 60 | log.info('Turning off') 61 | parent.childSetBinaryState(device, 0) 62 | } 63 | 64 | def setLevel(value) { 65 | def binaryState = 1 66 | 67 | if (value > 0 && value <= 100) { 68 | binaryState = 1 69 | } else if (value == 0) { 70 | binaryState = 0 71 | } else { 72 | binaryState = 1 73 | value = 100 74 | } 75 | log.info("setLevel: Setting level to $value with state to $binaryState") 76 | parent.childSetBinaryState(device, binaryState, value) 77 | } 78 | 79 | def setLevel(value, duration) { 80 | def curValue = device.currentValue('level') 81 | def tgtValue = value 82 | def fadeStepValue = Math.round((tgtValue - curValue)/duration) 83 | 84 | log.info("setLevel: Setting level to $value from $curValue with duration $duration using step value $fadeStepValue") 85 | // TODO, break-down duration=100 into perhaps 10 scheduled actions instead of 100 86 | 87 | // Loop through the duration counter in seconds 88 | for (i = 1; i <= duration; i++) { 89 | curValue = (curValue + fadeStepValue) 90 | 91 | // Fixup integer rounding errors on the last cycle 92 | if (i == duration && curValue != value) { 93 | curValue = value 94 | } 95 | 96 | // Schedule setLevel based on the duration counter 97 | runIn(i, setLevel_scheduledHandler, [overwrite: false, data: [value: curValue]]) 98 | } 99 | } 100 | 101 | def setLevel_scheduledHandler(data) { 102 | setLevel(data.value) 103 | } 104 | 105 | def parse(description) { 106 | debugLog('parse: received message') 107 | 108 | // A message was received, so the device isn't offline 109 | unschedule('setOffline') 110 | 111 | def msg = parseLanMessage(description) 112 | parent.childUpdateSubscription(msg, device) 113 | 114 | def result = [] 115 | def bodyString = msg.body 116 | if (bodyString) { 117 | def body = new XmlSlurper().parseText(bodyString) 118 | 119 | if (body?.property?.TimeSyncRequest?.text()) { 120 | debugLog('parse: Got TimeSyncRequest') 121 | result << syncTime() 122 | } else if (body?.Body?.SetBinaryStateResponse?.BinaryState?.text()) { 123 | def rawValue = body.Body.SetBinaryStateResponse.BinaryState.text() 124 | debugLog("parse: Got SetBinaryStateResponse = ${rawValue}") 125 | result << createBinaryStateEvent(rawValue) 126 | 127 | if (body?.Body?.SetBinaryStateResponse?.brightness?.text()) { 128 | rawValue = body.Body.SetBinaryStateResponse.brightness?.text() 129 | debugLog("parse: Notify: brightness = ${rawValue}") 130 | result << createLevelEvent(rawValue) 131 | } 132 | } else if (body?.property?.BinaryState?.text()) { 133 | def rawValue = body.property.BinaryState.text() 134 | debugLog("parse: Notify: BinaryState = ${rawValue}") 135 | result << createBinaryStateEvent(rawValue) 136 | 137 | if (body.property.brightness?.text()) { 138 | rawValue = body.property.brightness?.text() 139 | debugLog("parse: Notify: brightness = ${rawValue}") 140 | result << createLevelEvent(rawValue) 141 | } 142 | } else if (body?.property?.TimeZoneNotification?.text()) { 143 | debugLog("parse: Notify: TimeZoneNotification = ${body.property.TimeZoneNotification.text()}") 144 | } else if (body?.Body?.GetBinaryStateResponse?.BinaryState?.text()) { 145 | def rawValue = body.Body.GetBinaryStateResponse.BinaryState.text() 146 | debugLog("parse: GetBinaryResponse: BinaryState = ${rawValue}") 147 | result << createBinaryStateEvent(rawValue) 148 | 149 | if (body.Body.GetBinaryStateResponse.brightness?.text()) { 150 | rawValue = body.Body.GetBinaryStateResponse.brightness?.text() 151 | debugLog("parse: GetBinaryResponse: brightness = ${rawValue}") 152 | result << createLevelEvent(rawValue) 153 | } 154 | } 155 | } 156 | 157 | result 158 | } 159 | 160 | def poll() { 161 | log.info('Polling') 162 | 163 | // Schedule a call to flag the device offline if no new message is received 164 | if (device.currentValue('switch') != 'offline') { 165 | runIn(10, setOffline) 166 | } 167 | 168 | parent.childGetBinaryState(device) 169 | } 170 | 171 | def refresh() { 172 | log.info('Refreshing') 173 | [ 174 | resubscribe(), 175 | syncTime(), 176 | poll() 177 | ] 178 | } 179 | 180 | def resubscribe() { 181 | log.info('Resubscribing') 182 | 183 | // Schedule a subscribe check that will run after the resubscription should 184 | // have completed 185 | runIn(10, subscribeIfNecessary) 186 | 187 | parent.childResubscribe(device) 188 | } 189 | 190 | def setOffline() { 191 | sendEvent( 192 | name: 'switch', 193 | value: 'offline', 194 | descriptionText: 'The device is offline' 195 | ) 196 | } 197 | 198 | def subscribe() { 199 | log.info('Subscribing') 200 | parent.childSubscribe(device) 201 | } 202 | 203 | def subscribeIfNecessary() { 204 | parent.childSubscribeIfNecessary(device) 205 | } 206 | 207 | def unsubscribe() { 208 | log.info('Unsubscribing') 209 | parent.childUnsubscribe(device) 210 | } 211 | 212 | def updated() { 213 | log.info('Updated') 214 | refresh() 215 | } 216 | 217 | private createBinaryStateEvent(rawValue) { 218 | def value = '' 219 | 220 | // Properly interpret our rawValue 221 | if (rawValue == '1') { 222 | value = 'on' 223 | } else if (rawValue == '0') { 224 | value = 'off' 225 | } else { 226 | // Sometimes, wemo returns us with rawValue=error, so we do nothing 227 | debugLog("parse: createBinaryStateEvent: rawValue = ${rawValue} : Invalid! Not raising any events") 228 | return 229 | } 230 | 231 | // Raise the switch state event 232 | createEvent( 233 | name: 'switch', 234 | value: value, 235 | descriptionText: "Switch is ${value} : ${rawValue}" 236 | ) 237 | } 238 | 239 | private createLevelEvent(rawValue) { 240 | def value = "$rawValue".toInteger() // rawValue is always an integer from 0 to 100 241 | createEvent( 242 | name: 'level', 243 | value: value, 244 | descriptionText: "Level is ${value}" 245 | ) 246 | } 247 | 248 | private debugLog(message) { 249 | if (parent.debugLogging) { 250 | log.debug(message) 251 | } 252 | } 253 | 254 | private syncTime() { 255 | parent.childSyncTime(device) 256 | } 257 | -------------------------------------------------------------------------------- /drivers/jason0x43-wemo_insight_switch.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * WeMo Insight Switch driver 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2021-03-21, 17:07:19-0400 6 | * 7 | * Based on the original Wemo Switch driver by Juan Risso at SmartThings, 8 | * 2015-10-11. 9 | * 10 | * Copyright 2015 SmartThings 11 | * 12 | * Licensed under the Apache License, Version 2.0 (the 'License'); you may not 13 | * use this file except in compliance with the License. You may obtain a copy 14 | * of the License at: 15 | * 16 | * http://www.apache.org/licenses/LICENSE-2.0 17 | * 18 | * Unless required by applicable law or agreed to in writing, software 19 | * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT 20 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 21 | * License for the specific language governing permissions and limitations 22 | * under the License. 23 | */ 24 | 25 | metadata { 26 | definition( 27 | name: 'Wemo Insight Switch', 28 | namespace: 'jason0x43', 29 | author: 'Jason Cheatham', 30 | importUrl: 'https://raw.githubusercontent.com/jason0x43/hubitat/master/drivers/jason0x43-wemo_insight_switch.groovy' 31 | ) { 32 | capability 'Actuator' 33 | capability 'Switch' 34 | capability 'Polling' 35 | capability 'Refresh' 36 | capability 'Sensor' 37 | capability 'Power Meter' 38 | capability 'Energy Meter' 39 | 40 | command 'subscribe' 41 | command 'unsubscribe' 42 | command 'resubscribe' 43 | } 44 | } 45 | 46 | def getDriverVersion() { 47 | 4 48 | } 49 | 50 | def on() { 51 | log.info('Turning on') 52 | parent.childSetBinaryState(device, '1') 53 | } 54 | 55 | def off() { 56 | log.info('Turning off') 57 | parent.childSetBinaryState(device, '0') 58 | } 59 | 60 | def parse(description) { 61 | debugLog('parse: received message') 62 | 63 | // A message was received, so the device isn't offline 64 | unschedule('setOffline') 65 | 66 | def msg = parseLanMessage(description) 67 | parent.childUpdateSubscription(msg, device) 68 | 69 | def result = [] 70 | def bodyString = msg.body 71 | if (bodyString) { 72 | def body = new XmlSlurper().parseText(bodyString) 73 | 74 | if (body?.property?.TimeSyncRequest?.text()) { 75 | debugLog('parse: Got TimeSyncRequest') 76 | result << syncTime() 77 | } else if (body?.Body?.SetBinaryStateResponse?.BinaryState?.text()) { 78 | def rawValue = body.Body.SetBinaryStateResponse.BinaryState.text() 79 | debugLog("parse: Got SetBinaryStateResponse: ${rawValue}") 80 | result += createStateEvents(rawValue) 81 | } else if (body?.property?.BinaryState?.text()) { 82 | def rawValue = body.property.BinaryState.text() 83 | debugLog("parse: Notify: BinaryState = ${rawValue}") 84 | result += createStateEvents(rawValue) 85 | } else if (body?.property?.TimeZoneNotification?.text()) { 86 | debugLog("parse: Notify: TimeZoneNotification = ${body.property.TimeZoneNotification.text()}") 87 | } else if (body?.Body?.GetBinaryStateResponse?.BinaryState?.text()) { 88 | def rawValue = body.Body.GetBinaryStateResponse.BinaryState.text() 89 | debugLog("parse: GetBinaryResponse: BinaryState = ${rawValue}") 90 | result += createStateEvents(rawValue) 91 | } else if (body?.Body?.GetInsightParamsResponse?.InsightParams?.text()) { 92 | def rawValue = body.Body.GetInsightParamsResponse.InsightParams.text() 93 | debugLog("parse: Got GetInsightParamsResponse: ${rawValue}") 94 | result += createStateEvents(rawValue) 95 | } 96 | } 97 | 98 | result 99 | } 100 | 101 | def poll() { 102 | log.info('Polling') 103 | 104 | // Schedule a call to flag the device offline if no new message is received 105 | if (device.currentValue('switch') != 'offline') { 106 | runIn(10, setOffline) 107 | } 108 | 109 | parent.childGetBinaryState(device) 110 | } 111 | 112 | def refresh() { 113 | log.info('Refreshing') 114 | [ 115 | resubscribe(), 116 | syncTime(), 117 | poll() 118 | ] 119 | } 120 | 121 | def resubscribe() { 122 | log.info('Resubscribing') 123 | 124 | // Schedule a subscribe check that will run after the resubscription should 125 | // have completed 126 | runIn(10, subscribeIfNecessary) 127 | 128 | parent.childResubscribe(device) 129 | } 130 | 131 | def setOffline() { 132 | sendEvent( 133 | name: 'switch', 134 | value: 'offline', 135 | descriptionText: 'The device is offline' 136 | ) 137 | } 138 | 139 | def subscribe() { 140 | log.info('Subscribing') 141 | parent.childSubscribe(device) 142 | } 143 | 144 | def subscribeIfNecessary() { 145 | parent.childSubscribeIfNecessary(device) 146 | } 147 | 148 | def unsubscribe() { 149 | log.info('Unsubscribing') 150 | parent.childUnsubscribe(device) 151 | } 152 | 153 | def updated() { 154 | log.info('Updated') 155 | refresh() 156 | } 157 | 158 | private createBinaryStateEvent(rawValue) { 159 | // Insight switches actually support 3 values: 160 | // 0: off 161 | // 1: on 162 | // 8: standby 163 | // We consider 'standby' to be 'on'. 164 | debugLog("Creating binary state event for ${rawValue}") 165 | def value = rawValue == '0' ? 'off' : 'on'; 166 | createEvent( 167 | name: 'switch', 168 | value: value, 169 | descriptionText: "Switch is ${value}" 170 | ) 171 | } 172 | 173 | private createEnergyEvent(rawValue) { 174 | debugLog("Creating energy event for ${rawValue}") 175 | def value = (rawValue.toDouble() / 60000000).round(2) 176 | createEvent( 177 | name: 'energy', 178 | value: value, 179 | descriptionText: "Energy today is ${value} WH" 180 | ) 181 | } 182 | 183 | private createPowerEvent(rawValue) { 184 | debugLog("Creating power event for ${rawValue}") 185 | def value = Math.round(rawValue.toInteger() / 1000) 186 | createEvent( 187 | name: 'power', 188 | value: value, 189 | descriptionText: "Power is ${value} W" 190 | ) 191 | } 192 | 193 | // A state event will probably look like: 194 | // 8|1536896687|5998|0|249789|1209600|118|190|164773|483265057 195 | // Fields are: 196 | // 8 on/off 197 | // 1536896687 last changed at (UNIX timestamp) 198 | // 5998 last on for (seconds?) 199 | // 0 on today 200 | // 249789 on total 201 | // 1209600 window (seconds) over which onTotal is aggregated 202 | // 110 average power (Watts) 203 | // 190 current power (Watts?) 204 | // 164773 energy today (mW mins) 205 | // 483265057 energy total (mW mins) 206 | private createStateEvents(stateString) { 207 | def params = stateString.split('\\|') 208 | debugLog("Event params: ${params}") 209 | def events = [] 210 | if (params.size() > 0) { 211 | events << createBinaryStateEvent(params[0]) 212 | } 213 | if (params.size() > 7) { 214 | events << createPowerEvent(params[7]) 215 | } 216 | if (params.size() > 8) { 217 | events << createEnergyEvent(params[8]) 218 | } 219 | events 220 | } 221 | 222 | private debugLog(message) { 223 | if (parent.debugLogging) { 224 | log.debug(message) 225 | } 226 | } 227 | 228 | private syncTime() { 229 | parent.childSyncTime(device) 230 | } 231 | -------------------------------------------------------------------------------- /drivers/jason0x43-wemo_maker.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * WeMo Maker driver 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2021-03-21, 17:07:13-0400 6 | * 7 | * Inspired by Chris Kitch's WeMo Maker driver 8 | * at https://github.com/Kriskit/SmartThingsPublic/blob/master/devicetypes/kriskit/wemo/wemo-maker.groovy 9 | * 10 | * Licensed under the Apache License, Version 2.0 (the 'License'); you may not 11 | * use this file except in compliance with the License. You may obtain a copy 12 | * of the License at: 13 | * 14 | * http://www.apache.org/licenses/LICENSE-2.0 15 | * 16 | * Unless required by applicable law or agreed to in writing, software 17 | * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT 18 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 19 | * License for the specific language governing permissions and limitations 20 | * under the License. 21 | */ 22 | 23 | metadata { 24 | definition( 25 | name: 'Wemo Maker', 26 | namespace: 'jason0x43', 27 | author: 'Jason Cheatham', 28 | importUrl: 'https://raw.githubusercontent.com/jason0x43/hubitat/master/drivers/jason0x43-wemo_maker.groovy' 29 | ) { 30 | capability 'Actuator' 31 | capability 'Switch' 32 | capability 'Contact Sensor' 33 | capability 'Momentary' 34 | capability 'Polling' 35 | capability 'Refresh' 36 | capability 'Sensor' 37 | 38 | attribute 'switchMode', 'string' 39 | attribute 'sensorPresent', 'string' 40 | 41 | command 'subscribe' 42 | command 'unsubscribe' 43 | command 'resubscribe' 44 | } 45 | 46 | preferences { 47 | section { 48 | input( 49 | name: 'invertSensor', 50 | type: 'bool', 51 | title: 'Invert Sensor', 52 | description: 'Inverts the sensor input', 53 | required: true 54 | ) 55 | } 56 | } 57 | } 58 | 59 | def getDriverVersion() { 60 | 4 61 | } 62 | 63 | def on() { 64 | log.info('Turning on') 65 | parent.childSetBinaryState(device, '1') 66 | } 67 | 68 | def off() { 69 | log.info('Turning off') 70 | parent.childSetBinaryState(device, '0') 71 | } 72 | 73 | def parse(description) { 74 | debugLog('parse: received message') 75 | 76 | // A message was received, so the device isn't offline 77 | unschedule('setOffline') 78 | 79 | def msg = parseLanMessage(description) 80 | parent.childUpdateSubscription(msg, device) 81 | 82 | def result = [] 83 | def bodyString = msg.body 84 | if (bodyString) { 85 | debugLog('parse: message body: ' + bodyString) 86 | 87 | def body = new XmlSlurper().parseText(bodyString) 88 | 89 | if (body?.property?.TimeSyncRequest?.text()) { 90 | debugLog('parse: Got TimeSyncRequest') 91 | result << syncTime() 92 | } else if (body?.Body?.SetBinaryStateResponse?.BinaryState?.text()) { 93 | def rawValue = body.Body.SetBinaryStateResponse.BinaryState.text() 94 | debugLog("parse: Got SetBinaryStateResponse = ${rawValue}") 95 | result << createBinaryStateEvent(rawValue) 96 | } else if (body?.property?.BinaryState?.text()) { 97 | def rawValue = body.property.BinaryState.text() 98 | debugLog("parse: Notify: BinaryState = ${rawValue}") 99 | result << createBinaryStateEvent(rawValue) 100 | } else if (body?.property?.TimeZoneNotification?.text()) { 101 | debugLog("parse: Notify: TimeZoneNotification = ${body.property.TimeZoneNotification.text()}") 102 | } else if (body?.Body?.GetBinaryStateResponse?.BinaryState?.text()) { 103 | def rawValue = body.Body.GetBinaryStateResponse.BinaryState.text() 104 | debugLog("parse: GetBinaryResponse: BinaryState = ${rawValue}") 105 | result << createBinaryStateEvent(rawValue) 106 | } else if (body?.property?.attributeList?.text()) { 107 | def rawValue = body.property.attributeList.text() 108 | debugLog("parse: Got PropertySet = ${rawValue}") 109 | result << createPropertySetEvent(rawValue) 110 | } 111 | } 112 | 113 | result 114 | } 115 | 116 | def poll() { 117 | log.info('Polling') 118 | 119 | // Schedule a call to flag the device offline if no new message is received 120 | if (device.currentValue('switch') != 'offline') { 121 | runIn(10, setOffline) 122 | } 123 | 124 | parent.childGetBinaryState(device) 125 | } 126 | 127 | def push() { 128 | log.info('Pushing') 129 | parent.childSetBinaryState(device, '1') 130 | } 131 | 132 | def refresh() { 133 | log.info('Refreshing') 134 | [ 135 | resubscribe(), 136 | syncTime(), 137 | poll() 138 | ] 139 | } 140 | 141 | def resubscribe() { 142 | log.info('Resubscribing') 143 | 144 | // Schedule a subscribe check that will run after the resubscription should 145 | // have completed 146 | runIn(10, subscribeIfNecessary) 147 | 148 | parent.childResubscribe(device) 149 | } 150 | 151 | def setOffline() { 152 | sendEvent( 153 | name: 'switch', 154 | value: 'offline', 155 | descriptionText: 'The device is offline' 156 | ) 157 | } 158 | 159 | def subscribe() { 160 | log.info('Subscribing') 161 | parent.childSubscribe(device) 162 | } 163 | 164 | def subscribeIfNecessary() { 165 | parent.childSubscribeIfNecessary(device) 166 | } 167 | 168 | def unsubscribe() { 169 | log.info('Unsubscribing') 170 | parent.childUnsubscribe(device) 171 | } 172 | 173 | def updated() { 174 | log.info('Updated') 175 | refresh() 176 | } 177 | 178 | private createBinaryStateEvent(rawValue) { 179 | if (rawValue == '1') { 180 | updateSwitch('on') 181 | } else if (rawValue == '0') { 182 | updateSwitch('off') 183 | } else { 184 | log.debug("createBinaryStateEvent: rawValue == ${rawValue}, ignoring") 185 | } 186 | } 187 | 188 | private createPropertySetEvent(rawValue) { 189 | def attrList = new XmlSlurper().parseText('' + rawValue + '') 190 | def result = [] 191 | processAttributeList(attrList, result) 192 | } 193 | 194 | private debugLog(message) { 195 | if (parent.debugLogging) { 196 | log.debug(message) 197 | } 198 | } 199 | 200 | private processAttributeList(list, result) { 201 | def values = [:] 202 | 203 | list?.attribute.findAll { 204 | it.name?.text() 205 | }.each { 206 | values[it.name.text()] = it.value.text() 207 | } 208 | 209 | log.debug("sensorPresent: ${device.currentValue('sensorPresent')}") 210 | log.debug("values: ${values}") 211 | 212 | def sensorPresent = device.currentValue('sensorPresent') == 'on' 213 | 214 | if (values['SensorPresent']) { 215 | log.debug "SensorPresent = ${values['SensorPresent']}" 216 | def newSensorPresent = values['SensorPresent'] == '1' 217 | 218 | // if (sensorPresent != newSensorPresent && newSensorPresent && !values['Sensor']) { 219 | if (!values['Sensor']) { 220 | values['Sensor'] = '0' 221 | } 222 | 223 | result << updateSensorPresent(newSensorPresent ? 'on' : 'off') 224 | sensorPresent = newSensorPresent 225 | } 226 | 227 | // The sensor presence functionality appears to either not work, or work 228 | // differently than other examples I've seen, so just ignore it. 229 | // if (!sensorPresent) { 230 | // result << updateSensor('disabled') 231 | // } else if (values['Sensor']) { 232 | 233 | if (values['Sensor']) { 234 | log.debug "Sensor = ${values['Sensor']}" 235 | def checkValue = invertSensor ? '1' : '0' 236 | result << updateSensor(values['Sensor'] == checkValue ? 'closed' : 'open') 237 | } 238 | 239 | def switchMode = device.currentValue('switchMode') 240 | 241 | if (values['SwitchMode']) { 242 | log.debug "SwitchMode = ${values['SwitchMode']}" 243 | switchMode = values['SwitchMode'] == '0' ? 'toggle' : 'momentary' 244 | 245 | if (switchMode == 'momentary' && device.currentValue('switch') != 'momentary') { 246 | result << updateSwitch('momentary') 247 | } else if (!values['Switch'] && switchMode == 'toggle' && device.currentValue('switch') != 'toggle') { 248 | values['Switch'] = '0' 249 | } 250 | 251 | result << updateSwitchMode(switchMode) 252 | } 253 | 254 | if (values['Switch']) { 255 | log.debug "Switch = ${values['Switch']}" 256 | if (switchMode == 'toggle') { 257 | result << updateSwitch(values['Switch'] == '0' ? 'off' : 'on') 258 | } else if (values['Switch'] == '0') { 259 | result << updateSwitch('momentary') 260 | } 261 | } 262 | 263 | result 264 | } 265 | 266 | private syncTime() { 267 | parent.childSyncTime(device) 268 | } 269 | 270 | private updateSensor(value) { 271 | createEvent( 272 | name: 'contact', 273 | value: value, 274 | descriptionText: "Contact is ${value}" 275 | ) 276 | } 277 | 278 | private updateSensorPresent(value) { 279 | def sensorState = value ? 'enabled' : 'disabled' 280 | createEvent( 281 | name: "sensorPresent", 282 | value: value, 283 | descriptionText: "Sensor is ${sensorState}" 284 | ) 285 | } 286 | 287 | private updateSwitch(value) { 288 | createEvent( 289 | name: 'switch', 290 | value: value, 291 | descriptionText: "Switch is ${value}" 292 | ) 293 | } 294 | 295 | private updateSwitchMode(value) { 296 | createEvent( 297 | name: 'switchMode', 298 | value: value, 299 | descriptionText: "Switch mode is ${value}" 300 | ) 301 | } 302 | -------------------------------------------------------------------------------- /drivers/jason0x43-wemo_motion.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * WeMo Motion driver 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2021-03-21, 17:07:57-0400 6 | * 7 | * Based on the original Wemo Motion driver by SmartThings, 2013-10-11. 8 | * 9 | * Copyright 2015 SmartThings 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the 'License'); you may not 12 | * use this file except in compliance with the License. You may obtain a copy 13 | * of the License at: 14 | * 15 | * http://www.apache.org/licenses/LICENSE-2.0 16 | * 17 | * Unless required by applicable law or agreed to in writing, software 18 | * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT 19 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | * License for the specific language governing permissions and limitations 21 | * under the License. 22 | */ 23 | 24 | metadata { 25 | definition( 26 | name: 'Wemo Motion', 27 | namespace: 'jason0x43', 28 | author: 'Jason Cheatham', 29 | importUrl: 'https://raw.githubusercontent.com/jason0x43/hubitat/master/drivers/jason0x43-wemo_motion.groovy' 30 | ) { 31 | capability 'Motion Sensor' 32 | capability 'Polling' 33 | capability 'Refresh' 34 | capability 'Sensor' 35 | 36 | command 'subscribe' 37 | command 'unsubscribe' 38 | command 'resubscribe' 39 | } 40 | } 41 | 42 | def getDriverVersion() { 43 | 4 44 | } 45 | 46 | def parse(description) { 47 | debugLog('parse: received message') 48 | 49 | // A message was received, so the device isn't offline 50 | unschedule('setOffline') 51 | 52 | def msg = parseLanMessage(description) 53 | parent.childUpdateSubscription(msg, device) 54 | 55 | def result = [] 56 | def bodyString = msg.body 57 | if (bodyString) { 58 | def body = new XmlSlurper().parseText(bodyString) 59 | 60 | if (body?.property?.TimeSyncRequest?.text()) { 61 | debugLog('parse: Got TimeSyncRequest') 62 | result << syncTime() 63 | } else if (body?.Body?.SetBinaryStateResponse?.BinaryState?.text()) { 64 | def rawValue = body.Body.SetBinaryStateResponse.BinaryState.text() 65 | debugLog("parse: Got SetBinaryStateResponse = ${rawValue}") 66 | result << createBinaryStateEvent(rawValue) 67 | } else if (body?.property?.BinaryState?.text()) { 68 | def rawValue = body.property.BinaryState.text() 69 | debugLog("parse: Notify: BinaryState = ${rawValue}") 70 | result << createBinaryStateEvent(rawValue) 71 | } else if (body?.property?.TimeZoneNotification?.text()) { 72 | debugLog("parse: Notify: TimeZoneNotification = ${body.property.TimeZoneNotification.text()}") 73 | } else if (body?.Body?.GetBinaryStateResponse?.BinaryState?.text()) { 74 | def rawValue = body.Body.GetBinaryStateResponse.BinaryState.text() 75 | debugLog("parse: GetBinaryResponse: BinaryState = ${rawValue}") 76 | result << createBinaryStateEvent(rawValue) 77 | } 78 | } 79 | 80 | result 81 | } 82 | 83 | def poll() { 84 | log.info('Polling') 85 | 86 | // Schedule a call to flag the device offline if no new message is received 87 | if (device.currentValue('switch') != 'offline') { 88 | runIn(10, setOffline) 89 | } 90 | 91 | parent.childGetBinaryState(device) 92 | } 93 | 94 | def refresh() { 95 | log.info('Refreshing') 96 | [ 97 | resubscribe(), 98 | syncTime(), 99 | poll() 100 | ] 101 | } 102 | 103 | def resubscribe() { 104 | log.info('Resubscribing') 105 | 106 | // Schedule a subscribe check that will run after the resubscription should 107 | // have completed 108 | runIn(10, subscribeIfNecessary) 109 | 110 | parent.childResubscribe(device) 111 | } 112 | 113 | def setOffline() { 114 | sendEvent( 115 | name: 'motion', 116 | value: 'offline', 117 | descriptionText: 'The device is offline' 118 | ) 119 | } 120 | 121 | def subscribe() { 122 | log.info('Subscribing') 123 | parent.childSubscribe(device) 124 | } 125 | 126 | def subscribeIfNecessary() { 127 | parent.childSubscribeIfNecessary(device) 128 | } 129 | 130 | def unsubscribe() { 131 | log.info('Unsubscribing') 132 | parent.childUnsubscribe(device) 133 | } 134 | 135 | def updated() { 136 | log.info('Updated') 137 | refresh() 138 | } 139 | 140 | private createBinaryStateEvent(rawValue) { 141 | def value = rawValue == '0' ? 'inactive' : 'active' 142 | createEvent( 143 | name: 'motion', 144 | value: value, 145 | descriptionText: "Motion is ${value}" 146 | ) 147 | } 148 | 149 | 150 | private debugLog(message) { 151 | if (parent.debugLogging) { 152 | log.debug(message) 153 | } 154 | } 155 | 156 | private syncTime() { 157 | parent.childSyncTime(device) 158 | } 159 | -------------------------------------------------------------------------------- /drivers/jason0x43-wemo_switch.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * WeMo Switch driver 3 | * 4 | * Author: Jason Cheatham 5 | * Last updated: 2021-03-21, 17:08:44-0400 6 | * 7 | * Based on the original Wemo Switch driver by Juan Risso at SmartThings, 8 | * 2015-10-11. 9 | * 10 | * Copyright 2015 SmartThings 11 | * 12 | * Licensed under the Apache License, Version 2.0 (the 'License'); you may not 13 | * use this file except in compliance with the License. You may obtain a copy 14 | * of the License at: 15 | * 16 | * http://www.apache.org/licenses/LICENSE-2.0 17 | * 18 | * Unless required by applicable law or agreed to in writing, software 19 | * distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT 20 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 21 | * License for the specific language governing permissions and limitations 22 | * under the License. 23 | */ 24 | 25 | metadata { 26 | definition( 27 | name: 'Wemo Switch', 28 | namespace: 'jason0x43', 29 | author: 'Jason Cheatham', 30 | importUrl: 'https://raw.githubusercontent.com/jason0x43/hubitat/master/drivers/jason0x43-wemo_switch.groovy' 31 | ) { 32 | capability 'Actuator' 33 | capability 'Switch' 34 | capability 'Polling' 35 | capability 'Refresh' 36 | capability 'Sensor' 37 | 38 | command 'subscribe' 39 | command 'unsubscribe' 40 | command 'resubscribe' 41 | } 42 | } 43 | 44 | def getDriverVersion() { 45 | 4 46 | } 47 | 48 | def on() { 49 | log.info('Turning on') 50 | parent.childSetBinaryState(device, '1') 51 | } 52 | 53 | def off() { 54 | log.info('Turning off') 55 | parent.childSetBinaryState(device, '0') 56 | } 57 | 58 | def parse(description) { 59 | debugLog('parse: received message') 60 | 61 | // A message was received, so the device isn't offline 62 | unschedule('setOffline') 63 | 64 | def msg = parseLanMessage(description) 65 | parent.childUpdateSubscription(msg, device) 66 | 67 | def result = [] 68 | def bodyString = msg.body 69 | if (bodyString) { 70 | def body = new XmlSlurper().parseText(bodyString) 71 | 72 | if (body?.property?.TimeSyncRequest?.text()) { 73 | debugLog('parse: Got TimeSyncRequest') 74 | result << syncTime() 75 | } else if (body?.Body?.SetBinaryStateResponse?.BinaryState?.text()) { 76 | def rawValue = body.Body.SetBinaryStateResponse.BinaryState.text() 77 | debugLog("parse: Got SetBinaryStateResponse = ${rawValue}") 78 | result << createBinaryStateEvent(rawValue) 79 | } else if (body?.property?.BinaryState?.text()) { 80 | def rawValue = body.property.BinaryState.text() 81 | debugLog("parse: Notify: BinaryState = ${rawValue}") 82 | result << createBinaryStateEvent(rawValue) 83 | } else if (body?.property?.TimeZoneNotification?.text()) { 84 | debugLog("parse: Notify: TimeZoneNotification = ${body.property.TimeZoneNotification.text()}") 85 | } else if (body?.Body?.GetBinaryStateResponse?.BinaryState?.text()) { 86 | def rawValue = body.Body.GetBinaryStateResponse.BinaryState.text() 87 | debugLog("parse: GetBinaryResponse: BinaryState = ${rawValue}") 88 | result << createBinaryStateEvent(rawValue) 89 | } 90 | } 91 | 92 | result 93 | } 94 | 95 | def poll() { 96 | log.info('Polling') 97 | 98 | // Schedule a call to flag the device offline if no new message is received 99 | if (device.currentValue('switch') != 'offline') { 100 | runIn(10, setOffline) 101 | } 102 | 103 | parent.childGetBinaryState(device) 104 | } 105 | 106 | def refresh() { 107 | log.info('Refreshing') 108 | [ 109 | resubscribe(), 110 | syncTime(), 111 | poll() 112 | ] 113 | } 114 | 115 | def resubscribe() { 116 | log.info('Resubscribing') 117 | 118 | // Schedule a subscribe check that will run after the resubscription should 119 | // have completed 120 | runIn(10, subscribeIfNecessary) 121 | 122 | parent.childResubscribe(device) 123 | } 124 | 125 | def setOffline() { 126 | sendEvent( 127 | name: 'switch', 128 | value: 'offline', 129 | descriptionText: 'The device is offline' 130 | ) 131 | } 132 | 133 | def subscribe() { 134 | log.info('Subscribing') 135 | parent.childSubscribe(device) 136 | } 137 | 138 | def subscribeIfNecessary() { 139 | parent.childSubscribeIfNecessary(device) 140 | } 141 | 142 | def unsubscribe() { 143 | log.info('Unsubscribing') 144 | parent.childUnsubscribe(device) 145 | } 146 | 147 | def updated() { 148 | log.info('Updated') 149 | refresh() 150 | } 151 | 152 | private createBinaryStateEvent(rawValue) { 153 | def value = '' 154 | 155 | // Properly interpret our rawValue 156 | if (rawValue == '1') { 157 | value = 'on' 158 | } else if (rawValue == '0') { 159 | value = 'off' 160 | } else { 161 | // Sometimes, wemo returns us with rawValue=error, so we do nothing 162 | debugLog("parse: createBinaryStateEvent: rawValue = ${rawValue} : Invalid! Not raising any events") 163 | return 164 | } 165 | 166 | // Raise the switch state event 167 | createEvent( 168 | name: 'switch', 169 | value: value, 170 | descriptionText: "Switch is ${value} : ${rawValue}" 171 | ) 172 | } 173 | 174 | private debugLog(message) { 175 | if (parent.debugLogging) { 176 | log.debug(message) 177 | } 178 | } 179 | 180 | private syncTime() { 181 | parent.childSyncTime(device) 182 | } 183 | -------------------------------------------------------------------------------- /hubitat: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | needsRebuild() { 6 | files=$(find . -name '*.ts' -not -path './src/node_modules/*') 7 | for ts in $files; do 8 | js="${ts%.*}.js" 9 | if [[ (! -f $js) || ($ts -nt $js) ]]; then 10 | echo 'rebuild' 11 | return 12 | fi 13 | done 14 | } 15 | 16 | if [[ ! -d 'src/node_modules' ]]; then 17 | echo 'Installing script dependencies...' 18 | cd src 19 | npm install 20 | cd .. 21 | fi 22 | 23 | if [[ -n $(needsRebuild) ]]; then 24 | echo 'Building the script...' 25 | cd src 26 | npm --silent run build 27 | cd .. 28 | fi 29 | 30 | # Use --no-warnings to suppress the fs.promises ExperimentalWarning 31 | node --no-warnings src/index.js $* 32 | -------------------------------------------------------------------------------- /packageManifest-wemo.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageName": "Wemo App and Drivers", 3 | "author": "Jason Cheatham", 4 | "version": "1.4.0", 5 | "minimumHEVersion": "2.1.0", 6 | "dateReleased": "2021-02-02", 7 | "apps": [ 8 | { 9 | "id": "2a4ef4ec-aa37-4e9f-a288-a92cef5e274b", 10 | "name": "Wemo Connect", 11 | "namespace": "jason0x43", 12 | "location": "https://raw.githubusercontent.com/jason0x43/hubitat/master/apps/jason0x43-wemo_connect.groovy", 13 | "required": true, 14 | "oauth": false, 15 | "primary": false 16 | } 17 | ], 18 | "drivers": [ 19 | { 20 | "id": "f99161ec-b817-48d8-b5f5-90a53f396577", 21 | "name": "Wemo Dimmer", 22 | "namespace": "jason0x43", 23 | "location": "https://raw.githubusercontent.com/jason0x43/hubitat/master/drivers/jason0x43-wemo_dimmer.groovy", 24 | "required": false 25 | }, 26 | { 27 | "id": "e2ff9634-3801-4560-beb9-d6cf62b2c67b", 28 | "name": "Wemo Insight", 29 | "namespace": "jason0x43", 30 | "location": "https://raw.githubusercontent.com/jason0x43/hubitat/master/drivers/jason0x43-wemo_insight_switch.groovy", 31 | "required": false 32 | }, 33 | { 34 | "id": "4436423c-765f-472f-a054-879dd4bddb19", 35 | "name": "jason0x43", 36 | "namespace": "Wemo Maker", 37 | "location": "https://raw.githubusercontent.com/jason0x43/hubitat/master/drivers/jason0x43-wemo_maker.groovy", 38 | "required": false 39 | }, 40 | { 41 | "id": "a30c8094-acbe-43c3-8a4f-39a398b01172", 42 | "name": "jason0x43", 43 | "namespace": "Wemo Motion", 44 | "location": "https://raw.githubusercontent.com/jason0x43/hubitat/master/drivers/jason0x43-wemo_motion.groovy", 45 | "required": false 46 | }, 47 | { 48 | "id": "be16fda5-f090-4cb1-86cc-006dfec3721f", 49 | "name": "jason0x43", 50 | "namespace": "Wemo Switch", 51 | "location": "https://raw.githubusercontent.com/jason0x43/hubitat/master/drivers/jason0x43-wemo_switch.groovy", 52 | "required": false 53 | } 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /repository.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Jason Cheatham", 3 | "gitHubUrl": "https://github.com/jasonx043", 4 | "packages": [ 5 | { 6 | "name": "Wemo App and Drivers", 7 | "category": "Control", 8 | "location": "https://raw.githubusercontent.com/jason0x43/hubitat/master/packageManifest-wemo.json", 9 | "description": "An app and drivers supporting Belkin Wemo devices" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('source-map-support').install(); 4 | require('dotenv-safe').config(); 5 | 6 | import program from 'commander'; 7 | 8 | import initEvents from './lib/commands/events'; 9 | import initInfo from './lib/commands/info'; 10 | import initInstall from './lib/commands/install'; 11 | import initList from './lib/commands/list'; 12 | import initLog from './lib/commands/log'; 13 | import initPull from './lib/commands/pull'; 14 | import initPush from './lib/commands/push'; 15 | import initRun from './lib/commands/run'; 16 | 17 | program.description('Interact with hubitat').option('-v, --verbose'); 18 | 19 | initEvents(program); 20 | initInfo(program); 21 | initInstall(program); 22 | initList(program); 23 | initLog(program); 24 | initPull(program); 25 | initPush(program); 26 | initRun(program); 27 | 28 | program.parse(process.argv); 29 | 30 | if (!process.argv.slice(2).length) { 31 | program.outputHelp(); 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/commands/events.ts: -------------------------------------------------------------------------------- 1 | import { CommanderStatic } from 'commander'; 2 | import Table from 'easy-table'; 3 | import { die, trim } from '../common'; 4 | import { hubitatFetch } from '../request'; 5 | 6 | // Setup cli ------------------------------------------------------------------ 7 | 8 | export default function init(program: CommanderStatic) { 9 | program 10 | .command('events ') 11 | .option('-f, --filter ', 'Filter on a string') 12 | .option('-l, --length ', 'How many records to retrieve (10)', parseInt) 13 | .description('Get events for a specific device') 14 | .action(async (id, cmd) => { 15 | try { 16 | const events = await getDeviceEvents(id, cmd); 17 | 18 | const t = new Table(); 19 | events.forEach(event => { 20 | t.cell('date', event.date, formatTimestamp); 21 | t.cell('name', event.name, Table.string); 22 | t.cell('value', event.value, Table.string); 23 | t.newRow(); 24 | }); 25 | 26 | console.log(trim(t.toString())); 27 | } catch (error) { 28 | die(error); 29 | } 30 | }); 31 | } 32 | 33 | interface Column { 34 | name: string; 35 | searchable: boolean; 36 | orderable: boolean; 37 | } 38 | 39 | /** 40 | * Retrieve a specific device 41 | */ 42 | async function getDeviceEvents( 43 | id: string, 44 | options: DeviceQueryOptions = {} 45 | ): Promise { 46 | const columns: Column[] = [ 47 | // { 48 | // name: 'ID', 49 | // searchable: false, 50 | // orderable: true 51 | // }, 52 | { 53 | name: 'NAME', 54 | searchable: true, 55 | orderable: true 56 | }, 57 | { 58 | name: 'VALUE', 59 | searchable: true, 60 | orderable: true 61 | }, 62 | // { 63 | // name: 'UNIT', 64 | // searchable: true, 65 | // orderable: true 66 | // }, 67 | // { 68 | // name: 'DESCRIPTION_TEXT', 69 | // searchable: true, 70 | // orderable: true 71 | // }, 72 | // { 73 | // name: 'SOURCE', 74 | // searchable: true, 75 | // orderable: true 76 | // }, 77 | // { 78 | // name: 'EVENT_TYPE', 79 | // searchable: true, 80 | // orderable: true 81 | // }, 82 | { 83 | name: 'DATE', 84 | searchable: true, 85 | orderable: true 86 | } 87 | ]; 88 | 89 | const params = new URLSearchParams([ 90 | ['_', String(Date.now())], 91 | 92 | ['draw', '1'], 93 | ['order[0][column]', String(columns.length - 1)], 94 | ['order[0][dir]', 'desc'], 95 | ['start', '0'], 96 | ['length', String(options.length || 10)], 97 | ['search[value]', options.filter || ''], 98 | ['search[regex]', 'false'] 99 | ]); 100 | 101 | columns.forEach((column, i) => { 102 | params.set(`columns[${i}][data]`, String(i)); 103 | Object.keys(column).forEach(key => { 104 | const colKey = key; 105 | params.set(`columns[${i}][${key}]`, String(column[colKey])); 106 | }); 107 | }); 108 | 109 | const eventResponse = await hubitatFetch( 110 | `/device/events/${id}/dataTablesJson?${params.toString()}` 111 | ); 112 | const eventObjs: DeviceEventsResponse = await eventResponse.json(); 113 | 114 | return eventObjs.data.map(event => { 115 | const [id, name, value, unit, , source, , date] = event; 116 | return { 117 | id, 118 | name, 119 | value, 120 | unit, 121 | source, 122 | date: new Date(date) 123 | }; 124 | }); 125 | } 126 | 127 | function formatTimestamp(time: Date, _width: number) { 128 | const year = time.getFullYear(); 129 | const month = padValue(time.getMonth() + 1); 130 | const day = padValue(time.getDate()); 131 | const hour = padValue(time.getHours()); 132 | const minute = padValue(time.getMinutes()); 133 | const second = padValue(time.getSeconds()); 134 | return `${year}-${month}-${day} ${hour}:${minute}:${second}`; 135 | } 136 | 137 | function padValue(num: number) { 138 | return String(num).padStart(2, '0'); 139 | } 140 | 141 | interface DeviceEvent { 142 | id: number; 143 | name: string; 144 | value: string; 145 | unit: string | null; 146 | date: Date; 147 | source: string; 148 | } 149 | 150 | interface DeviceEventsResponse { 151 | draw: number; 152 | recordsTotal: number; 153 | recordsFiltered: number; 154 | data: [number, string, string, null, string, string, string, string][]; 155 | } 156 | 157 | interface DeviceQueryOptions { 158 | filter?: string; 159 | length?: number 160 | } 161 | -------------------------------------------------------------------------------- /src/lib/commands/info.ts: -------------------------------------------------------------------------------- 1 | import { CommanderStatic } from 'commander'; 2 | import { die } from '../common'; 3 | import { makerFetch } from '../request'; 4 | 5 | // Setup cli ------------------------------------------------------------------ 6 | 7 | export default function init(program: CommanderStatic) { 8 | program 9 | .command('info ') 10 | .description('Show information about a specific device') 11 | .action(async id => { 12 | try { 13 | const dev = await getDevice(id); 14 | console.log(dev); 15 | } catch (error) { 16 | die(error); 17 | } 18 | }); 19 | } 20 | 21 | /** 22 | * Retrieve a specific device 23 | */ 24 | async function getDevice(id: number): Promise { 25 | const infoResponse = await makerFetch(`/devices/${id}`); 26 | const infoObj: MakerDeviceInfo = await infoResponse.json(); 27 | 28 | const states: DeviceInfo['states'] = {}; 29 | infoObj.attributes.forEach(attr => { 30 | const { name, currentValue } = attr; 31 | states[name] = currentValue; 32 | }); 33 | 34 | const commandsResponse = await makerFetch(`/devices/${id}/commands`); 35 | const commandsObj: MakerCommand[] = await commandsResponse.json(); 36 | const commands: { [name: string]: string[] } = {}; 37 | commandsObj.forEach(cmd => { 38 | const name = cmd.command; 39 | commands[name] = cmd.type.filter(t => t != 'n/a'); 40 | }); 41 | 42 | return { name: infoObj.label, states, commands }; 43 | } 44 | 45 | interface DeviceInfo { 46 | name: string; 47 | states: { 48 | [name: string]: string | number | null; 49 | }; 50 | commands: { 51 | [name: string]: string[]; 52 | }; 53 | } 54 | 55 | interface MakerDeviceInfo { 56 | id: string; 57 | name: string; 58 | label: string; 59 | attributes: ( 60 | | MakerNumberAttribute 61 | | MakerEnumAttribute 62 | | MakerStringAttribute)[]; 63 | capabilities: (string | MakerCapabilityAttribute)[]; 64 | commands: string[]; 65 | } 66 | 67 | interface MakerAttribute { 68 | name: string; 69 | currentValue: T; 70 | } 71 | 72 | interface MakerStringAttribute extends MakerAttribute { 73 | dataType: 'STRING'; 74 | } 75 | 76 | interface MakerNumberAttribute extends MakerAttribute { 77 | dataType: 'NUMBER'; 78 | } 79 | 80 | interface MakerEnumAttribute extends MakerAttribute { 81 | dataType: 'ENUM'; 82 | values: string[]; 83 | } 84 | 85 | interface MakerCapabilityAttribute { 86 | name: string; 87 | dataType: null; 88 | } 89 | 90 | interface MakerCommand { 91 | command: string; 92 | type: string[]; 93 | } 94 | -------------------------------------------------------------------------------- /src/lib/commands/install.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, promises } from 'fs'; 2 | import { join } from 'path'; 3 | import { createHash } from 'crypto'; 4 | import cheerio from 'cheerio'; 5 | import { execSync } from 'child_process'; 6 | import { CommanderStatic } from 'commander'; 7 | 8 | import { die, simpleEncode } from '../common'; 9 | import { loadManifest, saveManifest, toManifestEntry } from '../manifest'; 10 | import { hubitatFetch } from '../request'; 11 | import { ResourceType, getFilename, getResources } from '../resource'; 12 | 13 | const { mkdir, readFile } = promises; 14 | const repoDir = '.repos'; 15 | 16 | // Setup cli ------------------------------------------------------------------ 17 | 18 | export default function init(program: CommanderStatic) { 19 | // Install a script from a github repo 20 | program 21 | .command('install ') 22 | .description( 23 | 'Install a resource from a GitHub path ' + 24 | '(git:org/repo/file.groovy) or local file path' 25 | ) 26 | .action(async (type, path) => { 27 | if (!/[^/]+\/.*\.groovy$/.test(path)) { 28 | die('path must have format org/repo/path/to/file.groovy'); 29 | } 30 | 31 | let filename: string; 32 | let isGithubResource = false; 33 | 34 | if (/^git:/.test(path)) { 35 | isGithubResource = true; 36 | const gitPath = path.slice(4); 37 | const parts = gitPath.split('/'); 38 | const orgPath = join(repoDir, parts[0]); 39 | await mkdirp(orgPath); 40 | 41 | const repoPath = join(orgPath, parts[1]); 42 | if (!existsSync(repoPath)) { 43 | const repo = parts.slice(0, 2).join('/'); 44 | execSync(`git clone git@github.com:${repo}`, { cwd: orgPath }); 45 | } 46 | 47 | filename = join(repoDir, gitPath); 48 | } else { 49 | filename = path; 50 | } 51 | 52 | try { 53 | const rtype = validateCodeType(type); 54 | const localManifest = await loadManifest(); 55 | 56 | console.log(`Installing ${filename}...`); 57 | await createRemoteResource( 58 | rtype, 59 | filename, 60 | localManifest, 61 | isGithubResource 62 | ); 63 | saveManifest(localManifest); 64 | } catch (error) { 65 | die(error); 66 | } 67 | }); 68 | } 69 | 70 | // Implementation ------------------------------------------------------------- 71 | 72 | /** 73 | * Create a remote resource. This should return a new version number which will 74 | * be added to the manifest. 75 | */ 76 | async function createRemoteResource( 77 | type: CodeResourceType, 78 | filename: string, 79 | localManifest: Manifest, 80 | isGithubResource = false 81 | ): Promise { 82 | const source = await readFile(filename, { 83 | encoding: 'utf8' 84 | }); 85 | 86 | const hash = hashSource(source); 87 | console.log(`Creating ${type} ${filename}...`); 88 | const newRes = await postResource(type, source); 89 | let newEntry: ManifestEntry; 90 | 91 | if (isGithubResource) { 92 | newEntry = { 93 | hash, 94 | filename, 95 | id: newRes.id, 96 | version: 1 97 | }; 98 | } else { 99 | const resources = await getResources(type); 100 | const resource = resources.find(res => res.id === newRes.id)!; 101 | newEntry = { 102 | hash, 103 | filename: getFilename(resource), 104 | ...newRes 105 | }; 106 | } 107 | 108 | localManifest[type][newRes.id] = toManifestEntry(newEntry); 109 | 110 | return true; 111 | } 112 | 113 | /** 114 | * Create a specific resource (driver or app) 115 | */ 116 | async function postResource( 117 | type: ResourceType, 118 | source: string 119 | ): Promise { 120 | const path = `/${type}/save`; 121 | const response = await hubitatFetch(path, { 122 | method: 'POST', 123 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 124 | body: simpleEncode({ id: '', version: '', source }) 125 | }); 126 | 127 | if (response.status !== 200) { 128 | throw new Error(`Error creating ${type}: ${response.statusText}`); 129 | } 130 | 131 | const html = await response.text(); 132 | const $ = cheerio.load(html); 133 | 134 | const errors = $('#errors'); 135 | const errorText = errors.text().replace(/×/, '').trim(); 136 | if (errorText) { 137 | throw new Error(`Error creating ${type}: ${errorText}`); 138 | } 139 | 140 | const form = $('form[name="editForm"]'); 141 | const id = $(form) 142 | .find('input[name="id"]') 143 | .val(); 144 | const version = $(form) 145 | .find('input[name="version"]') 146 | .val(); 147 | 148 | return { 149 | id: Number(id), 150 | version: Number(version) 151 | }; 152 | } 153 | 154 | /** 155 | * Make a directory and its parents 156 | */ 157 | async function mkdirp(dir: string) { 158 | const parts = dir.split('/'); 159 | let path = '.'; 160 | while (parts.length > 0) { 161 | path = join(path, parts.shift()!); 162 | if (!existsSync(path)) { 163 | await mkdir(path); 164 | } 165 | } 166 | } 167 | 168 | /** 169 | * Generate a SHA512 hash of a source string 170 | */ 171 | function hashSource(source: string) { 172 | const hash = createHash('sha512'); 173 | hash.update(source); 174 | return hash.digest('hex'); 175 | } 176 | 177 | function validateCodeType(type: string): CodeResourceType { 178 | if (/apps?/.test(type)) { 179 | return 'app'; 180 | } 181 | if (/drivers?/.test(type)) { 182 | return 'driver'; 183 | } 184 | 185 | die(`Invalid type "${type}"`); 186 | return ''; 187 | } 188 | 189 | interface Manifest { 190 | app: ManifestResources; 191 | driver: ManifestResources; 192 | } 193 | 194 | interface ManifestResources { 195 | [id: number]: ManifestEntry; 196 | } 197 | 198 | interface ManifestEntry { 199 | id: number; 200 | filename: string; 201 | version: number; 202 | hash: string; 203 | } 204 | 205 | type CodeResourceType = 'app' | 'driver'; 206 | 207 | interface CreateResource { 208 | id: number; 209 | version: number; 210 | } 211 | -------------------------------------------------------------------------------- /src/lib/commands/list.ts: -------------------------------------------------------------------------------- 1 | import { CommanderStatic } from 'commander'; 2 | import Table from 'easy-table'; 3 | 4 | import { trim } from '../common'; 5 | import { makerFetch } from '../request'; 6 | import { 7 | validateType, 8 | ResourceType, 9 | Resource, 10 | InstalledResource, 11 | DeviceResource, 12 | CodeResource, 13 | getResources 14 | } from '../resource'; 15 | 16 | // Setup cli ------------------------------------------------------------------ 17 | 18 | export default function init(program: CommanderStatic) { 19 | program 20 | .command('list ') 21 | .description(`List drivers, apps, or devices on Hubitat`) 22 | .action(async type => { 23 | try { 24 | const rtype = validateType(type); 25 | const t = new Table(); 26 | 27 | if (rtype === 'driver') { 28 | const drivers = await listResources(rtype); 29 | drivers.forEach(driver => 30 | addCodeRow(t, driver, type ? undefined : rtype) 31 | ); 32 | } else if (rtype === 'app') { 33 | const apps = await listResources(rtype); 34 | apps.forEach(app => addCodeRow(t, app, type ? undefined : rtype)); 35 | } else if (rtype === 'installedapp') { 36 | const apps = await listResources(rtype); 37 | apps.forEach(app => addInstalledRow(t, app)); 38 | } else { 39 | const devices = await listDevices(); 40 | devices.forEach(dev => addDeviceRow(t, dev)); 41 | } 42 | 43 | console.log(trim(t.toString())); 44 | } catch (error) { 45 | console.error(error); 46 | } 47 | }); 48 | 49 | function addCodeRow(t: Table, resource: CodeResource, type?: ResourceType) { 50 | if (type) { 51 | t.cell('type', type); 52 | } 53 | t.cell('id', resource.id, Table.number()); 54 | t.cell('name', resource.name, Table.string); 55 | t.newRow(); 56 | } 57 | 58 | function addInstalledRow(t: Table, resource: InstalledResource) { 59 | t.cell('id', resource.id, Table.number()); 60 | t.cell('name', resource.name, Table.string); 61 | t.cell('app', resource.app, Table.string); 62 | t.newRow(); 63 | } 64 | 65 | function addDeviceRow(t: Table, resource: DeviceResource) { 66 | t.cell('id', resource.id, Table.number()); 67 | t.cell('name', resource.name, Table.string); 68 | t.cell('driver', resource.driver, Table.string); 69 | t.newRow(); 70 | } 71 | } 72 | 73 | // Implementation ------------------------------------------------------------- 74 | 75 | /** 76 | * Get a resource list from Hubitat 77 | */ 78 | async function listResources( 79 | resource: 'installedapp' 80 | ): Promise; 81 | async function listResources(resource: ResourceType): Promise; 82 | async function listResources(resource: ResourceType): Promise { 83 | return await getResources(resource); 84 | } 85 | 86 | /** 87 | * Get a device list using the Maker API 88 | */ 89 | async function listDevices(): Promise { 90 | const devResponse = await makerFetch(`/devices`); 91 | const devObj: MakerDevice[] = await devResponse.json(); 92 | return devObj.map(obj => ({ 93 | id: Number(obj.id), 94 | name: obj.label, 95 | type: 'driver', 96 | driver: obj.name 97 | })); 98 | } 99 | 100 | interface MakerDevice { 101 | // The device ID 102 | id: string; 103 | // The device type name 104 | name: string; 105 | // The device name 106 | label: string; 107 | } 108 | -------------------------------------------------------------------------------- /src/lib/commands/log.ts: -------------------------------------------------------------------------------- 1 | import { XmlEntities } from 'html-entities'; 2 | import { CommanderStatic } from 'commander'; 3 | import WebSocket from 'ws'; 4 | 5 | import { die } from '../common'; 6 | import { getHost } from '../request'; 7 | import { validateId } from '../resource'; 8 | 9 | // Setup cli ------------------------------------------------------------------ 10 | 11 | export default function init(program: CommanderStatic) { 12 | program 13 | .command('log [type] [id]') 14 | .description( 15 | 'Log events for a given source, type of source, or all sources' 16 | ) 17 | .action((type: string, id?: string) => { 18 | const _type = validateType(type); 19 | const _id = validateId(id); 20 | 21 | if (_id && !_type) { 22 | die('An ID requires a type'); 23 | } 24 | 25 | const ws = new WebSocket(`ws://${getHost()}/logsocket`); 26 | const entities = new XmlEntities(); 27 | 28 | ws.on('open', () => { 29 | console.log('Opened connection to Hubitat'); 30 | }); 31 | 32 | ws.on('message', (data: string) => { 33 | const msg: Message = JSON.parse(data); 34 | if (_type && msg.type !== _type) { 35 | return; 36 | } 37 | if (_id && msg.id !== _id) { 38 | return; 39 | } 40 | logMessage(entities, msg); 41 | }); 42 | }); 43 | } 44 | 45 | function logMessage(entities: TextConverter, message: Message) { 46 | const { time, type, msg, level, id, name } = message; 47 | console.log( 48 | `${time} [${type}:${id}] (${level}) ${name} - ${entities.decode(msg)}` 49 | ); 50 | } 51 | 52 | function validateType(type: string): 'app' | 'dev' { 53 | if (/apps?/.test(type)) { 54 | return 'app'; 55 | } 56 | if (/dev(ices)?/.test(type)) { 57 | return 'dev'; 58 | } 59 | die('Type should be "app" or "dev"'); 60 | return ''; 61 | } 62 | 63 | interface Message { 64 | name: string; 65 | type: string; 66 | level: string; 67 | time: string; 68 | id: number; 69 | msg: string; 70 | } 71 | 72 | interface TextConverter { 73 | decode(input: string): string; 74 | } 75 | -------------------------------------------------------------------------------- /src/lib/commands/pull.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync, writeFileSync } from 'fs'; 2 | import { basename, join } from 'path'; 3 | import { execSync } from 'child_process'; 4 | 5 | import { die } from '../common'; 6 | import { Logger, createLogger } from '../log'; 7 | import { 8 | Manifest, 9 | getRemoteManifest, 10 | loadManifest, 11 | saveManifest, 12 | toManifestEntry 13 | } from '../manifest'; 14 | import { getResource, hashSource, resourceDirs, validateId } from '../resource'; 15 | import { CommanderStatic } from 'commander'; 16 | 17 | const repoDir = '.repos'; 18 | 19 | let log: Logger; 20 | 21 | // Setup cli ------------------------------------------------------------------ 22 | 23 | export default function init(program: CommanderStatic) { 24 | log = createLogger(program); 25 | 26 | // Pull a specific resource from Hubitat 27 | program 28 | .command('pull [type] [id]') 29 | .description('Pull drivers and apps from Hubitat to this host') 30 | .action(async (type, id) => { 31 | try { 32 | let rtype: CodeResourceType | undefined; 33 | if (type) { 34 | rtype = validateCodeType(type); 35 | } 36 | if (id) { 37 | validateId(id); 38 | } 39 | 40 | // The remote manifest will be used for filenames (if files don't exist 41 | // locally) 42 | const remoteManifest = await getRemoteManifest(); 43 | const localManifest = await loadManifest(); 44 | 45 | if (!rtype) { 46 | console.log('Pulling everything...'); 47 | await Promise.all( 48 | ['app', 'driver'].map(async typeStr => { 49 | const type = typeStr; 50 | await Promise.all( 51 | Object.keys(remoteManifest[type]).map(async id => { 52 | await updateLocalResource( 53 | type, 54 | Number(id), 55 | localManifest, 56 | remoteManifest 57 | ); 58 | }) 59 | ); 60 | }) 61 | ); 62 | } else if (!id) { 63 | console.log(`Pulling all ${rtype}s...`); 64 | await Promise.all( 65 | Object.keys(remoteManifest[rtype]).map(async id => { 66 | updateLocalResource( 67 | rtype!, 68 | Number(id), 69 | localManifest, 70 | remoteManifest 71 | ); 72 | }) 73 | ); 74 | } else { 75 | console.log(`Pulling ${type}:${id}...`); 76 | updateLocalResource(type, id, localManifest, remoteManifest); 77 | } 78 | 79 | await saveManifest(localManifest); 80 | } catch (error) { 81 | die(error); 82 | } 83 | }); 84 | } 85 | 86 | // Implementation ------------------------------------------------------------- 87 | 88 | /** 89 | * Update a local resource with a remote resource. This saves a local copy of 90 | * the resource and updates the manifest. If the remote resource is newer than 91 | * the local resource, it will overwrite the local resource. If the local 92 | * resource has been edited, it will need to be committed before a pull can 93 | * complete. 94 | * 95 | * After a pull, any files that differ between the remote and local will result 96 | * in unstaged changes in the local repo. 97 | */ 98 | async function updateLocalResource( 99 | type: CodeResourceType, 100 | id: number, 101 | localManifest: Manifest, 102 | remoteManifest: Manifest 103 | ): Promise { 104 | const resource = await getResource(type, id); 105 | const localRes = localManifest[type][resource.id]; 106 | 107 | if (!localRes) { 108 | console.log(`No local resource for ${type} ${resource.id}`); 109 | return false; 110 | } 111 | 112 | if (localRes.filename.indexOf(repoDir) === 0) { 113 | console.log(`Skipping github resource ${basename(localRes.filename)}`); 114 | return false; 115 | } 116 | 117 | const remoteRes = remoteManifest[type][resource.id]; 118 | const filename = join(resourceDirs[type], remoteRes.filename); 119 | 120 | if (localRes) { 121 | const source = readFileSync(filename, { encoding: 'utf8' }); 122 | const sourceHash = hashSource(source); 123 | // If the local has changed from the last time it was synced with Hubitat 124 | // *and* it hasn't been committed, don't update 125 | if (sourceHash !== localRes.hash && needsCommit(filename)) { 126 | console.log(`Skipping ${filename}; please commit first`); 127 | return false; 128 | } 129 | } 130 | 131 | if (localRes && remoteRes.hash === localRes.hash) { 132 | log(`Skipping ${filename}; no changes`); 133 | return true; 134 | } 135 | 136 | console.log(`Updating ${type} ${filename}`); 137 | writeFileSync(join(resourceDirs[type], remoteRes.filename), resource.source); 138 | 139 | const hash = hashSource(resource.source); 140 | const newResource = { type, hash, filename: remoteRes.filename, ...resource }; 141 | localManifest[type][resource.id] = toManifestEntry(newResource); 142 | 143 | return true; 144 | } 145 | 146 | /** 147 | * Indicate whether a file has uncommitted changes 148 | */ 149 | function needsCommit(file: string) { 150 | if (!existsSync(file)) { 151 | return false; 152 | } 153 | return execSync(`git status --short ${file}`, { encoding: 'utf8' }) !== ''; 154 | } 155 | 156 | function validateCodeType(type: string): CodeResourceType { 157 | if (/apps?/.test(type)) { 158 | return 'app'; 159 | } 160 | if (/drivers?/.test(type)) { 161 | return 'driver'; 162 | } 163 | 164 | die(`Invalid type "${type}"`); 165 | return ''; 166 | } 167 | 168 | type CodeResourceType = 'app' | 'driver'; 169 | -------------------------------------------------------------------------------- /src/lib/commands/push.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { basename, join } from 'path'; 3 | import { CommanderStatic } from 'commander'; 4 | import { execSync } from 'child_process'; 5 | 6 | import { die, trim } from '../common'; 7 | import { Logger, createLogger } from '../log'; 8 | import { 9 | Manifest, 10 | loadManifest, 11 | saveManifest, 12 | toManifestEntry, 13 | toManifestSection 14 | } from '../manifest'; 15 | import { 16 | getFileResources, 17 | hashSource, 18 | putResource, 19 | resourceDirs, 20 | validateId 21 | } from '../resource'; 22 | 23 | const repoDir = '.repos'; 24 | 25 | let log: Logger; 26 | 27 | // Setup cli ------------------------------------------------------------------ 28 | 29 | export default function init(program: CommanderStatic) { 30 | log = createLogger(program); 31 | 32 | // Push a specific resource to Hubitat 33 | program 34 | .command('push [type] [id]') 35 | .description('Push apps and drivers from this host to Hubitat') 36 | .action(async (type, id) => { 37 | let rtype: keyof Manifest | undefined; 38 | 39 | try { 40 | if (type) { 41 | rtype = validateCodeType(type); 42 | } 43 | if (id) { 44 | validateId(id); 45 | } 46 | 47 | const remoteManifest = await getRemoteManifest(); 48 | const localManifest = await loadManifest(); 49 | 50 | if (!rtype) { 51 | console.log('Pushing everything...'); 52 | await Promise.all( 53 | ['app', 'driver'].map(async typeStr => { 54 | const type = typeStr; 55 | await Promise.all( 56 | Object.keys(localManifest[type]).map(async id => { 57 | await updateRemoteResource( 58 | type, 59 | Number(id), 60 | localManifest, 61 | remoteManifest 62 | ); 63 | }) 64 | ); 65 | }) 66 | ); 67 | } else if (!id) { 68 | console.log(`Pushing all ${rtype}...`); 69 | await Promise.all( 70 | Object.keys(localManifest[rtype]).map(async id => { 71 | await updateRemoteResource( 72 | rtype!, 73 | Number(id), 74 | localManifest, 75 | remoteManifest 76 | ); 77 | }) 78 | ); 79 | } else { 80 | console.log(`Pushing ${type}:${id}...`); 81 | await updateRemoteResource(type, id, localManifest, remoteManifest); 82 | } 83 | 84 | await saveManifest(localManifest); 85 | } catch (error) { 86 | die(error); 87 | } 88 | }); 89 | } 90 | 91 | // Implementation ------------------------------------------------------------- 92 | 93 | /** 94 | * Get a manifest of resources available on the Hubitat 95 | */ 96 | async function getRemoteManifest(type?: CodeResourceType): Promise { 97 | const manifest: Manifest = { 98 | app: {}, 99 | driver: {} 100 | }; 101 | 102 | if (type) { 103 | console.log(`Loading remote manifest for ${type}s...`); 104 | const resources = await getFileResources(type); 105 | manifest[type] = toManifestSection(resources); 106 | console.log(`Loaded ${Object.keys(resources).length} entries`); 107 | } else { 108 | console.log('Loading remote manifest for all resource types...'); 109 | const apps = await getFileResources('app'); 110 | const numApps = Object.keys(apps).length; 111 | manifest.app = toManifestSection(apps); 112 | console.log(`Loaded ${numApps} app${numApps === 1 ? '' : 's'}`); 113 | 114 | const drivers = await getFileResources('driver'); 115 | const numDrivers = Object.keys(drivers).length; 116 | manifest.driver = toManifestSection(drivers); 117 | console.log(`Loaded ${numDrivers} driver${numDrivers === 1 ? '' : 's'}`); 118 | } 119 | 120 | return manifest; 121 | } 122 | 123 | /** 124 | * Update a remote resource. This should return a new version number which will 125 | * be added to the manifest. 126 | */ 127 | async function updateRemoteResource( 128 | type: CodeResourceType, 129 | id: number, 130 | localManifest: Manifest, 131 | remoteManifest: Manifest 132 | ): Promise { 133 | const localRes = localManifest[type][id]; 134 | const remoteRes = remoteManifest[type][id]; 135 | const filename = localRes.filename; 136 | const name = basename(filename); 137 | let source: string; 138 | 139 | if (!remoteManifest[type][id]) { 140 | const capType = `${type[0].toUpperCase()}${type.slice(1)}`; 141 | console.error( 142 | `${capType} ${name} does not exist in remote manifest; ignoring` 143 | ); 144 | return false; 145 | } 146 | 147 | try { 148 | if (filename.indexOf(repoDir) === 0) { 149 | const repo = join(...filename.split('/').slice(0, 3)); 150 | execSync('git pull', { cwd: repo }); 151 | source = readFileSync(filename, { 152 | encoding: 'utf8' 153 | }); 154 | // The local version is irrelevant for repo-based resources 155 | localRes.version = remoteRes.version; 156 | } else { 157 | source = readFileSync(join(resourceDirs[type], filename), { 158 | encoding: 'utf8' 159 | }); 160 | } 161 | 162 | const hash = hashSource(source); 163 | if (hash === localRes.hash) { 164 | // File hasn't changed -- don't push 165 | log(`${filename} hasn't changed; not pushing`); 166 | return true; 167 | } 168 | 169 | if (localRes.version !== remoteRes.version) { 170 | console.error(`${type} ${filename} is out of date; pull first`); 171 | return false; 172 | } 173 | 174 | console.log(`Pushing ${type} ${filename}...`); 175 | const res = await putResource(type, id, localRes.version, source); 176 | if (res.status === 'error') { 177 | console.log(res); 178 | console.error( 179 | `Error pushing ${type} ${filename}: ${trim(res.errorMessage)}` 180 | ); 181 | return false; 182 | } 183 | 184 | const newResource = { 185 | hash, 186 | filename, 187 | id: res.id, 188 | version: res.version 189 | }; 190 | localManifest[type][res.id] = toManifestEntry(newResource); 191 | } catch (error) { 192 | if (error.code === 'ENOENT') { 193 | console.log(`No local script ${filename}`); 194 | // console.log(`No local script ${filename}, removing from manifest`); 195 | // delete localManifest[type][id]; 196 | } else { 197 | console.error(error); 198 | } 199 | } 200 | 201 | return true; 202 | } 203 | 204 | function validateCodeType(type: string): CodeResourceType { 205 | if (/apps?/.test(type)) { 206 | return 'app'; 207 | } 208 | if (/drivers?/.test(type)) { 209 | return 'driver'; 210 | } 211 | 212 | die(`Invalid type "${type}"`); 213 | return ''; 214 | } 215 | 216 | type CodeResourceType = 'app' | 'driver'; 217 | -------------------------------------------------------------------------------- /src/lib/commands/run.ts: -------------------------------------------------------------------------------- 1 | import { CommanderStatic } from 'commander'; 2 | 3 | import { die, simpleEncode } from '../common'; 4 | import { hubitatFetch } from '../request'; 5 | import { validateId } from '../resource'; 6 | 7 | // Setup cli ------------------------------------------------------------------ 8 | 9 | export default function init(program: CommanderStatic) { 10 | program 11 | .command('run [args...]') 12 | .description('Run a command on a device') 13 | .action(async (id: string, command: string, args: string[]) => { 14 | try { 15 | const _id = validateId(id)!; 16 | await runCommand(_id, command, args); 17 | } catch (error) { 18 | die(error); 19 | } 20 | }); 21 | } 22 | 23 | async function runCommand(id: number, command: string, args: string[]) { 24 | const body: { [name: string]: string } = { id: String(id), method: command }; 25 | if (args) { 26 | args.forEach((arg, i) => { 27 | body[`arg[${i + 1}]`] = arg; 28 | if (!isNaN(Number(arg))) { 29 | body[`argType.${i + 1}`] = 'NUMBER'; 30 | } 31 | }); 32 | } 33 | 34 | const response = await hubitatFetch(`/device/runmethod`, { 35 | method: 'POST', 36 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 37 | body: simpleEncode(body) 38 | }); 39 | 40 | if (response.status !== 200) { 41 | throw new Error(`Error running command: ${response.statusText}`); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Display a message and quit 3 | */ 4 | export function die(message: string | Error) { 5 | if (typeof message === 'string') { 6 | console.error(`\n error: ${message}\n`); 7 | } else { 8 | const lines = message.stack!.split('\n'); 9 | console.error(`\n ${lines.join('\n ')}\n`); 10 | } 11 | process.exit(1); 12 | } 13 | 14 | /** 15 | * Encode a JS object to x-www-form-urlencoded format 16 | */ 17 | export function simpleEncode(value: any, key?: string, list?: string[]) { 18 | list = list || []; 19 | if (typeof value === 'object') { 20 | for (let k in value) { 21 | simpleEncode(value[k], key ? `${key}[${k}]` : k, list); 22 | } 23 | } else { 24 | list.push(`${key}=${encodeURIComponent(value)}`); 25 | } 26 | return list.join('&'); 27 | } 28 | 29 | /** 30 | * Trim whitespace from either end of a string 31 | */ 32 | export function trim(str?: string) { 33 | if (!str) { 34 | return str; 35 | } 36 | return str.replace(/^\s+/, '').replace(/\s+$/, ''); 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/log.ts: -------------------------------------------------------------------------------- 1 | import { CommanderStatic } from 'commander'; 2 | 3 | /** 4 | * Debug log 5 | */ 6 | export function createLogger(program: CommanderStatic): Logger { 7 | if (program.verbose) { 8 | return console.log; 9 | } else { 10 | return (..._args: any[]) => {}; 11 | } 12 | } 13 | 14 | export interface Logger { 15 | (...args: any[]): void; 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/manifest.ts: -------------------------------------------------------------------------------- 1 | import { promises } from 'fs'; 2 | import { join } from 'path'; 3 | import { CodeResourceType, FileResource, getFileResources } from './resource'; 4 | 5 | const { readFile, writeFile } = promises; 6 | const manifestFile = join(__dirname, '..', '..', 'manifest.json'); 7 | 8 | /** 9 | * Get a manifest of resources available on the Hubitat 10 | */ 11 | export async function getRemoteManifest( 12 | type?: CodeResourceType 13 | ): Promise { 14 | const manifest: Manifest = { 15 | app: {}, 16 | driver: {} 17 | }; 18 | 19 | if (type) { 20 | console.log(`Loading remote manifest for ${type}s...`); 21 | const resources = await getFileResources(type); 22 | manifest[type] = toManifestSection(resources); 23 | console.log(`Loaded ${Object.keys(resources).length} entries`); 24 | } else { 25 | console.log('Loading remote manifest for all resource types...'); 26 | const apps = await getFileResources('app'); 27 | const numApps = Object.keys(apps).length; 28 | manifest.app = toManifestSection(apps); 29 | console.log(`Loaded ${numApps} app${numApps === 1 ? '' : 's'}`); 30 | 31 | const drivers = await getFileResources('driver'); 32 | const numDrivers = Object.keys(drivers).length; 33 | manifest.driver = toManifestSection(drivers); 34 | console.log(`Loaded ${numDrivers} driver${numDrivers === 1 ? '' : 's'}`); 35 | } 36 | 37 | return manifest; 38 | } 39 | 40 | /** 41 | * Load the current manifest file 42 | */ 43 | export async function loadManifest(): Promise { 44 | try { 45 | const data = await readFile(manifestFile, { encoding: 'utf8' }); 46 | if (data) { 47 | return JSON.parse(data); 48 | } 49 | } catch (error) { 50 | if (error.code !== 'ENOENT') { 51 | throw error; 52 | } 53 | } 54 | 55 | return { 56 | app: {}, 57 | driver: {} 58 | }; 59 | } 60 | 61 | /** 62 | * Save the given manifest, overwriting the current manifest 63 | */ 64 | export async function saveManifest(manifest: Manifest) { 65 | return await writeFile(manifestFile, JSON.stringify(manifest, null, ' ')); 66 | } 67 | 68 | /** 69 | * Create a manifest entry representing a FileResource 70 | */ 71 | export function toManifestEntry(resource: ManifestEntry) { 72 | return { 73 | filename: resource.filename, 74 | id: resource.id, 75 | version: resource.version, 76 | hash: resource.hash 77 | }; 78 | } 79 | 80 | /** 81 | * Create a manifest section representing an array of FileResources 82 | */ 83 | export function toManifestSection(resources: FileResource[]) { 84 | return resources.reduce( 85 | (all, res) => ({ 86 | ...all, 87 | [res.id]: toManifestEntry(res) 88 | }), 89 | {} 90 | ); 91 | } 92 | 93 | export interface Manifest { 94 | app: ManifestResources; 95 | driver: ManifestResources; 96 | } 97 | 98 | export interface ManifestResources { 99 | [id: number]: ManifestEntry; 100 | } 101 | 102 | export interface ManifestEntry { 103 | id: number; 104 | filename: string; 105 | version: number; 106 | hash: string; 107 | } 108 | -------------------------------------------------------------------------------- /src/lib/request.ts: -------------------------------------------------------------------------------- 1 | import fetch, { RequestInit } from 'node-fetch'; 2 | 3 | const hubitatHost = process.env.HUBITAT_HOST!; 4 | const makerApiId = process.env.MAKER_API_ID!; 5 | const makerApiToken = process.env.MAKER_API_TOKEN!; 6 | 7 | export function hubitatFetch(path: string, init?: RequestInit) { 8 | return fetch(`http://${hubitatHost}${path}`, init); 9 | } 10 | 11 | export function makerFetch(path: string) { 12 | return fetch( 13 | `http://${hubitatHost}/apps/api/${makerApiId}${path}?access_token=${makerApiToken}` 14 | ); 15 | } 16 | 17 | export function getHost() { 18 | return hubitatHost; 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/resource.ts: -------------------------------------------------------------------------------- 1 | import { join, relative } from 'path'; 2 | import { createHash } from 'crypto'; 3 | import * as cheerio from 'cheerio'; 4 | 5 | import { die, simpleEncode } from './common'; 6 | import { hubitatFetch } from './request'; 7 | 8 | export type CodeResourceType = 'app' | 'driver'; 9 | 10 | export const resourceDirs = { 11 | app: relative(process.cwd(), join(__dirname, '..', '..', 'apps')), 12 | driver: relative(process.cwd(), join(__dirname, '..', '..', 'drivers')) 13 | }; 14 | 15 | /** 16 | * Get a filename for a resource 17 | */ 18 | export function getFilename(resource: CodeResource) { 19 | const { name, namespace } = resource; 20 | if (!name) { 21 | throw new Error(`Empty name for ${JSON.stringify(resource)}`); 22 | } 23 | return `${namespace}-${name!.toLowerCase().replace(/\s/g, '_')}.groovy`; 24 | } 25 | 26 | /** 27 | * Get a resource list from Hubitat 28 | */ 29 | export async function getFileResources( 30 | type: CodeResourceType 31 | ): Promise { 32 | const resources = await getResources(type); 33 | 34 | return Promise.all( 35 | resources.map(async res => { 36 | const { id } = res; 37 | const filename = getFilename(res); 38 | const item = await getResource(type, Number(id)); 39 | const hash = hashSource(item.source); 40 | return { filename, hash, type, ...item }; 41 | }) 42 | ); 43 | } 44 | 45 | /** 46 | * Retrieve a specific resource (driver or app) 47 | */ 48 | export async function getResource( 49 | type: ResourceType, 50 | id: number 51 | ): Promise { 52 | const response = await hubitatFetch(`/${type}/ajax/code?id=${id}`); 53 | if (response.status !== 200) { 54 | throw new Error(`Error getting ${type} ${id}: ${response.statusText}`); 55 | } 56 | return >response.json(); 57 | } 58 | 59 | /** 60 | * Get a resource list from Hubitat 61 | */ 62 | export async function getResources(type: 'device'): Promise; 63 | export async function getResources( 64 | type: 'app' | 'driver' 65 | ): Promise; 66 | export async function getResources(type: ResourceType): Promise; 67 | export async function getResources(type: ResourceType): Promise { 68 | const response = await hubitatFetch(`/${type}/list`); 69 | const html = await response.text(); 70 | const $ = cheerio.load(html); 71 | const selector = tableSelectors[type]; 72 | const processRow = rowProcessors[type]; 73 | 74 | return $(selector) 75 | .toArray() 76 | .reduce( 77 | (allResources, elem) => [ 78 | ...allResources, 79 | ...processRow($, $(elem), type) 80 | ], 81 | [] 82 | ); 83 | } 84 | 85 | /** 86 | * Generate a SHA512 hash of a source string 87 | */ 88 | export function hashSource(source: string) { 89 | const hash = createHash('sha512'); 90 | hash.update(source); 91 | return hash.digest('hex'); 92 | } 93 | 94 | /** 95 | * Store a specific resource (driver or app) 96 | */ 97 | export async function putResource( 98 | type: ResourceType, 99 | id: number, 100 | version: number, 101 | source: string 102 | ): Promise { 103 | const response = await hubitatFetch(`/${type}/ajax/update`, { 104 | method: 'POST', 105 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 106 | body: simpleEncode({ id, version, source }) 107 | }); 108 | if (response.status !== 200) { 109 | throw new Error(`Error putting ${type} ${id}: ${response.statusText}`); 110 | } 111 | return >response.json(); 112 | } 113 | 114 | /** 115 | * Validate a code type argument. 116 | */ 117 | export function validateCodeType(type: string): CodeResourceType { 118 | if (/apps?/.test(type)) { 119 | return 'app'; 120 | } 121 | if (/drivers?/.test(type)) { 122 | return 'driver'; 123 | } 124 | 125 | die(`Invalid type "${type}"`); 126 | return ''; 127 | } 128 | 129 | /** 130 | * Validate an ID argument 131 | */ 132 | export function validateId(value?: string) { 133 | if (value == null) { 134 | return value; 135 | } 136 | const id = Number(value); 137 | if (isNaN(id)) { 138 | die('ID must be a number'); 139 | } 140 | return id; 141 | } 142 | 143 | /** 144 | * Verify that a type variable is a ResourceType 145 | */ 146 | export function validateType(type: string): ResourceType { 147 | if ('apps'.startsWith(type)) { 148 | return 'app'; 149 | } 150 | if ('drivers'.startsWith(type)) { 151 | return 'driver'; 152 | } 153 | if ('devices'.startsWith(type)) { 154 | return 'device'; 155 | } 156 | if ('installedapp'.startsWith(type)) { 157 | return 'installedapp'; 158 | } 159 | 160 | die(`Invalid type "${type}"`); 161 | return ''; 162 | } 163 | 164 | export interface Resource { 165 | id: number; 166 | name: string; 167 | type: string; 168 | } 169 | 170 | export interface CodeResource extends Resource { 171 | namespace: string; 172 | } 173 | 174 | export interface InstalledResource extends Resource { 175 | app: string; 176 | } 177 | 178 | export interface DeviceResource extends Resource { 179 | source?: SourceType; 180 | driver: string; 181 | } 182 | 183 | export type SourceType = 'System' | 'User'; 184 | 185 | export type ResourceType = 'driver' | 'app' | 'device' | 'installedapp'; 186 | 187 | export interface ResponseResource { 188 | id: number; 189 | version: number; 190 | source: string; 191 | status: string; 192 | errorMessage?: string; 193 | } 194 | 195 | export interface FileResource extends ResponseResource { 196 | filename: string; 197 | hash: string; 198 | type: ResourceType; 199 | } 200 | 201 | function processCodeRow( 202 | $: CheerioStatic, 203 | row: Cheerio, 204 | type: ResourceType 205 | ): CodeResource[] { 206 | const id = Number(row.data(`app-id`)); 207 | const link = $(row.find('td')[0]).find('a'); 208 | const name = link.text().trim(); 209 | const namespace = $(row.find('td')[1]) 210 | .text() 211 | .trim(); 212 | 213 | if (!id || !name || !namespace) { 214 | throw new Error(`Invalid row: ${row}`); 215 | } 216 | 217 | return [ 218 | { 219 | id, 220 | type, 221 | name: name!, 222 | namespace: namespace! 223 | } 224 | ]; 225 | } 226 | 227 | function processDeviceRow( 228 | $: CheerioStatic, 229 | row: Cheerio, 230 | type: ResourceType 231 | ): DeviceResource[] { 232 | const id = Number(row.data(`${type}-id`)); 233 | const name = $(row.find('td')[1]) 234 | .text() 235 | .trim() 236 | .split('\n')[0] 237 | .trim(); 238 | const driver = $(row.find('td')[2]) 239 | .text() 240 | .trim(); 241 | const source = $(row.find('td')[3]) 242 | .text() 243 | .trim(); 244 | 245 | return [ 246 | { 247 | id, 248 | driver, 249 | type, 250 | source, 251 | name: name 252 | } 253 | ]; 254 | } 255 | 256 | function processInstalledRow( 257 | $: CheerioStatic, 258 | row: Cheerio, 259 | type: ResourceType 260 | ): InstalledResource[] { 261 | const apps: InstalledResource[] = []; 262 | const nameCell = $(row.find('td')[0]); 263 | const names = nameCell.find('.app-row-link a, .app-row-link-child a'); 264 | const typeCell = $(row.find('td')[1]); 265 | const types = typeCell.find('div,li'); 266 | 267 | names.each((i, link) => { 268 | const name = $(link) 269 | .text() 270 | .trim(); 271 | const id = Number( 272 | $(link) 273 | .attr('href') 274 | .split('/')[3] 275 | ); 276 | const app = $(types[i]) 277 | .text() 278 | .trim(); 279 | apps.push({ name, id, app, type }); 280 | }); 281 | 282 | return apps; 283 | } 284 | 285 | const tableSelectors = { 286 | app: '#hubitapps-table tbody .app-row', 287 | installedapp: '#app-table tbody .app-row', 288 | driver: '#devicetype-table tbody .driver-row', 289 | device: '#device-table tbody .device-row' 290 | }; 291 | const rowProcessors = { 292 | app: processCodeRow, 293 | driver: processCodeRow, 294 | device: processDeviceRow, 295 | installedapp: processInstalledRow 296 | }; 297 | -------------------------------------------------------------------------------- /src/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubitat", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/cheerio": { 8 | "version": "0.22.11", 9 | "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.11.tgz", 10 | "integrity": "sha512-x0X3kPbholdJZng9wDMhb2swvUi3UYRNAuWAmIPIWlfgAJZp//cql/qblE7181Mg7SjWVwq6ldCPCLn5AY/e7w==", 11 | "dev": true, 12 | "requires": { 13 | "@types/node": "*" 14 | } 15 | }, 16 | "@types/easy-table": { 17 | "version": "0.0.32", 18 | "resolved": "https://registry.npmjs.org/@types/easy-table/-/easy-table-0.0.32.tgz", 19 | "integrity": "sha512-zKh0f/ixYFnr3Ldf5ZJTi1ZpnRqAynTTtVyGvWDf/TT12asE8ac98t3/WGWfFdRPp/qsccxg82C/Kl3NPNhqEw==", 20 | "dev": true 21 | }, 22 | "@types/events": { 23 | "version": "1.2.0", 24 | "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", 25 | "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==", 26 | "dev": true 27 | }, 28 | "@types/html-entities": { 29 | "version": "1.2.16", 30 | "resolved": "https://registry.npmjs.org/@types/html-entities/-/html-entities-1.2.16.tgz", 31 | "integrity": "sha512-CI6fHfFvkTtX2Nlr4JBA6yIFTfA4p9E6w9ky64X6PrfXiTALhUh/SOa+Sxvv2p87m+y5AH71lAUrx0lSYx4hKQ==", 32 | "dev": true 33 | }, 34 | "@types/node": { 35 | "version": "11.13.10", 36 | "resolved": "https://registry.npmjs.org/@types/node/-/node-11.13.10.tgz", 37 | "integrity": "sha512-leUNzbFTMX94TWaIKz8N15Chu55F9QSH+INKayQr5xpkasBQBRF3qQXfo3/dOnMU/dEIit+Y/SU8HyOjq++GwA==" 38 | }, 39 | "@types/node-fetch": { 40 | "version": "2.3.3", 41 | "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.3.3.tgz", 42 | "integrity": "sha512-MIplfRxrDTsIbOLGyFqNWTmxho5Fs710Kul35tEcaqkx9He86mGbSCDvILL0LCMfmm+oJ8tDg51crE9+pJGgiQ==", 43 | "dev": true, 44 | "requires": { 45 | "@types/node": "*" 46 | } 47 | }, 48 | "@types/ws": { 49 | "version": "6.0.1", 50 | "resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.1.tgz", 51 | "integrity": "sha512-EzH8k1gyZ4xih/MaZTXwT2xOkPiIMSrhQ9b8wrlX88L0T02eYsddatQlwVFlEPyEqV0ChpdpNnE51QPH6NVT4Q==", 52 | "dev": true, 53 | "requires": { 54 | "@types/events": "*", 55 | "@types/node": "*" 56 | } 57 | }, 58 | "ansi-regex": { 59 | "version": "3.0.0", 60 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", 61 | "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" 62 | }, 63 | "async-limiter": { 64 | "version": "1.0.0", 65 | "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", 66 | "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" 67 | }, 68 | "boolbase": { 69 | "version": "1.0.0", 70 | "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", 71 | "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" 72 | }, 73 | "buffer-from": { 74 | "version": "1.1.1", 75 | "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", 76 | "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" 77 | }, 78 | "cheerio": { 79 | "version": "1.0.0-rc.3", 80 | "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.3.tgz", 81 | "integrity": "sha512-0td5ijfUPuubwLUu0OBoe98gZj8C/AA+RW3v67GPlGOrvxWjZmBXiBCRU+I8VEiNyJzjth40POfHiz2RB3gImA==", 82 | "requires": { 83 | "css-select": "~1.2.0", 84 | "dom-serializer": "~0.1.1", 85 | "entities": "~1.1.1", 86 | "htmlparser2": "^3.9.1", 87 | "lodash": "^4.15.0", 88 | "parse5": "^3.0.1" 89 | } 90 | }, 91 | "clone": { 92 | "version": "1.0.4", 93 | "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", 94 | "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", 95 | "optional": true 96 | }, 97 | "commander": { 98 | "version": "2.20.0", 99 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", 100 | "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==" 101 | }, 102 | "css-select": { 103 | "version": "1.2.0", 104 | "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", 105 | "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=", 106 | "requires": { 107 | "boolbase": "~1.0.0", 108 | "css-what": "2.1", 109 | "domutils": "1.5.1", 110 | "nth-check": "~1.0.1" 111 | } 112 | }, 113 | "css-what": { 114 | "version": "2.1.3", 115 | "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.3.tgz", 116 | "integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==" 117 | }, 118 | "defaults": { 119 | "version": "1.0.3", 120 | "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", 121 | "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", 122 | "optional": true, 123 | "requires": { 124 | "clone": "^1.0.2" 125 | } 126 | }, 127 | "dom-serializer": { 128 | "version": "0.1.1", 129 | "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz", 130 | "integrity": "sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==", 131 | "requires": { 132 | "domelementtype": "^1.3.0", 133 | "entities": "^1.1.1" 134 | } 135 | }, 136 | "domelementtype": { 137 | "version": "1.3.1", 138 | "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", 139 | "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" 140 | }, 141 | "domhandler": { 142 | "version": "2.4.2", 143 | "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", 144 | "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", 145 | "requires": { 146 | "domelementtype": "1" 147 | } 148 | }, 149 | "domutils": { 150 | "version": "1.5.1", 151 | "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", 152 | "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", 153 | "requires": { 154 | "dom-serializer": "0", 155 | "domelementtype": "1" 156 | } 157 | }, 158 | "dotenv": { 159 | "version": "6.1.0", 160 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-6.1.0.tgz", 161 | "integrity": "sha512-/veDn2ztgRlB7gKmE3i9f6CmDIyXAy6d5nBq+whO9SLX+Zs1sXEgFLPi+aSuWqUuusMfbi84fT8j34fs1HaYUw==" 162 | }, 163 | "dotenv-safe": { 164 | "version": "6.1.0", 165 | "resolved": "https://registry.npmjs.org/dotenv-safe/-/dotenv-safe-6.1.0.tgz", 166 | "integrity": "sha512-O02OUTS+XmoRNZR4kRjJ9jlUGvQoXpMeTVVEBc8hUtgvPTgVZpsZH7TOocq4RVDpPrs2xGPwj6gIWjqRX+ErHA==", 167 | "requires": { 168 | "dotenv": "^6.1.0" 169 | } 170 | }, 171 | "easy-table": { 172 | "version": "1.1.1", 173 | "resolved": "https://registry.npmjs.org/easy-table/-/easy-table-1.1.1.tgz", 174 | "integrity": "sha512-C9Lvm0WFcn2RgxbMnTbXZenMIWcBtkzMr+dWqq/JsVoGFSVUVlPqeOa5LP5kM0I3zoOazFpckOEb2/0LDFfToQ==", 175 | "requires": { 176 | "ansi-regex": "^3.0.0", 177 | "wcwidth": ">=1.0.1" 178 | } 179 | }, 180 | "entities": { 181 | "version": "1.1.2", 182 | "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", 183 | "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==" 184 | }, 185 | "html-entities": { 186 | "version": "1.2.1", 187 | "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.2.1.tgz", 188 | "integrity": "sha1-DfKTUfByEWNRXfueVUPl9u7VFi8=" 189 | }, 190 | "htmlparser2": { 191 | "version": "3.10.1", 192 | "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", 193 | "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", 194 | "requires": { 195 | "domelementtype": "^1.3.1", 196 | "domhandler": "^2.3.0", 197 | "domutils": "^1.5.1", 198 | "entities": "^1.1.1", 199 | "inherits": "^2.0.1", 200 | "readable-stream": "^3.1.1" 201 | } 202 | }, 203 | "inherits": { 204 | "version": "2.0.3", 205 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 206 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 207 | }, 208 | "lodash": { 209 | "version": "4.17.19", 210 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", 211 | "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" 212 | }, 213 | "node-fetch": { 214 | "version": "2.6.1", 215 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", 216 | "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" 217 | }, 218 | "nth-check": { 219 | "version": "1.0.2", 220 | "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", 221 | "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", 222 | "requires": { 223 | "boolbase": "~1.0.0" 224 | } 225 | }, 226 | "parse5": { 227 | "version": "3.0.3", 228 | "resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz", 229 | "integrity": "sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==", 230 | "requires": { 231 | "@types/node": "*" 232 | } 233 | }, 234 | "readable-stream": { 235 | "version": "3.3.0", 236 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.3.0.tgz", 237 | "integrity": "sha512-EsI+s3k3XsW+fU8fQACLN59ky34AZ14LoeVZpYwmZvldCFo0r0gnelwF2TcMjLor/BTL5aDJVBMkss0dthToPw==", 238 | "requires": { 239 | "inherits": "^2.0.3", 240 | "string_decoder": "^1.1.1", 241 | "util-deprecate": "^1.0.1" 242 | } 243 | }, 244 | "safe-buffer": { 245 | "version": "5.1.2", 246 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", 247 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" 248 | }, 249 | "source-map": { 250 | "version": "0.6.1", 251 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 252 | "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" 253 | }, 254 | "source-map-support": { 255 | "version": "0.5.12", 256 | "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.12.tgz", 257 | "integrity": "sha512-4h2Pbvyy15EE02G+JOZpUCmqWJuqrs+sEkzewTm++BPi7Hvn/HwcqLAcNxYAyI0x13CpPPn+kMjl+hplXMHITQ==", 258 | "requires": { 259 | "buffer-from": "^1.0.0", 260 | "source-map": "^0.6.0" 261 | } 262 | }, 263 | "string_decoder": { 264 | "version": "1.2.0", 265 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.2.0.tgz", 266 | "integrity": "sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w==", 267 | "requires": { 268 | "safe-buffer": "~5.1.0" 269 | } 270 | }, 271 | "typescript": { 272 | "version": "3.3.4000", 273 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.3.4000.tgz", 274 | "integrity": "sha512-jjOcCZvpkl2+z7JFn0yBOoLQyLoIkNZAs/fYJkUG6VKy6zLPHJGfQJYFHzibB6GJaF/8QrcECtlQ5cpvRHSMEA==", 275 | "dev": true 276 | }, 277 | "util-deprecate": { 278 | "version": "1.0.2", 279 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 280 | "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" 281 | }, 282 | "wcwidth": { 283 | "version": "1.0.1", 284 | "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", 285 | "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", 286 | "optional": true, 287 | "requires": { 288 | "defaults": "^1.0.3" 289 | } 290 | }, 291 | "ws": { 292 | "version": "6.1.4", 293 | "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.4.tgz", 294 | "integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==", 295 | "requires": { 296 | "async-limiter": "~1.0.0" 297 | } 298 | } 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubitat", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Interact with Hubitat", 6 | "main": "index.js", 7 | "scripts": { 8 | "build": "tsc" 9 | }, 10 | "keywords": [], 11 | "author": "Jason Cheatham ", 12 | "license": "ISC", 13 | "dependencies": { 14 | "cheerio": "~1.0.0-rc.3", 15 | "commander": "~2.20.0", 16 | "dotenv-safe": "^6.1.0", 17 | "easy-table": "^1.1.1", 18 | "html-entities": "^1.2.1", 19 | "node-fetch": "~2.6.1", 20 | "source-map-support": "~0.5.12", 21 | "ws": "~6.1.4" 22 | }, 23 | "devDependencies": { 24 | "@types/cheerio": "~0.22.11", 25 | "@types/easy-table": "^0.0.32", 26 | "@types/html-entities": "^1.2.16", 27 | "@types/node": "~11.13.10", 28 | "@types/node-fetch": "~2.3.3", 29 | "@types/ws": "^6.0.1", 30 | "typescript": "~3.3.4000" 31 | }, 32 | "prettier": { 33 | "singleQuote": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "declaration": false, 5 | "allowSyntheticDefaultImports": true, 6 | "esModuleInterop": true, 7 | "module": "commonjs", 8 | "target": "esnext", 9 | "strict": true, 10 | "noUnusedParameters": true, 11 | "noUnusedLocals": true 12 | }, 13 | "include": ["**/*.ts"] 14 | } 15 | --------------------------------------------------------------------------------