├── .gitignore ├── README.md ├── devicetypes └── aromka │ ├── myq-garage-door.src │ └── myq-garage-door.groovy │ └── myq-switch.src │ └── myq-switch.groovy ├── package.json ├── server ├── index.js └── myq.service.js └── smartapps └── aromka └── myq-controller.src └── myq-controller.groovy /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | server/config/config.json 4 | npm-debug.log -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MyQController 2 | 3 | SmartThings integration for MyQ Garage Doors and Switches 4 | Based on [ady642/HomeCloudHub](https://github.com/ady624/HomeCloudHub) 5 | 6 | # Installation 7 | 8 | #### 1. Install the SmartApp 9 | 10 | 1. Go to your [SmartThings IDE](https://graph.api.smartthings.com/login/auth) 11 | 1. Go to *My SmartApps* link 12 | 1. Click on *Settings* button 13 | 1. Click *Add new repository* 14 | 1. Enter owner `aromka`, name `MyQController`, branch `master`. 15 | 1. Click *Save* 16 | 1. Click on the *Update from Repo* button 17 | 1. Select the `MyQController (master)` repository 18 | 1. Check the application, check *Publish*, and click *Execute Update* 19 | 20 | #### 2. Install Device Handlers 21 | 22 | 1. Go to *My Device Handlers* 23 | 1. Click on *Update from Repo* button and select `MyQController (master)` 24 | 1. Select devices that you want to install (Garage Door, Switch) 25 | 1. check *Publish*, and click *Execute Update* 26 | 27 | #### 3. Installing Local Server 28 | 29 | Prerequisites: You must have node and npm installed on your system. 30 | 31 | 1. Run `git clone https://github.com/aromka/myqcontroller.git` from directory where you want the server installed 32 | 1. Run `npm install` 33 | 1. Find out the IP and Port of your SmartThings hub 34 | - either from your router, 35 | - or go to *My Hubs* in SmartThings IDE and look for `localIP` 36 | 37 | #### 4. Running and Using SmartThings App 38 | 39 | 1. Run the server `node server [your-localIP]` from `myqcontroller` directory, for example: `node server 192.168.0.10` 40 | 1. Open SmartThings app 41 | 1. Go to Marketplace -> SmartApps tab 42 | 1. Scroll down and go to *MyApps* 43 | 1. Select *MyQ Controller* 44 | 1. Enter the IP of your local server 45 | (This should be pc/mac that's running node server. You can find this out by going to Network Preferences, usually it's something like `192.168.0.5`. If you have firewall enabled, make sure to open port `42457`) 46 | 1. Enter your MyQ username and password 47 | (Your credentials are stored in your SmartThings account, and never used or shared outside of this SmartApp) 48 | 1. Press *Next* 49 | 1. If you entered everything correctly, you should see success confirmation message 50 | 1. Press *Done* 51 | 1. Your devices should appear in *My Home* -> *Things* 52 | 1. You should also see all the devices that were found, as well as any commands sent in your console running the server 53 | 54 | 55 | # Raspberry Pi setup 56 | 57 | Installing on a fresh copy of [Raspbian NOOB](https://www.raspberrypi.org/downloads/noobs/) on [Raspberry Pi 3](https://www.amazon.com/gp/product/B01CD5VC92/ref=as_li_tl?ie=UTF8&camp=1789&creative=9325&creativeASIN=B01CD5VC92&linkCode=as2&tag=aromka-20&linkId=ae74c6aa2ea4a794b8662d6c9dcdc464) 58 | 59 | You can either do this through Raspberry Pi's console, or ssh to it from your mac / pc. 60 | 61 | If you want to ssh and run the commands from your mac, the default "Raspbian" OS will automatically broadcast its presence on your network under the mDNS name `raspberrypi`. If you are using Mac or Linux, you can reach your Pi easily: 62 | 63 | ssh pi@raspberrypi.local 64 | 65 | The default username for Raspbian is `pi` and the password is `raspberry`. 66 | 67 | Once you login, update the system and install npm: 68 | 69 | sudo apt-get update 70 | sudo apt-get upgrade 71 | sudo apt-get npm 72 | 73 | Optionally create a folder where you want to store MyQController app 74 | 75 | mkdir ~/Apps 76 | cd ~/Apps 77 | git clone https://github.com/aromka/myqcontroller.git 78 | cd myqcontroller && npm install 79 | 80 | Update config.json file 81 | 82 | cd ~/Apps/myqcontroller/server/config 83 | cp config.json.example config.json 84 | nano config.json 85 | 86 | And set your SmartThings Hub's IP and port. Now run the server. 87 | Find out your Raspberry Pi's IP address on your local network (you will need to set it in the app) 88 | 89 | hostname -I 90 | 91 | Run the server 92 | 93 | node server 94 | 95 | And update the IP in MyQ Controller SmartApp in the SmartThings app. 96 | 97 | 98 | #### Running the server on the background / after startup 99 | 100 | You can add a command to your /etc/rc.local 101 | 102 | sudo nano /etc/rc.local 103 | 104 | and add the following content right before `exit 0` 105 | 106 | exec 2> /tmp/rc.local.log # send stderr from rc.local to a log file 107 | exec 1>&2 # send stdout to the same log file 108 | set -x # tell sh to display commands before execution 109 | 110 | node /home/pi/Apps/myqcontroller/server [your-localIP] & 111 | 112 | This will run the MyQController server after raspberry pi boots up, and will log the output to `/tmp/rc.local.log` file. 113 | 114 | Restart the system 115 | 116 | sudo reboot 117 | 118 | You can tail the logs to make sure everything works as expected. 119 | 120 | tail -f /tmp/rc.local.log 121 | 122 | #### Restart MyQ background process 123 | 124 | sudo kill $(ps aux | grep [m]yq | awk '{print $2}') && sudo /etc/rc.local 125 | 126 | # Known issues 127 | 128 | * When you PC / Mac restarts when running a node server, you might get a different IP address, so app settings need to be update to assign the new IP, so you should both Raspberry PI (or your server) and SmartThing to static IP 129 | 130 | ####Setting static IP on raspberry pi 131 | 132 | 1. sudo nano /etc/dhcpcd.conf 133 | 134 | 2. interface eth0 135 | static ip_address=192.168.1.XX/24 136 | static routers=192.168.1.1 137 | static domain_name_servers=192.168.1.1 138 | 139 | 3. sudo reboot 140 | 141 | * If you run a server first time, and shortly kill it, and run it again - duplicate devices might be created, as it seems like ST doesn't return newly created devices within first few minutes. You can simply go and delete those duplicate devices to solve this. 142 | -------------------------------------------------------------------------------- /devicetypes/aromka/myq-garage-door.src/myq-garage-door.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * MyQ Garage Door 3 | * 4 | * Copyright 2016 Roman Alexeev 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | metadata { 17 | definition (name: "MyQ Garage Door", namespace: "aromka", author: "aromka", oauth: true) { 18 | capability "Refresh" 19 | capability "Polling" 20 | capability "Garage Door Control" 21 | capability "Contact Sensor" 22 | attribute "id", "string" 23 | attribute "module", "string" 24 | attribute "type", "string" 25 | } 26 | 27 | // UI tile definitions 28 | tiles(scale: 2) { 29 | standardTile("door", "device.door", width: 2, height: 2) { 30 | state "open", label: '${name}', icon: "st.doors.garage.garage-open", backgroundColor: "#ffa81e", canChangeIcon: true, canChangeBackground: true 31 | state "closed", label: '${name}', icon: "st.doors.garage.garage-closed", backgroundColor: "#79b821", canChangeIcon: true, canChangeBackground: true 32 | } 33 | 34 | multiAttributeTile(name:"details", type:"generic", width:6, height:4) { 35 | tileAttribute("device.door", key: "PRIMARY_CONTROL") { 36 | attributeState "open", label: '${name}', icon:"st.doors.garage.garage-open", backgroundColor:"#ffa81e", action: "close", nextState:"closing" 37 | attributeState "closing", label: '${name}', icon:"st.doors.garage.garage-open", backgroundColor:"#ffa81e", action: "open", nextState:"closed" 38 | attributeState "closed", label:'${name}', icon:"st.doors.garage.garage-closed", backgroundColor:"#79b821", action: "open", nextState:"opening" 39 | attributeState "opening", label:'${name}', icon:"st.doors.garage.garage-closed", backgroundColor:"#79b821", action: "close", nextState:"open" 40 | } 41 | } 42 | 43 | main(["door"]) 44 | details(["details"]) 45 | } 46 | } 47 | 48 | // parse events into attributes 49 | def parse(String description) { 50 | } 51 | 52 | def open() { 53 | parent.proxyCommand(device, 'desireddoorstate', 1); 54 | } 55 | 56 | def close() { 57 | parent.proxyCommand(device, 'desireddoorstate', 0); 58 | } -------------------------------------------------------------------------------- /devicetypes/aromka/myq-switch.src/myq-switch.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * MyQ Switch 3 | * 4 | * Copyright 2016 Roman Alexeev 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | metadata { 17 | definition (name: "MyQ Switch", namespace: "aromka", author: "aromka", oauth: true) { 18 | capability "Refresh" 19 | capability "Polling" 20 | capability "Switch" 21 | attribute "id", "string" 22 | attribute "module", "string" 23 | attribute "type", "string" 24 | } 25 | 26 | // UI tile definitions 27 | tiles(scale: 2) { 28 | standardTile("switch", "device.switch", width: 2, height: 2, decoration: "flat") { 29 | state "off", label: '${currentValue}', action: "switch.on", icon: "st.switches.switch.off", backgroundColor: "#ffffff", defaultState: true 30 | state "on", label: '${currentValue}', action: "switch.off", icon: "st.switches.switch.on", backgroundColor: "#79b821" 31 | 32 | } 33 | 34 | standardTile("details", "device.switch", width: 6, height: 4, decoration: "flat") { 35 | state "off", label: '${currentValue}', action: "switch.on", icon: "st.switches.switch.off", backgroundColor: "#ffffff", defaultState: true 36 | state "on", label: '${currentValue}', action: "switch.off", icon: "st.switches.switch.on", backgroundColor: "#79b821" 37 | 38 | } 39 | 40 | main(["switch"]) 41 | details(["details"]) 42 | } 43 | } 44 | 45 | def parse(String description) { 46 | } 47 | 48 | def on() { 49 | log.trace "on()" 50 | parent.proxyCommand(device, 'desiredlightstate', 1); 51 | } 52 | 53 | def off() { 54 | log.trace "off()" 55 | parent.proxyCommand(device, 'desiredlightstate', 0); 56 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "myqcontroller", 3 | "version": "2.0.1", 4 | "description": "MyQ integration for SmartThings", 5 | "main": "server/index.js", 6 | "dependencies": { 7 | "node-ssdp": "^2.9.0", 8 | "request": "^2.78.0" 9 | }, 10 | "devDependencies": {}, 11 | "scripts": { 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/aromka/myqcontroller.git" 17 | }, 18 | "keywords": [ 19 | "smartthings", 20 | "myq", 21 | "garage", 22 | "door", 23 | "switch" 24 | ], 25 | "author": "Roman Alexeev", 26 | "license": "ISC", 27 | "bugs": { 28 | "url": "https://github.com/aromka/myqcontroller/issues" 29 | }, 30 | "homepage": "https://github.com/aromka/myqcontroller#readme" 31 | } 32 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * MyQ Controller Application 3 | * 4 | * Copyright 2016 Roman Alexeev 5 | * Based on work of Adrian Caramaliu (HomeCloudHub) 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 8 | * in compliance with the License. You may obtain a copy of the License at: 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 13 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 14 | * for the specific language governing permissions and limitations under the License. 15 | * 16 | **/ 17 | 18 | var controller = new function () { 19 | 20 | var app = this, 21 | configFile = __dirname + '/config.json', 22 | node = { 23 | 'ssdp': require('node-ssdp'), 24 | 'http': require('http'), 25 | 'url': require('url'), 26 | 'request': require('request'), 27 | 'fs': require('fs') 28 | }, 29 | myq = null, 30 | config = {}, 31 | server = null; 32 | 33 | /** 34 | * Public methods 35 | */ 36 | this.init = init; 37 | this.start = start; 38 | this.refreshTokens = refreshTokens; 39 | 40 | 41 | /** 42 | * Process events 43 | */ 44 | function doProcessEvent(event) { 45 | try { 46 | if (event) { 47 | // log support 48 | if (event.name === 'log') { 49 | if (event.data) { 50 | console.log(event.data); 51 | } 52 | } else if (event.data && event.data.device) { 53 | var device = event.data.device; 54 | if (device.id && device.name && device.type) { 55 | try { 56 | var data = { 57 | id: device.id, 58 | name: device.name, 59 | type: device.type, 60 | event: event.name, 61 | value: event.data.value 62 | }; 63 | for (var attr in device) { 64 | if (attr.substr(0, 5) === 'data-') { 65 | data[attr] = device[attr]; 66 | } 67 | } 68 | 69 | console.log(getTimestamp() + 'Sending event to ST: ' + (event.data.description || '')); 70 | node 71 | .request 72 | .put({ 73 | url: 'http://' + config.server.ip + ':' + config.server.port + '/event', 74 | headers: { 75 | 'Content-Type': 'application/json' 76 | }, 77 | json: true, 78 | body: { 79 | event: 'event', 80 | data: data 81 | } 82 | }, 83 | function (err, response, body) { 84 | if (err) { 85 | console.error(getTimestamp() + 'Failed sending event: ' + err); 86 | } 87 | }) 88 | .on('error', function (e) { 89 | console.error(getTimestamp() + 'Error getting event request: ' + e); 90 | }); 91 | 92 | } catch (e) { 93 | console.error(getTimestamp() + 'Error parsing event data: ' + e); 94 | } 95 | } 96 | } 97 | } 98 | } catch (e) { 99 | console.error(getTimestamp() + 'Failed to send event to SmartThings: ' + e); 100 | } 101 | } 102 | 103 | /** 104 | * Process server request 105 | * 106 | * @param request 107 | * @param response 108 | */ 109 | function doProcessRequest(request, response) { 110 | 111 | try { 112 | var urlp = node.url.parse(request.url, true), 113 | query = urlp.query, 114 | payload = null; 115 | 116 | console.log(getTimestamp() + 'Handling request for: ' + urlp.pathname); 117 | 118 | if (query && query.payload) { 119 | payload = JSON.parse((new Buffer(query.payload, 'base64')).toString()) 120 | } 121 | 122 | if (request.method === 'GET') { 123 | switch (urlp.pathname) { 124 | case '/ping': 125 | console.log(getTimestamp() + 'Getting ping... replying pong'); 126 | response.writeHead(202, { 127 | 'Content-Type': 'application/json' 128 | }); 129 | response.write(JSON.stringify({ 130 | service: 'myq', 131 | result: 'pong' 132 | })); 133 | response.end(); 134 | return; 135 | 136 | case '/init': 137 | console.log(getTimestamp() + 'Received init request'); 138 | if (payload) { 139 | response.writeHead(202, { 140 | 'Content-Type': 'application/json' 141 | }); 142 | response.end(); 143 | 144 | app.start(payload.security); 145 | } 146 | break; 147 | } 148 | } 149 | } catch (e) { 150 | console.error(getTimestamp() + "ERROR: " + e); 151 | } 152 | 153 | response.writeHead(500, {}); 154 | response.end(); 155 | } 156 | 157 | /** 158 | * Load config file 159 | */ 160 | function doLoadConfig(ip, port) { 161 | 162 | config.server = { 163 | ip: ip, 164 | port: port 165 | }; 166 | 167 | if (!config.server.ip) { 168 | console.error('IP was not provided'); 169 | return; 170 | } 171 | 172 | try { 173 | console.log(getTimestamp() + 'Retrieved config for server: ' + config.server.ip + ':' + config.server.port); 174 | node 175 | .request 176 | .put({ 177 | url: 'http://' + config.server.ip + ':' + config.server.port, 178 | headers: { 179 | 'Content-Type': 'application/json' 180 | }, 181 | json: true, 182 | body: { 183 | event: 'init' 184 | } 185 | }, function () { 186 | console.log(getTimestamp() + 'Config loaded'); 187 | }, function (err) { 188 | console.error(getTimestamp() + 'Failed loading config: ' + err); 189 | }); 190 | } catch (e) { 191 | console.error(getTimestamp() + 'Failed reading config file: ' + e); 192 | } 193 | 194 | } 195 | 196 | /** 197 | * Get timestamp string for the log 198 | */ 199 | function getTimestamp() { 200 | var dt = new Date(), 201 | pad = function (val) { 202 | return val < 10 ? '0' + val : val; 203 | }; 204 | 205 | return '[' + pad(dt.getDate()) + '/' + pad(dt.getMonth()+1) + ' ' + 206 | pad(dt.getHours()) + ':' + pad(dt.getMinutes()) + ':' + pad(dt.getSeconds()) + '] '; 207 | } 208 | 209 | /** 210 | * Start the server 211 | */ 212 | function init() { 213 | 214 | console.log('=== === === MyQ Controller === === ==='); 215 | 216 | config = {}; 217 | 218 | server = node.http.createServer(doProcessRequest); 219 | server.listen(42457, '0.0.0.0'); 220 | 221 | var ip = process.argv.slice(2)[0], 222 | port = process.argv.slice(3)[0] || 39500; 223 | 224 | // load the configuration from config file 225 | doLoadConfig(ip, port); 226 | } 227 | 228 | /** 229 | * Start 230 | * 231 | * @param config 232 | */ 233 | function start(config) { 234 | try { 235 | var initial = !myq; 236 | myq = myq || require('./myq.service.js'); 237 | myq.config = config; 238 | 239 | if (initial || myq.shouldRecover) { 240 | myq.start(app, config, function (event) { 241 | doProcessEvent(event); 242 | }); 243 | } 244 | } catch (e) { 245 | console.error(getTimestamp() + 'Error starting myq: ' + e); 246 | } 247 | } 248 | 249 | /** 250 | * Refresh security tokens 251 | */ 252 | function refreshTokens() { 253 | 254 | if (!config.server || !config.server.ip || !config.server.port) { 255 | console.log(getTimestamp() + 'Could not refresh tokens due to bad config'); 256 | } 257 | 258 | try { 259 | console.log(getTimestamp() + 'Refreshing tokens...'); 260 | node 261 | .request 262 | .put({ 263 | url: 'http://' + config.server.ip + ':' + config.server.port, 264 | headers: { 265 | 'Content-Type': 'application/json' 266 | }, 267 | json: true, 268 | body: { 269 | event: 'init' 270 | } 271 | }) 272 | .on('error', function (e) { 273 | console.error(getTimestamp() + 'Failed refreshing tokens: ' + e); 274 | myq.shouldRecover = true; 275 | }); 276 | } catch (e) { 277 | console.error(getTimestamp() + 'Refresh tokens failed: ' + e); 278 | myq.shouldRecover = true; 279 | } 280 | } 281 | }; 282 | 283 | // init 284 | controller.init(); 285 | -------------------------------------------------------------------------------- /server/myq.service.js: -------------------------------------------------------------------------------- 1 | /** 2 | * MyQ Service 3 | * 4 | * Copyright 2016 Roman Alexeev 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | **/ 16 | 17 | /* module paths - please add your own path for node_modules if not here already */ 18 | module.paths.push('/usr/lib/node_modules'); 19 | module.paths.push('/usr/local/lib/node_modules'); 20 | 21 | var exports = module.exports = new function () { 22 | 23 | var app = null, 24 | config = {}, 25 | callback = null, 26 | shouldRecover = false, 27 | https = require('https'), 28 | request = require('request').defaults({ 29 | jar: true, 30 | encoding: 'utf8', 31 | followRedirect: true, 32 | followAllRedirects: true, 33 | forever: true 34 | }), 35 | myQAppId = 'NWknvuBd7LoFHfXmKNMBcgajXtZEgKUh4V7WNzMidrpUUluDpVYVZx+xT4PCM5Kx', 36 | devices = [], 37 | tmrRecover = null; 38 | 39 | /** 40 | * Get api url 41 | * 42 | * @param path 43 | * @returns {string} 44 | */ 45 | function getUrl(path) { 46 | return 'https://myqexternal.myqdevice.com' + path + '?appId=' + myQAppId + '&securityToken=' + config.securityToken 47 | } 48 | 49 | /** 50 | * Init 51 | */ 52 | function doInit() { 53 | console.log(getTimestamp() + 'Initializing...'); 54 | 55 | shouldRecover = false; 56 | if (!config.securityToken) { 57 | console.error('Could not get tokens'); 58 | } 59 | doGetDevices(); 60 | } 61 | 62 | /** 63 | * Recover 64 | */ 65 | function doRecover() { 66 | 67 | if (!shouldRecover) { 68 | return; 69 | } 70 | shouldRecover = false; 71 | 72 | console.log(getTimestamp() + 'Trying to recover...'); 73 | 74 | // cancel previous timeout if its set 75 | if (tmrRecover) { 76 | clearTimeout(tmrRecover); 77 | } 78 | tmrRecover = setTimeout(doRecover, 5 * 60 * 1000); 79 | 80 | app.refreshTokens(); 81 | } 82 | 83 | /** 84 | * Get devices 85 | */ 86 | function doGetDevices(isRefresh) { 87 | 88 | if (!isRefresh) { 89 | console.log(getTimestamp() + 'Getting device list...'); 90 | } 91 | 92 | // get devices 93 | request 94 | .get({ 95 | url: getUrl('/api/v4/userdevicedetails/get'), 96 | headers: { 97 | 'User-Agent': 'Chamberlain/3.73', 98 | 'BrandId': '2', 99 | 'ApiVersion': '4.1', 100 | 'Culture': 'en', 101 | 'MyQApplicationId': myQAppId 102 | } 103 | }, handleGetDeviceResponse) 104 | .on('error', function (e) { 105 | console.error(getTimestamp() + 'Failed sending doGetDevice request: ' + e); 106 | shouldRecover = true; 107 | doRecover(); 108 | }); 109 | } 110 | 111 | /** 112 | * @param err 113 | * @param response 114 | * @param body 115 | */ 116 | function handleGetDeviceResponse(err, response, body) { 117 | 118 | // handle error 119 | if (err || response.statusCode !== 200 || !body) { 120 | console.error(getTimestamp() + 'Error getting device list: ' + err); 121 | shouldRecover = true; 122 | return doRecover(); 123 | } 124 | 125 | // proceed with parsing body 126 | try { 127 | var data = JSON.parse(body); 128 | if ((data) && (data.Devices) && (data.Devices.length)) { 129 | 130 | // cycle through each device 131 | for (var d in data.Devices) { 132 | 133 | var dev = data.Devices[d], 134 | device = { 135 | 'id': dev.MyQDeviceId, 136 | 'name': dev.MyQDeviceTypeName.replace(' Opener', ''), 137 | 'type': dev.MyQDeviceTypeName.replace('VGDO', 'GarageDoorOpener'), 138 | 'serial': dev.SerialNumber 139 | }; 140 | 141 | 142 | // if not switch or door - skip this 143 | if (['GarageDoorOpener', 'LampModule'].indexOf(device.type) === -1) { 144 | continue; 145 | } 146 | 147 | // set device attributes 148 | for (var prop in dev.Attributes) { 149 | var attr = dev.Attributes[prop]; 150 | doSetDeviceAttribute(device, attr.AttributeDisplayName, attr.Value); 151 | } 152 | 153 | // Rename device with actual MYQ door name 154 | if (device['data-description'] !== '') { 155 | device.name = device['data-description']; 156 | } 157 | 158 | var existing = false; 159 | var notify = false; 160 | 161 | // we only push updates if there are any changes made 162 | for (var i in devices) { 163 | 164 | if (devices[i].id === device.id) { 165 | // found an existing device 166 | existing = true; 167 | if (JSON.stringify(devices[i]) !== JSON.stringify(device)) { 168 | var attribute = '', 169 | oldValue = '', 170 | newValue = ''; 171 | 172 | if (devices[i]['data-door'] !== device['data-door']) { 173 | attribute = 'data-door'; 174 | oldValue = devices[i]['data-door']; 175 | newValue = device['data-door']; 176 | notify = true; 177 | } else if (devices[i]['data-switch'] !== device['data-switch']) { 178 | attribute = 'data-switch'; 179 | oldValue = devices[i]['data-switch']; 180 | newValue = device['data-switch']; 181 | notify = true; 182 | } 183 | 184 | // update the device 185 | devices[i] = device; 186 | 187 | // notify change 188 | if (notify) { 189 | callback({ 190 | name: 'update', 191 | data: { 192 | device: device, 193 | attribute: attribute, 194 | oldValue: oldValue, 195 | newValue: newValue, 196 | value: newValue, 197 | description: 'Device "' + device.name + '" <' + device.id + '> changed its "' + attribute + '" value from "' + oldValue + '" to "' + newValue + '"' 198 | } 199 | }); 200 | } 201 | } 202 | break; 203 | } 204 | } 205 | 206 | if (!existing && device.type !== 'Gateway' && device['data-description'] !== '') { 207 | devices.push(device); 208 | callback({ 209 | name: 'discovery', 210 | data: { 211 | device: device, 212 | description: 'Discovered device "' + device.name + '" <' + device.id + '>' 213 | } 214 | }); 215 | } 216 | } 217 | 218 | // refresh every 10 seconds 219 | setTimeout(function () { 220 | doGetDevices(true); 221 | }, 10 * 1000); 222 | } 223 | } catch (e) { 224 | // try to recover 225 | console.error(getTimestamp() + 'Error reading device list: ' + e); 226 | return doRecover(); 227 | } 228 | } 229 | 230 | /** 231 | * Set device attributes 232 | * 233 | * @param device 234 | * @param attribute 235 | * @param value 236 | * @returns {*} 237 | */ 238 | function doSetDeviceAttribute(device, attribute, value) { 239 | 240 | switch (device.type) { 241 | case 'GarageDoorOpener': 242 | if (attribute === 'doorstate') { 243 | switch (value) { 244 | case '1': 245 | case '9': 246 | value = 'open'; 247 | break; 248 | case '2': 249 | value = 'closed'; 250 | break; 251 | case '3': 252 | value = 'stopped'; 253 | break; 254 | case '4': 255 | value = 'opening'; 256 | break; 257 | case '5': 258 | value = 'closing'; 259 | break; 260 | default: 261 | value = 'unknown'; 262 | } 263 | } 264 | break; 265 | 266 | case 'LampModule': 267 | if (attribute === 'lightstate') { 268 | switch (value) { 269 | case '0': 270 | value = 'off'; 271 | break; 272 | case '1': 273 | value = 'on'; 274 | break; 275 | default: 276 | value = 'unknown'; 277 | } 278 | } 279 | break; 280 | } 281 | 282 | attribute = attribute 283 | .replace('doorstate', 'door') 284 | .replace('lightstate', 'switch') 285 | .replace('desc', 'description'); 286 | 287 | var attr = 'data-' + attribute; 288 | 289 | // return true if the value changed 290 | if (device[attr] !== value) { 291 | var oldValue = device[attr]; 292 | device[attr] = value; 293 | 294 | if (attr === 'data-door') { 295 | device['data-contact'] = value; 296 | } else if (attr === 'data-light') { 297 | device['data-switch'] = value; 298 | } 299 | 300 | return { 301 | attr: attr, 302 | oldValue: oldValue, 303 | newValue: value 304 | } 305 | } 306 | return false; 307 | } 308 | 309 | /** 310 | * Get timestamp string for the log 311 | */ 312 | function getTimestamp() { 313 | var dt = new Date(), 314 | pad = function (val) { 315 | return val < 10 ? '0' + val : val; 316 | }; 317 | 318 | return '[' + pad(dt.getDate()) + '/' + pad(dt.getMonth()+1) + ' ' + 319 | pad(dt.getHours()) + ':' + pad(dt.getMinutes()) + ':' + pad(dt.getSeconds()) + '] '; 320 | } 321 | 322 | /** 323 | * 324 | * @param _app 325 | * @param _config 326 | * @param _callback 327 | * @returns {boolean} 328 | */ 329 | this.start = function (_app, _config, _callback) { 330 | if (_app && _config && _callback) { 331 | app = _app; 332 | config = _config; 333 | callback = _callback; 334 | doInit(); 335 | return true; 336 | } 337 | return false; 338 | }; 339 | 340 | /** 341 | * 342 | * @param deviceId 343 | * @param command 344 | * @param value 345 | */ 346 | this.processCommand = function (deviceId, command, value) { 347 | doProcessCommand(deviceId, command, value); 348 | }; 349 | 350 | /** 351 | * 352 | * @returns {boolean} 353 | */ 354 | this.shouldRecover = function () { 355 | return !!shouldRecover; 356 | }; 357 | 358 | }(); 359 | -------------------------------------------------------------------------------- /smartapps/aromka/myq-controller.src/myq-controller.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * MyQ Controller 3 | * 4 | * Copyright 2016 Roman Alexeev 5 | * 6 | * NOTE: This application requires a local server connected to the same network as your SmartThings hub. 7 | * Find more info at https://github.com/aromka/MyQController 8 | * 9 | * Based on the work of https://github.com/ady624/HomeCloudHub 10 | * 11 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 12 | * in compliance with the License. You may obtain a copy 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 distributed under the License is distributed 17 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 18 | * for the specific language governing permissions and limitations under the License. 19 | **/ 20 | 21 | definition( 22 | name: "MyQ Controller", 23 | namespace: "aromka", 24 | singleInstance: true, 25 | author: "aromka", 26 | description: "Provides integration with MyQ Garage doors and switches", 27 | category: "Convenience", 28 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/garage_contact.png", 29 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/garage_contact@2x.png", 30 | iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Meta/garage_contact@2x.png", 31 | oauth: true) 32 | 33 | private getMyQAppId() { 34 | return 'NWknvuBd7LoFHfXmKNMBcgajXtZEgKUh4V7WNzMidrpUUluDpVYVZx+xT4PCM5Kx' 35 | } 36 | 37 | preferences { 38 | page(name: "prefWelcome") 39 | page(name: "prefMyQValidate") 40 | } 41 | 42 | 43 | /***********************************************************************/ 44 | /* INSTALLATION UI PAGES */ 45 | /***********************************************************************/ 46 | def prefWelcome() { 47 | 48 | dynamicPage(name: "prefWelcome", title: "MyQ™ Integration", uninstall: true, nextPage: "prefMyQValidate") { 49 | 50 | section("Local Server Settings") { 51 | input("localServerIp", "text", title: "Enter the IP of your local server", required: true, defaultValue: "192.168.0.") 52 | } 53 | 54 | section("MyQ Credentials"){ 55 | input("myqUsername", "email", title: "Username", description: "Your MyQ™ login", required: true) 56 | input("myqPassword", "password", title: "Password", description: "Your MyQ™ password", required: true) 57 | } 58 | } 59 | } 60 | 61 | def prefMyQValidate() { 62 | 63 | atomicState.security = [:] 64 | 65 | if (doLocalLogin()) { 66 | if (doMyQLogin(true, true)) { 67 | dynamicPage(name: "prefMyQValidate", title: "MyQ™ Integration Completed", install: true) { 68 | section(){ 69 | paragraph "Congratulations! You have successfully connected your MyQ™ system." 70 | } 71 | } 72 | } else { 73 | dynamicPage(name: "prefMyQValidate", title: "MyQ™ Integration Error") { 74 | section(){ 75 | paragraph "Sorry, the credentials you provided for MyQ™ are invalid. Please go back and try again." 76 | } 77 | } 78 | } 79 | } else { 80 | dynamicPage(name: "prefMyQValidate", title: "MyQ™ Integration Error") { 81 | section(){ 82 | paragraph "Sorry, your local server does not seem to respond at ${settings.localServerIp}." 83 | } 84 | } 85 | } 86 | } 87 | 88 | 89 | /***********************************************************************/ 90 | /* LOGIN PROCEDURES */ 91 | /***********************************************************************/ 92 | /* Login to local server */ 93 | /***********************************************************************/ 94 | private doLocalLogin() { 95 | 96 | if(!atomicState.subscribed) { 97 | subscribe(location, null, lanEventHandler, [filterEvents:false]) 98 | atomicState.subscribed = true 99 | } 100 | 101 | atomicState.pong = false 102 | 103 | log.trace "Pinging local server at " + settings.localServerIp 104 | sendLocalServerCommand settings.localServerIp, "ping", "" 105 | 106 | def cnt = 50 107 | def pong = false 108 | while (cnt--) { 109 | pause(200) 110 | pong = atomicState.pong 111 | log.trace "Pong: " + atomicState.pong 112 | if (pong) { 113 | return true 114 | } 115 | } 116 | return false 117 | } 118 | 119 | /***********************************************************************/ 120 | /* Login to MyQ */ 121 | /***********************************************************************/ 122 | def doMyQLogin(installing, force) { 123 | 124 | // if cookies haven't expired and unless we need to force a login, we report all is pink 125 | if (!installing && !force && atomicState.security && atomicState.security.connected && atomicState.security.expires > now()) { 126 | log.info "Reusing previously login for MyQ" 127 | return true; 128 | } 129 | 130 | // setup our security descriptor 131 | atomicState.security = [ 132 | 'securityToken': null, 133 | 'enabled': !!(settings.myqUsername && settings.myqPassword), 134 | 'connected': 0 135 | ] 136 | 137 | if (!atomicState.security.enabled) { 138 | log.info "Missing MyQ credentials" 139 | return false; 140 | } 141 | 142 | log.info "Logging in to MyQ... " 143 | 144 | // perform the login, retrieve token 145 | def result = false 146 | try { 147 | result = httpPost([ 148 | uri: "https://myqexternal.myqdevice.com", 149 | path: "/api/v4/User/Validate", 150 | headers: [ 151 | "User-Agent": "Chamberlain/3.73", 152 | "BrandId": "2", 153 | "ApiVersion": "4.1", 154 | "Culture": "en", 155 | "MyQApplicationId": getMyQAppId() 156 | ], 157 | body: [ 158 | username: settings.myqUsername, 159 | password: settings.myqPassword 160 | ] 161 | ]) { response -> 162 | 163 | log.info "Login response code: " + response.status 164 | 165 | // check response, continue if 200 OK 166 | if (response.status == 200) { 167 | 168 | if (response.data && response.data.SecurityToken != null) { 169 | def tempStateSecurity = atomicState.security 170 | tempStateSecurity.securityToken = response.data.SecurityToken 171 | tempStateSecurity.connected = now() 172 | tempStateSecurity.expires = now() + 900000 // expire in 15 minutes 173 | atomicState.security = tempStateSecurity 174 | log.info "Successfully connected to MyQ" 175 | return true; 176 | } 177 | } 178 | 179 | log.info "Response data: " + response.data 180 | return false; 181 | } 182 | } catch (SocketException e) { 183 | log.debug "API Error: $e" 184 | } 185 | 186 | return result; 187 | } 188 | 189 | 190 | /***********************************************************************/ 191 | /* INSTALL/UNINSTALL SUPPORTING PROCEDURES */ 192 | /***********************************************************************/ 193 | def installed() { 194 | initialize() 195 | } 196 | 197 | def updated() { 198 | unsubscribe() 199 | initialize() 200 | } 201 | 202 | def uninstalled() { 203 | } 204 | 205 | def initialize() { 206 | 207 | log.info "Initializing MyQ controller..." 208 | 209 | // login to myq 210 | doMyQLogin(false, false) 211 | 212 | // initialize the local server 213 | sendLocalServerCommand settings.localServerIp, "init", [ 214 | security: atomicState.security 215 | ] 216 | 217 | // listen to LAN incoming messages 218 | subscribe(location, null, lanEventHandler, [filterEvents:false]) 219 | } 220 | 221 | /***********************************************************************/ 222 | /* SMARTTHINGS EVENT HANDLERS */ 223 | /***********************************************************************/ 224 | def lanEventHandler(evt) { 225 | def description = evt.description 226 | def hub = evt?.hubId 227 | def parsedEvent = parseLanMessage(description) 228 | 229 | // ping response 230 | if (parsedEvent.data && parsedEvent.data.service && (parsedEvent.data.service == "myq")) { 231 | def msg = parsedEvent.data 232 | if (msg.result == "pong") { 233 | //log in successful to local server 234 | log.info "Successfully contacted local server" 235 | atomicState.pong = true 236 | } 237 | } 238 | 239 | if (parsedEvent.data && parsedEvent.data.event) { 240 | switch (parsedEvent.data.event) { 241 | case "init": 242 | sendLocalServerCommand settings.localServerIp, "init", [ 243 | security: processSecurity() 244 | ] 245 | break 246 | case "event": 247 | processEvent(parsedEvent.data.data); 248 | break 249 | } 250 | } 251 | } 252 | 253 | private sendLocalServerCommand(ip, command, payload) { 254 | sendHubCommand(new physicalgraph.device.HubAction( 255 | method: "GET", 256 | path: "/${command}", 257 | headers: [ 258 | HOST: "${ip}:42457" 259 | ], 260 | query: payload ? [payload: groovy.json.JsonOutput.toJson(payload).bytes.encodeBase64()] : [] 261 | )) 262 | } 263 | 264 | 265 | /***********************************************************************/ 266 | /* EXTERNAL EVENT MAPPINGS */ 267 | /***********************************************************************/ 268 | mappings { 269 | path("/event") { 270 | action: [ 271 | GET: "processEvent", 272 | PUT: "processEvent" 273 | ] 274 | } 275 | path("/security") { 276 | action: [ 277 | GET: "processSecurity" 278 | ] 279 | } 280 | } 281 | 282 | /***********************************************************************/ 283 | /* EXTERNAL EVENT HANDLERS */ 284 | /***********************************************************************/ 285 | private processEvent(data) { 286 | if (!data) { 287 | data = params 288 | } 289 | 290 | def eventName = data?.event 291 | def eventValue = data?.value 292 | def deviceId = data?.id 293 | def deviceName = data?.name.capitalize() 294 | def deviceType = data?.type 295 | def description = data?.description 296 | 297 | if (description) { 298 | log.info 'Received event: ' + description 299 | } else { 300 | log.info "Received ${eventName} event for device ${deviceName}, value ${eventValue}, data: $data" 301 | } 302 | 303 | // see if the specified device exists and create it if it does not exist 304 | def deviceDNI = 'myq-' + deviceId; 305 | def device = getChildDevice(deviceDNI) 306 | if (!device) { 307 | 308 | log.info "Adding new device: " + deviceType + ", ID: " + deviceDNI 309 | 310 | //build the device type 311 | def deviceHandler = null; 312 | 313 | // support for various device types 314 | if (deviceType == "GarageDoorOpener") { 315 | deviceHandler = 'MyQ Garage Door' 316 | } else if (deviceType == "LampModule") { 317 | deviceHandler = 'MyQ Switch' 318 | } 319 | 320 | if (deviceHandler) { 321 | 322 | log.info "Looking for device handler: " + deviceHandler 323 | 324 | // we have a valid device type, create it 325 | try { 326 | device = addChildDevice("aromka", deviceHandler, deviceDNI, null, [label: deviceName]) 327 | device.sendEvent(name: 'id', value: deviceId); 328 | device.sendEvent(name: 'type', value: deviceType); 329 | } catch(e) { 330 | log.info "MyQ Controller discovered a device that is not yet supported by your hub. Please find and install the [${deviceHandler}] device handler from https://github.com/aromka/MyQController/tree/master/devicetypes/aromka" 331 | } 332 | } 333 | 334 | } else { 335 | log.info "Device already exists. ID: " + deviceDNI 336 | } 337 | 338 | // we have a valid device that existed or was just created, now set the state 339 | if (device) { 340 | for (param in data) { 341 | def key = param.key 342 | def value = param.value 343 | if ((key.size() > 5) && (key.substring(0, 5) == 'data-')) { 344 | key = key.substring(5); 345 | def oldValue = device.currentValue(key); 346 | if (oldValue != value) { 347 | device.sendEvent(name: key, value: value); 348 | // list of capabilities 349 | // http://docs.smartthings.com/en/latest/capabilities-reference.html 350 | } 351 | } 352 | } 353 | } 354 | } 355 | 356 | private processSecurity() { 357 | doMyQLogin(false, true) 358 | 359 | log.info "Providing security token to MyQ Controller" 360 | return atomicState.security; 361 | } 362 | 363 | 364 | /***********************************************************************/ 365 | /* MYQ COMMANDS */ 366 | /***********************************************************************/ 367 | def proxyCommand(device, command, value) { 368 | exec(device, command, value, false) 369 | } 370 | 371 | def exec(device, command, value, retry) { 372 | 373 | // get myq login 374 | if (!doMyQLogin(false, retry)) { 375 | log.error "Failed sending command to MyQ because we could not connect" 376 | } 377 | 378 | def result = false 379 | def message = "" 380 | if (command && value != null) { 381 | log.info "Setting device " + device.currentValue("type") + ": " + command + "=" + value 382 | try { 383 | result = httpPutJson([ 384 | uri: "https://myqexternal.myqdevice.com/api/v4/deviceAttribute/putDeviceAttribute?appId=" + getMyQAppId() + "&securityToken=${atomicState.security.securityToken}", 385 | headers: [ 386 | "User-Agent": "Chamberlain/3.73", 387 | "BrandId": "2", 388 | "ApiVersion": "4.1", 389 | "Culture": "en", 390 | "MyQApplicationId": getMyQAppId() 391 | ], 392 | body: [ 393 | ApplicationId: getMyQAppId(), 394 | SecurityToken: atomicState.security.securityToken, 395 | MyQDeviceId: device.currentValue('id'), 396 | AttributeName: command, 397 | AttributeValue: value 398 | ] 399 | ]) { response -> 400 | //check response, continue if 200 OK 401 | message = response.data 402 | if ((response.status == 200) && response.data && (response.data.SecurityToken != null)) { 403 | return true 404 | } 405 | return false 406 | } 407 | 408 | if (result) { 409 | return "Successfully sent command to MyQ: device [${device}] command [${command}] value [${value}] result [${message}]" 410 | } else { 411 | // if we failed and this was already a retry, give up 412 | if (retry) { 413 | return "Failed sending command to MyQ: ${message}" 414 | } 415 | 416 | // we failed the first time, let's retry 417 | return exec(device, command, value, true) 418 | } 419 | } catch(e) { 420 | message = "Failed sending command to MyQ: ${e}" 421 | } 422 | } 423 | } --------------------------------------------------------------------------------