MyQ Garage SmartThings device handler
/** | |
* 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