Last active
March 3, 2019 05:06
-
-
Save fishy/5d5bdae727d5daa75f00e723ed4fc183 to your computer and use it in GitHub Desktop.
MyQ Garage SmartThings device handler
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* MyQ Garage Door | |
* | |
* Copyright 2018 Yuxuan Wang | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except | |
* in compliance with the License. You may obtain a copy of the License at: | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed | |
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License | |
* for the specific language governing permissions and limitations under the License. | |
* | |
*/ | |
// This is a combination of https://github.com/adamheinmiller/ST_MyQ and https://github.com/Einstein42/myq-garage | |
// adamheinmiller/ST_MyQ by Adam Heinmiller, Apache License | |
// Einstein42/myq-garage by Einstein42, MIT License | |
metadata { | |
definition (name: "MyQ Garage Door", namespace: "fishy", author: "Yuxuan Wang") { | |
capability "Contact Sensor" | |
capability "Door Control" | |
capability "Garage Door Control" | |
capability "Momentary" | |
capability "Polling" | |
capability "Refresh" | |
capability "Switch" | |
attribute "lastDoorAction", "string" | |
attribute "lastHttpStatus", "enum", ["succeeded", "failed"] | |
} | |
preferences { | |
input( | |
name: "username", | |
type: "text", | |
title: "Username", | |
description: "MyQ username (email address)", | |
required: true, | |
) | |
input( | |
name: "password", | |
type: "password", | |
title: "Password", | |
description: "MyQ password", | |
required: true, | |
) | |
input( | |
name: "base_url", | |
type: "text", | |
title: "Base URL", | |
description: "Base URL of MyQ API endpoint (default: \"https://myqexternal.myqdevice.com/\")", | |
) | |
input( | |
name: "myq_app_id", | |
type: "text", | |
title: "MyQ App ID", | |
description: "appId used in API calls, leave empty to use default one", | |
) | |
input( | |
name: "door_name", | |
type: "text", | |
title: "Door Name", | |
description: "MyQ Garage Door name (default: \"Garage Door\")", | |
) | |
input( | |
name: "refresh_rate", | |
type: "enum", | |
title: "State Refresh Rate", | |
options: [ | |
"Every minute", | |
"Every 5 minutes", | |
"Every 10 minutes", | |
"Every 15 minutes", | |
"Every 30 minutes", | |
"Every hour", | |
"Disabled", | |
], | |
description: "Only disable it if you have another contact sensor hooked on the garage door", | |
required: true, | |
) | |
} | |
tiles { | |
standardTile("sDoorToggle", "device.door", width: 1, height: 1, canChangeIcon: false) { | |
state( | |
"default", | |
label: "", | |
) | |
state( | |
"unknown", | |
label: "Unknown", | |
icon: "st.unknown.unknown.unknown", | |
action: "refresh.refresh", | |
backgroundColor: "#afafaf", | |
) | |
state( | |
"stopped", | |
label: "Stopped", | |
icon: "st.contact.contact.open", | |
action: "close", | |
backgroundColor: "#ffdd00", | |
) | |
state( | |
"closed", | |
label: "Closed", | |
icon: "st.doors.garage.garage-closed", | |
action: "open", | |
backgroundColor: "#00a0dc", | |
) | |
state( | |
"closing", | |
label: "Closing", | |
icon: "st.doors.garage.garage-closing", | |
backgroundColor: "#ffdd00", | |
) | |
state( | |
"open", | |
label: "Open", | |
icon: "st.doors.garage.garage-open", | |
action: "close", | |
backgroundColor: "#ffdd00", | |
) | |
state( | |
"opening", | |
label: "Opening", | |
icon: "st.doors.garage.garage-opening", | |
backgroundColor: "#ffdd00", | |
) | |
state( | |
"moving", | |
label: "Moving", | |
icon: "st.motion.motion.active", | |
action: "refresh.refresh", | |
backgroundColor: "#ffdd00", | |
) | |
} | |
standardTile("sRefresh", "device.door", inactiveLabel: false, decoration: "flat") { | |
state( | |
"default", | |
label: "", | |
action: "refresh.refresh", | |
icon: "st.secondary.refresh", | |
) | |
} | |
standardTile("sContact", "device.contact") { | |
state( | |
"open", | |
label: "${name}", | |
icon: "st.contact.contact.open", | |
backgroundColor: "#ffa81e", | |
) | |
state( | |
"closed", | |
label: "${name}", | |
icon: "st.contact.contact.closed", | |
backgroundColor: "#79b821", | |
) | |
} | |
valueTile("vLastDoorAction", "device.lastDoorAction", width: 2, height: 1, decoration: "flat") { | |
state "default", label: '${currentValue}' | |
} | |
main(["sDoorToggle"]) | |
details(["sDoorToggle", "vLastDoorAction", "sRefresh"]) | |
} | |
} | |
def installed() { | |
log.debug "Installing MyQ Garage Door" | |
state.Login = [ Expiration: 0 ] | |
state.DeviceID = 0 | |
checkLogin(settings, state) | |
checkRefresh(settings) | |
} | |
def updated() { | |
log.debug "Updating MyQ Garage Door" | |
state.Login = [ Expiration: 0 ] | |
//state.Login.Expiration = 0 | |
state.DeviceID = 0 | |
checkLogin(settings, state) | |
checkRefresh(settings) | |
} | |
// parse events into attributes | |
def parse(String description) { | |
log.debug "Parsing '${description}'" | |
// TODO: handle 'contact' attribute | |
// TODO: handle 'switch' attribute | |
} | |
// handle commands | |
def push() { | |
log.debug "Executing 'push'" | |
checkLogin(settings, state) | |
def dInitStatus | |
getDoorStatus(state) { status -> dInitStatus = status } | |
if (dInitStatus == "closed" || dInitStatus == "closing" || dInitStatus == "stopped" || dInitStatus == "moving") { | |
log.debug "Door is in a closed status, opening" | |
open() | |
} else if (dInitStatus == "open" || dInitStatus == "opening") { | |
log.debug "Door is in an open status, closing" | |
close() | |
} else if (dInitStatus == "unknown") { | |
log.debug "Door is in an unknown state, doing nothing" | |
} | |
} | |
def poll() { | |
log.debug "MyQ Garage door Polling" | |
refresh() | |
} | |
def refresh() { | |
doRefresh() | |
} | |
def checkRefresh(settings) { | |
switch (settings.refresh_rate.toLowerCase()) { | |
case "disabled": | |
unschedule(doRefresh) | |
doRefresh() | |
break | |
case "every 5 minutes": | |
runEvery5Minutes(doRefresh) | |
break | |
case "every 10 minutes": | |
runEvery10Minutes(doRefresh) | |
break | |
case "every 15 minutes": | |
runEvery15Minutes(doRefresh) | |
break | |
case "every 30 minutes": | |
runEvery30Minutes(doRefresh) | |
break | |
case "every hour": | |
runEvery1Hour(doRefresh) | |
break | |
case "every minute": | |
default: | |
runEvery1Minute(doRefresh) | |
break | |
} | |
} | |
def doRefresh() { | |
log.debug "Refreshing Door State" | |
checkLogin(settings, state) | |
getDoorStatus(state) { status -> | |
setDoorState(status) | |
log.debug "Door Status: $status" | |
} | |
} | |
def afterForceRefresh(status, startTime) { | |
def time = (now() - startTime) / 1000 | |
log.debug "Final Door Status: $status, took $time seconds" | |
setDoorState(status) | |
} | |
def forceRefreshUntil(data) { | |
def timestamp = now() | |
def target = data.targetStatus | |
log.debug "forceRefreshUntil: ${new Date()}, timestamp: $timestamp, stops at ${data.stopAt}, target status: $target" | |
def scheduleNext = true | |
if (timestamp >= data.stopAt) { | |
log.debug "Stopping refreshing..." | |
getDoorStatus(state) { status -> | |
afterForceRefresh(status, data.startTime) | |
} | |
scheduleNext = false | |
return | |
} | |
getDoorStatus(state) { status -> | |
log.debug "forceRefreshUntil: get door status: $status" | |
if (status == target) { | |
log.debug "Got target status $status, stopping refreshing..." | |
afterForceRefresh(status, data.startTime) | |
scheduleNext = false | |
return | |
} | |
} | |
if (scheduleNext) { | |
def options = [ | |
overwrite: true, | |
data: data, | |
] | |
runIn(3, forceRefreshUntil, options) | |
} else { | |
unschedule(forceRefreshUntil) | |
} | |
} | |
def refreshUntil(target) { | |
log.debug "refreshUntil: $target" | |
def maxMin = 5 | |
def timestamp = now() + 60 * 1000 * maxMin | |
def data = [ | |
startTime: now(), | |
stopAt: timestamp, | |
targetStatus: target, | |
] | |
forceRefreshUntil(data) | |
} | |
def on() { | |
log.debug "Executing 'on'" | |
open() | |
} | |
def off() { | |
log.debug "Executing 'off'" | |
close() | |
} | |
def open() { | |
log.debug "Opening Door" | |
checkLogin(settings, state) | |
def dInitStatus | |
def dCurrentStatus = "opening" | |
getDoorStatus(state) { status -> dInitStatus = status } | |
if (dInitStatus == "opening" || dInitStatus == "open" || dInitStatus == "moving") { return } | |
setDoorState("opening") | |
openDoor(state) | |
refreshUntil("open") | |
} | |
def close() { | |
log.debug "Closing Door" | |
checkLogin(settings, state) | |
def dInitStatus | |
def dCurrentStatus = "closing" | |
def dTotalSleep = 0 | |
getDoorStatus(state) { status -> dInitStatus = status } | |
if (dInitStatus == "closing" || dInitStatus == "closed" || dInitStatus == "moving") { return } | |
setDoorState("closing") | |
closeDoor(state) | |
refreshUntil("closed") | |
} | |
def checkLogin(settings, state) { | |
//log.debug "Checking Login Credentials" | |
def logins = state.Login | |
log.debug "login: $logins" | |
if (state.Login.Expiration <= new Date().getTime()) { | |
def now = new Date().getTime() | |
def expiration = state.Login.Expiration | |
log.debug "expiration: $expiration, now: $now" | |
login(settings, state) | |
} | |
if (state.DeviceID == 0) { | |
getDevice(settings, state) | |
} | |
} | |
def login(settings, state) { | |
log.debug "Logging In to Webservice" | |
def logins = state.Login | |
log.debug "login: $logins" | |
def body = [ | |
username: settings.username, | |
password: settings.password, | |
] | |
callApiPost(state, "api/v4/User/Validate", body) { response -> | |
log.debug "response: $response.data" | |
state.Login = [ | |
BrandID: response.data.BrandName, | |
UserID: response.data.UserId, | |
SecToken: response.data.SecurityToken, | |
Expiration: (new Date()).getTime() + 600000 | |
] | |
log.debug "Sec Token: $state.Login.SecToken" | |
} | |
} | |
def getDevice(settings, state) { | |
log.debug "Getting MyQ Devices" | |
callApiGet(state, "api/v4/userdevicedetails/get") { response -> | |
def garageDevices = response.getData().Devices.findAll{ it.MyQDeviceTypeId == 2 } | |
if (garageDevices.isEmpty() == true) { | |
log.debug "Device Discovery found no supported door devices" | |
sendEvent( | |
name: "lastHttpStatus", | |
value: "failed", | |
displayed: true, | |
descriptionText: "Device Discovery found no supported door devices", | |
) | |
return | |
} | |
state.DeviceID = 0 | |
def doorName = "garage door" | |
if (settings.door_name != null) { | |
doorName = settings.door_name.toLowerCase() | |
} | |
garageDevices.each { pDevice -> | |
log.debug "device = $pDevice" | |
def doorAttrib = pDevice.Attributes.find{ it.AttributeDisplayName == "desc" } | |
if (doorAttrib.Value.toLowerCase() == doorName) { | |
log.debug "Door ID: $pDevice.MyQDeviceId" | |
state.DeviceID = pDevice.MyQDeviceId | |
} | |
} | |
if (state.DeviceID == 0) { | |
log.debug "Supported door devices were found but none matched name '$settings.door_name'" | |
} | |
} | |
} | |
def getDoorStatus(state, initialStatus = null, callback) { | |
callApiGet(state, "api/v4/userdevicedetails/get") { response -> | |
def garageDevices = response.getData().Devices.findAll{ it.MyQDeviceId == state.DeviceID } | |
if (garageDevices.isEmpty() == true) { | |
log.debug "Cannot find the garage door" | |
return | |
} | |
garageDevices.each { pDevice -> | |
def stateAttrib = pDevice.Attributes.find{ it.AttributeDisplayName == "doorstate" } | |
def doorState = translateDoorStatus( stateAttrib.Value, initialStatus ) | |
calcLastActivityTime(stateAttrib.UpdatedTime.toLong(), doorState) | |
callback(doorState) | |
} | |
} | |
} | |
def calcLastActivityTime(lastActivity, doorState) { | |
def currentTime = new Date().getTime() | |
def diffTotal = currentTime - lastActivity | |
def lastActLabel = "" | |
//diffTotal = (86400000 * 12) + (3600000 * 2) + (60000 * 1) | |
def diffDays = (diffTotal / 86400000) as long | |
def diffHours = (diffTotal % 86400000 / 3600000) as long | |
def diffMinutes = (diffTotal % 86400000 % 3600000 / 60000) as long | |
def diffSeconds = (diffTotal % 86400000 % 3600000 % 60000 / 1000) as long | |
if (diffDays == 1) lastActLabel += "${diffDays} Day" | |
else if (diffDays > 1) lastActLabel += "${diffDays} Days" | |
if (diffDays > 0 && diffHours > 0) lastActLabel += ", " | |
if (diffHours == 1) lastActLabel += "${diffHours} Hour" | |
else if (diffHours > 1) lastActLabel += "${diffHours} Hours" | |
if (diffDays == 0 && diffHours > 0 && diffMinutes > 0) lastActLabel += ", " | |
if (diffDays == 0 && diffMinutes == 1) lastActLabel += "${diffMinutes} Minute" | |
if (diffDays == 0 && diffMinutes > 1) lastActLabel += "${diffMinutes} Minutes" | |
if (diffTotal < 60000) lastActLabel = "${diffSeconds} Seconds" | |
sendEvent( | |
name: "lastDoorAction", | |
value: lastActLabel, | |
displayed: true, | |
descriptionText: "$doorState time is $lastActLabel", | |
) | |
} | |
def openDoor(state) { | |
def value = "1" | |
callApiPut(state, "api/v4/DeviceAttribute/PutDeviceAttribute", value) { response -> | |
// if error, do something? | |
} | |
} | |
def closeDoor(state) { | |
def value = "0" | |
callApiPut(state, "api/v4/DeviceAttribute/PutDeviceAttribute", value) { response -> | |
// if error, do something? | |
} | |
} | |
def setContactSensorState(status) { | |
// Sync contact sensor | |
if (status == "open" || status == "opening" || status == "stopped" || status == "closing") { | |
sendEvent( | |
name: "contact", | |
value: "open", | |
displayed: true, | |
descriptionText: "Contact is open", | |
) | |
sendEvent( | |
name: "switch", | |
value: "on", | |
displayed: true, | |
descriptionText: "Switch is on", | |
) | |
} else if (status == "closed") { | |
sendEvent( | |
name: "contact", | |
value: "closed", | |
displayed: true, | |
descriptionText: "Contact is closed", | |
) | |
sendEvent( | |
name: "switch", | |
value: "off", | |
displayed: true, | |
descriptionText: "Switch is off", | |
) | |
} | |
} | |
def setDoorState(status) { | |
sendEvent( | |
name: "door", | |
value: status, | |
displayed: true, | |
descriptionText: "Door is $status", | |
) | |
setContactSensorState(status) | |
} | |
def translateDoorStatus(status, initStatus = null) { | |
def dReturn = "unknown" | |
if (status == "2") dReturn = "closed" | |
else if (status == "1" || status == "9") dReturn = "open" | |
else if (status == "4" || (status == "8" && initStatus == "closed")) dReturn = "opening" | |
else if (status == "5" || (status == "8" && initStatus == "open")) dReturn = "closing" | |
else if (status == "3") dReturn = "stopped" | |
else if (status == "8" && initStatus == null) dReturn = "moving" | |
if (dReturn == "unknown") { log.debug "Unknown Door Status ID: $status" } | |
log.debug "state = $dReturn" | |
return dReturn | |
} | |
def getVarParams() { | |
def base = "https://myqexternal.myqdevice.com/" | |
if (settings.base_url != null) { | |
base = settings.base_url | |
} | |
def appId = "NWknvuBd7LoFHfXmKNMBcgajXtZEgKUh4V7WNzMidrpUUluDpVYVZx+xT4PCM5Kx" | |
if (settings.myq_app_id != null) { | |
appId = settings.myq_app_id | |
} | |
def dParams = [ | |
BaseURL: base, | |
AppId: appId, | |
BrandId: "2", | |
UserAgent: "Chamberlain/3.73", | |
ApiVersion: "4.1", | |
Culture: "en", | |
] | |
return dParams | |
} | |
def callApiPut(state, apipath, value, callback = {}) { | |
def finalHeaders = [ | |
"User-Agent": getVarParams().UserAgent, | |
"BrandId": getVarParams().BrandId, | |
"ApiVersion": getVarParams().ApiVersion, | |
"Culture": getVarParams().Culture, | |
"MyQApplicationId": getVarParams().AppId, | |
"SecurityToken": state.Login.SecToken, | |
] | |
def query = [ | |
"appId": getVarParams().AppId, | |
"SecurityToken": state.Login.SecToken, | |
] | |
def body = [ | |
"MyQDeviceId": state.DeviceID, | |
"SecurityToken": state.Login.SecToken, | |
"ApplicationId": getVarParams().AppId, | |
"AttributeValue": value, | |
"AttributeName": "desireddoorstate", | |
"format": "json", | |
"nojsoncallback": "1", | |
] | |
def finalParams = [ | |
uri: getVarParams().BaseURL, | |
path: apipath, | |
query: query, | |
headers: finalHeaders, | |
body: body, | |
] | |
//log.debug finalParams | |
try { | |
httpPutJson(finalParams) { response -> | |
log.debug response.data | |
if (response.data.ErrorMessage) { | |
log.debug "API Error: $response.data" | |
sendEvent( | |
name: "lastHttpStatus", | |
value: "failed", | |
displayed: true, | |
descriptionText: "API Error: $response.data", | |
) | |
callback(response) | |
return | |
} | |
sendEvent( | |
name: "lastHttpStatus", | |
value: "succeeded", | |
displayed: true, | |
descriptionText: "HTTP request succeeded", | |
) | |
callback(response) | |
} | |
} catch (Error e) { | |
// setDoorState("unknown") | |
sendEvent( | |
name: "lastHttpStatus", | |
value: "failed", | |
displayed: true, | |
descriptionText: "HTTP request failed: $e", | |
) | |
log.debug "API Error: $e" | |
} | |
} | |
def callApiGet(state, apipath, callback = {}) { | |
def finalHeaders = [ | |
"User-Agent": getVarParams().UserAgent, | |
"BrandId": getVarParams().BrandId, | |
"ApiVersion": getVarParams().ApiVersion, | |
"Culture": getVarParams().Culture, | |
"MyQApplicationId": getVarParams().AppId, | |
"SecurityToken": state.Login.SecToken, | |
] | |
def finalQParams = [ | |
"appId": getVarParams().AppId, | |
"SecurityToken": state.Login.SecToken, | |
"filterOn": "true", | |
"format": "json", | |
"nojsoncallback": "1", | |
] | |
def finalParams = [ | |
uri: getVarParams().BaseURL, | |
path: apipath, | |
headers: finalHeaders, | |
query: finalQParams, | |
] | |
log.debug finalParams | |
try { | |
httpGet(finalParams) { response -> | |
if (response.data.ErrorMessage) { | |
log.debug "API Error: $response.data" | |
sendEvent( | |
name: "lastHttpStatus", | |
value: "failed", | |
displayed: true, | |
descriptionText: "API Error: $response.data", | |
) | |
callback(response) | |
return | |
} | |
sendEvent( | |
name: "lastHttpStatus", | |
value: "succeeded", | |
displayed: true, | |
descriptionText: "HTTP request succeeded", | |
) | |
callback(response) | |
} | |
} catch (Error e) { | |
// setDoorState("unknown") | |
sendEvent( | |
name: "lastHttpStatus", | |
value: "failed", | |
displayed: true, | |
descriptionText: "HTTP request failed: $e", | |
) | |
log.debug "API Error: $e" | |
} | |
} | |
def callApiPost(state, apipath, body, callback = {}) { | |
def finalHeaders = [ | |
"User-Agent": getVarParams().UserAgent, | |
"BrandId": getVarParams().BrandId, | |
"ApiVersion": getVarParams().ApiVersion, | |
"Culture": getVarParams().Culture, | |
"MyQApplicationId": getVarParams().AppId, | |
] | |
def finalParams = [ | |
uri: getVarParams().BaseURL, | |
path: apipath, | |
headers: finalHeaders, | |
body: body, | |
] | |
log.debug "final params: $finalParams" | |
try { | |
httpPostJson(finalParams) { response -> | |
if (response.data.ErrorMessage) { | |
log.debug "API Error: $response.data" | |
sendEvent( | |
name: "lastHttpStatus", | |
value: "failed", | |
displayed: true, | |
descriptionText: "API Error: $response.data", | |
) | |
callback(response) | |
return | |
} | |
log.debug "Login succeed: $response.data" | |
sendEvent( | |
name: "lastHttpStatus", | |
value: "succeeded", | |
displayed: true, | |
descriptionText: "HTTP request succeeded", | |
) | |
callback(response) | |
} | |
} catch (Error e) { | |
// setDoorState("unknown") | |
sendEvent( | |
name: "lastHttpStatus", | |
value: "failed", | |
displayed: true, | |
descriptionText: "HTTP request failed: $e", | |
) | |
log.debug "API Error: $e" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment