DVCSMP-2475 Netatmo Fixes (#1717)

- Adds logic to invalidate auth tokens
- Adds checks for auth tokens before making API requests
- Improves SmartApp logic to fix execution timeout exceptions
- Improves refresh token logic
- Fixes API response parsing bugs
- Adds auth and refresh token to atomicState rather than state
- Preference / OAuth wording changes
This commit is contained in:
Jason Botello
2017-02-27 13:51:06 -06:00
committed by Vinay Rao
parent d6c85436d2
commit 1c83a27c40

View File

@@ -5,7 +5,6 @@ import java.text.DecimalFormat
import groovy.json.JsonSlurper import groovy.json.JsonSlurper
private getApiUrl() { "https://api.netatmo.com" } private getApiUrl() { "https://api.netatmo.com" }
private getVendorName() { "netatmo" }
private getVendorAuthPath() { "${apiUrl}/oauth2/authorize?" } private getVendorAuthPath() { "${apiUrl}/oauth2/authorize?" }
private getVendorTokenPath(){ "${apiUrl}/oauth2/token" } private getVendorTokenPath(){ "${apiUrl}/oauth2/token" }
private getVendorIcon() { "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1%402x.png" } private getVendorIcon() { "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1%402x.png" }
@@ -14,14 +13,13 @@ private getClientSecret() { appSettings.clientSecret }
private getServerUrl() { appSettings.serverUrl } private getServerUrl() { appSettings.serverUrl }
private getShardUrl() { return getApiServerUrl() } private getShardUrl() { return getApiServerUrl() }
private getCallbackUrl() { "${serverUrl}/oauth/callback" } private getCallbackUrl() { "${serverUrl}/oauth/callback" }
private getBuildRedirectUrl() { "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}&apiServerUrl=${shardUrl}" } private getBuildRedirectUrl() { "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" }
// Automatically generated. Make future change here.
definition( definition(
name: "Netatmo (Connect)", name: "Netatmo (Connect)",
namespace: "dianoga", namespace: "dianoga",
author: "Brian Steere", author: "Brian Steere",
description: "Netatmo Integration", description: "Integrate your Netatmo devices with SmartThings",
category: "SmartThings Labs", category: "SmartThings Labs",
iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1.png", iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1.png",
iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1%402x.png", iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1%402x.png",
@@ -44,15 +42,16 @@ mappings {
} }
def authPage() { def authPage() {
log.debug "In authPage" // log.debug "running authPage()"
def description def description
def uninstallAllowed = false def uninstallAllowed = false
def oauthTokenProvided = false def oauthTokenProvided = false
if (!state.accessToken) { // If an access token doesn't exist, create one
log.debug "About to create access token." if (!atomicState.accessToken) {
state.accessToken = createAccessToken() atomicState.accessToken = createAccessToken()
log.debug "Created access token"
} }
if (canInstallLabs()) { if (canInstallLabs()) {
@@ -60,36 +59,32 @@ def authPage() {
def redirectUrl = getBuildRedirectUrl() def redirectUrl = getBuildRedirectUrl()
// log.debug "Redirect url = ${redirectUrl}" // log.debug "Redirect url = ${redirectUrl}"
if (state.authToken) { if (atomicState.authToken) {
description = "Tap 'Next' to proceed" description = "Tap 'Next' to select devices"
uninstallAllowed = true uninstallAllowed = true
oauthTokenProvided = true oauthTokenProvided = true
} else { } else {
description = "Click to enter Credentials." description = "Tap to enter credentials"
} }
if (!oauthTokenProvided) { if (!oauthTokenProvided) {
log.debug "Show the login page" log.debug "Showing the login page"
return dynamicPage(name: "Credentials", title: "Authorize Connection", nextPage:"listDevices", uninstall: uninstallAllowed, install:false) { return dynamicPage(name: "Credentials", title: "Authorize Connection", nextPage:"listDevices", uninstall: uninstallAllowed, install:false) {
section() { section() {
paragraph "Tap below to log in to the netatmo and authorize SmartThings access." paragraph "Tap below to login to Netatmo and authorize SmartThings access"
href url:redirectUrl, style:"embedded", required:false, title:"Connect to ${getVendorName()}", description:description href url:redirectUrl, style:"embedded", required:false, title:"Connect to Netatmo", description:description
} }
} }
} else { } else {
log.debug "Show the devices page" log.debug "Showing the devices page"
return dynamicPage(name: "Credentials", title: "Credentials Accepted!", nextPage:"listDevices", uninstall: uninstallAllowed, install:false) { return dynamicPage(name: "Credentials", title: "Connected", nextPage:"listDevices", uninstall: uninstallAllowed, install:false) {
section() { section() {
input(name:"Devices", style:"embedded", required:false, title:"${getVendorName()} is now connected to SmartThings!", description:description) input(name:"Devices", style:"embedded", required:false, title:"Netatmo is connected to SmartThings", description:description)
} }
} }
} }
} else { } else {
def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub"."""
To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub"."""
return dynamicPage(name:"Credentials", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { return dynamicPage(name:"Credentials", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) {
section { section {
paragraph "$upgradeNeeded" paragraph "$upgradeNeeded"
@@ -100,15 +95,15 @@ To update your Hub, access Location Settings in the Main Menu (tap the gear next
} }
def oauthInitUrl() { def oauthInitUrl() {
log.debug "In oauthInitUrl" // log.debug "runing oauthInitUrl()"
state.oauthInitState = UUID.randomUUID().toString() atomicState.oauthInitState = UUID.randomUUID().toString()
def oauthParams = [ def oauthParams = [
response_type: "code", response_type: "code",
client_id: getClientId(), client_id: getClientId(),
client_secret: getClientSecret(), client_secret: getClientSecret(),
state: state.oauthInitState, state: atomicState.oauthInitState,
redirect_uri: getCallbackUrl(), redirect_uri: getCallbackUrl(),
scope: "read_station" scope: "read_station"
] ]
@@ -119,78 +114,72 @@ def oauthInitUrl() {
} }
def callback() { def callback() {
// log.debug "callback()>> params: $params, params.code ${params.code}" // log.debug "running callback()"
def code = params.code def code = params.code
def oauthState = params.state def oauthState = params.state
if (oauthState == state.oauthInitState) { if (oauthState == atomicState.oauthInitState) {
def tokenParams = [ def tokenParams = [
grant_type: "authorization_code",
client_secret: getClientSecret(), client_secret: getClientSecret(),
client_id : getClientId(), client_id : getClientId(),
grant_type: "authorization_code",
redirect_uri: getCallbackUrl(),
code: code, code: code,
scope: "read_station" scope: "read_station",
redirect_uri: getCallbackUrl()
] ]
// log.debug "TOKEN URL: ${getVendorTokenPath() + toQueryString(tokenParams)}" // log.debug "TOKEN URL: ${getVendorTokenPath() + toQueryString(tokenParams)}"
def tokenUrl = getVendorTokenPath() def tokenUrl = getVendorTokenPath()
def params = [ def requestTokenParams = [
uri: tokenUrl, uri: tokenUrl,
contentType: 'application/x-www-form-urlencoded', requestContentType: 'application/x-www-form-urlencoded',
body: tokenParams body: tokenParams
] ]
// log.debug "PARAMS: ${params}" // log.debug "PARAMS: ${requestTokenParams}"
try { try {
httpPost(params) { resp -> httpPost(requestTokenParams) { resp ->
//log.debug "Data: ${resp.data}"
def slurper = new JsonSlurper() atomicState.refreshToken = resp.data.refresh_token
atomicState.authToken = resp.data.access_token
resp.data.each { key, value -> // resp.data.expires_in is in milliseconds so we need to convert it to seconds
def data = slurper.parseText(key) atomicState.tokenExpires = now() + (resp.data.expires_in * 1000)
log.debug "Data: $data"
state.refreshToken = data.refresh_token
state.authToken = data.access_token
//state.accessToken = data.access_token
state.tokenExpires = now() + (data.expires_in * 1000)
// log.debug "swapped token: $resp.data"
}
} }
} catch (Exception e) { } catch (e) {
log.debug "callback: Call failed $e" log.debug "callback() failed: $e"
} }
// Handle success and failure here, and render stuff accordingly // If we successfully got an authToken run sucess(), else fail()
if (state.authToken) { if (atomicState.authToken) {
success() success()
} else { } else {
fail() fail()
} }
} else { } else {
log.error "callback() failed oauthState != state.oauthInitState" log.error "callback() failed oauthState != atomicState.oauthInitState"
} }
} }
def success() { def success() {
log.debug "in success" log.debug "OAuth flow succeeded"
def message = """ def message = """
<p>We have located your """ + getVendorName() + """ account.</p> <p>Success!</p>
<p>Tap 'Done' to continue to Devices.</p> <p>Tap 'Done' to continue</p>
""" """
connectionStatus(message) connectionStatus(message)
} }
def fail() { def fail() {
log.debug "in fail" log.debug "OAuth flow failed"
atomicState.authToken = null
def message = """ def message = """
<p>The connection could not be established!</p> <p>Error</p>
<p>Click 'Done' to return to the menu.</p> <p>Tap 'Done' to return</p>
""" """
connectionStatus(message) connectionStatus(message)
} }
@@ -202,13 +191,12 @@ def connectionStatus(message, redirectUrl = null) {
<meta http-equiv="refresh" content="3; url=${redirectUrl}" /> <meta http-equiv="refresh" content="3; url=${redirectUrl}" />
""" """
} }
def html = """ def html = """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>${getVendorName()} Connection</title> <title>Netatmo Connection</title>
<style type="text/css"> <style type="text/css">
* { box-sizing: border-box; } * { box-sizing: border-box; }
@font-face { @font-face {
@@ -234,7 +222,6 @@ def connectionStatus(message, redirectUrl = null) {
.container { .container {
width: 100%; width: 100%;
padding: 40px; padding: 40px;
/*background: #eee;*/
text-align: center; text-align: center;
} }
img { img {
@@ -250,14 +237,9 @@ def connectionStatus(message, redirectUrl = null) {
color: #666666; color: #666666;
margin-bottom: 0; margin-bottom: 0;
} }
/*
p:last-child {
margin-top: 0px;
}
*/
span { span {
font-family: 'Swiss 721 W01 Light'; font-family: 'Swiss 721 W01 Light';
} }
</style> </style>
</head> </head>
<body> <body>
@@ -274,46 +256,47 @@ def connectionStatus(message, redirectUrl = null) {
} }
def refreshToken() { def refreshToken() {
log.debug "In refreshToken" // Check if atomicState has a refresh token
if (atomicState.refreshToken) {
log.debug "running refreshToken()"
def oauthParams = [ def oauthParams = [
client_secret: getClientSecret(), grant_type: "refresh_token",
client_id: getClientId(), refresh_token: atomicState.refreshToken,
grant_type: "refresh_token", client_secret: getClientSecret(),
refresh_token: state.refreshToken client_id: getClientId(),
] ]
def tokenUrl = getVendorTokenPath() def tokenUrl = getVendorTokenPath()
def params = [
uri: tokenUrl, def requestOauthParams = [
contentType: 'application/x-www-form-urlencoded', uri: tokenUrl,
body: oauthParams, requestContentType: 'application/x-www-form-urlencoded',
] body: oauthParams
]
// log.debug "PARAMS: ${requestOauthParams}"
// OAuth Step 2: Request access token with our client Secret and OAuth "Code" try {
try { httpPost(requestOauthParams) { resp ->
httpPost(params) { response -> //log.debug "Data: ${resp.data}"
def slurper = new JsonSlurper(); atomicState.refreshToken = resp.data.refresh_token
atomicState.authToken = resp.data.access_token
// resp.data.expires_in is in milliseconds so we need to convert it to seconds
atomicState.tokenExpires = now() + (resp.data.expires_in * 1000)
return true
}
} catch (e) {
log.debug "refreshToken() failed: $e"
}
response.data.each {key, value -> // If we didn't get an authToken
def data = slurper.parseText(key); if (!atomicState.authToken) {
// log.debug "Data: $data" return false
}
state.refreshToken = data.refresh_token } else {
state.accessToken = data.access_token return false
state.tokenExpires = now() + (data.expires_in * 1000) }
return true
}
}
} catch (Exception e) {
log.debug "Error: $e"
}
// We didn't get an access token
if ( !state.accessToken ) {
return false
}
} }
String toQueryString(Map m) { String toQueryString(Map m) {
@@ -322,13 +305,11 @@ String toQueryString(Map m) {
def installed() { def installed() {
log.debug "Installed with settings: ${settings}" log.debug "Installed with settings: ${settings}"
initialize() initialize()
} }
def updated() { def updated() {
log.debug "Updated with settings: ${settings}" log.debug "Updated with settings: ${settings}"
unsubscribe() unsubscribe()
unschedule() unschedule()
initialize() initialize()
@@ -336,9 +317,9 @@ def updated() {
def initialize() { def initialize() {
log.debug "Initialized with settings: ${settings}" log.debug "Initialized with settings: ${settings}"
// Pull the latest device info into state // Pull the latest device info into state
getDeviceList(); getDeviceList()
settings.devices.each { settings.devices.each {
def deviceId = it def deviceId = it
@@ -372,57 +353,56 @@ def initialize() {
def delete = getChildDevices().findAll { !settings.devices.contains(it.deviceNetworkId) } def delete = getChildDevices().findAll { !settings.devices.contains(it.deviceNetworkId) }
log.debug "Delete: $delete" log.debug "Delete: $delete"
delete.each { deleteChildDevice(it.deviceNetworkId) } delete.each { deleteChildDevice(it.deviceNetworkId) }
// Do the initial poll // Run initial poll and schedule future polls
poll() poll()
// Schedule it to run every 5 minutes
runEvery5Minutes("poll") runEvery5Minutes("poll")
} }
def uninstalled() { def uninstalled() {
log.debug "In uninstalled" log.debug "Uninstalling"
removeChildDevices(getChildDevices()) removeChildDevices(getChildDevices())
} }
def getDeviceList() { def getDeviceList() {
log.debug "In getDeviceList" if (atomicState.authToken) {
log.debug "Getting stations data"
def deviceList = [:] def deviceList = [:]
state.deviceDetail = [:] state.deviceDetail = [:]
state.deviceState = [:] state.deviceState = [:]
apiGet("/api/getstationsdata") { response -> apiGet("/api/getstationsdata") { resp ->
response.data.body.devices.each { value -> resp.data.body.devices.each { value ->
def key = value._id def key = value._id
deviceList[key] = "${value.station_name}: ${value.module_name}" deviceList[key] = "${value.station_name}: ${value.module_name}"
state.deviceDetail[key] = value state.deviceDetail[key] = value
state.deviceState[key] = value.dashboard_data state.deviceState[key] = value.dashboard_data
value.modules.each { value2 -> value.modules.each { value2 ->
def key2 = value2._id def key2 = value2._id
deviceList[key2] = "${value.station_name}: ${value2.module_name}" deviceList[key2] = "${value.station_name}: ${value2.module_name}"
state.deviceDetail[key2] = value2 state.deviceDetail[key2] = value2
state.deviceState[key2] = value2.dashboard_data state.deviceState[key2] = value2.dashboard_data
}
} }
} }
}
return deviceList.sort() { it.value.toLowerCase() }
return deviceList.sort() { it.value.toLowerCase() }
} else {
return null
}
} }
private removeChildDevices(delete) { private removeChildDevices(delete) {
log.debug "In removeChildDevices" log.debug "Removing ${delete.size()} devices"
log.debug "deleting ${delete.size()} devices"
delete.each { delete.each {
deleteChildDevice(it.deviceNetworkId) deleteChildDevice(it.deviceNetworkId)
} }
} }
def createChildDevice(deviceFile, dni, name, label) { def createChildDevice(deviceFile, dni, name, label) {
log.debug "In createChildDevice"
try { try {
def existingDevice = getChildDevice(dni) def existingDevice = getChildDevice(dni)
if(!existingDevice) { if(!existingDevice) {
@@ -437,13 +417,13 @@ def createChildDevice(deviceFile, dni, name, label) {
} }
def listDevices() { def listDevices() {
log.debug "In listDevices" log.debug "Listing devices"
def devices = getDeviceList() def devices = getDeviceList()
dynamicPage(name: "listDevices", title: "Choose devices", install: true) { dynamicPage(name: "listDevices", title: "Choose Devices", install: true) {
section("Devices") { section("Devices") {
input "devices", "enum", title: "Select Device(s)", required: false, multiple: true, options: devices input "devices", "enum", title: "Select Devices", required: false, multiple: true, options: devices
} }
section("Preferences") { section("Preferences") {
@@ -453,36 +433,37 @@ def listDevices() {
} }
def apiGet(String path, Map query, Closure callback) { def apiGet(String path, Map query, Closure callback) {
log.debug "running apiGet()"
if(now() >= state.tokenExpires) {
refreshToken(); // If the current time is over the expiration time, request a new token
if(now() >= atomicState.tokenExpires) {
atomicState.authToken = null
refreshToken()
} }
query['access_token'] = state.accessToken def queryParam = [
def params = [ access_token: atomicState.authToken
]
def apiGetParams = [
uri: getApiUrl(), uri: getApiUrl(),
path: path, path: path,
'query': query query: queryParam
] ]
// log.debug "API Get: $params"
// log.debug "apiGet(): $apiGetParams"
try { try {
httpGet(params) { response -> httpGet(apiGetParams) { resp ->
callback.call(response) callback.call(resp)
}
} catch (e) {
log.debug "apiGet() failed: $e"
// Netatmo API has rate limits so a failure here doesn't necessarily mean our token has expired, but we will check anyways
if(now() >= atomicState.tokenExpires) {
atomicState.authToken = null
refreshToken()
} }
} catch (Exception e) {
// This is most likely due to an invalid token. Try to refresh it and try again.
log.debug "apiGet: Call failed $e"
if(refreshToken()) {
log.debug "apiGet: Trying again after refreshing token"
try {
httpGet(params) { response ->
callback.call(response)
}
} catch (Exception f) {
log.debug "apiGet: Call failed $f"
}
}
} }
} }
@@ -491,10 +472,12 @@ def apiGet(String path, Closure callback) {
} }
def poll() { def poll() {
log.debug "In Poll" log.debug "Polling..."
getDeviceList();
getDeviceList()
def children = getChildDevices() def children = getChildDevices()
log.debug "State: ${state.deviceState}" //log.debug "State: ${state.deviceState}"
settings.devices.each { deviceId -> settings.devices.each { deviceId ->
def detail = state?.deviceDetail[deviceId] def detail = state?.deviceDetail[deviceId]
@@ -550,15 +533,13 @@ def rainToPref(rain) {
} }
def debugEvent(message, displayEvent) { def debugEvent(message, displayEvent) {
def results = [ def results = [
name: "appdebug", name: "appdebug",
descriptionText: message, descriptionText: message,
displayed: displayEvent displayed: displayEvent
] ]
log.debug "Generating AppDebug Event: ${results}" log.debug "Generating AppDebug Event: ${results}"
sendEvent (results) sendEvent(results)
} }
private Boolean canInstallLabs() { private Boolean canInstallLabs() {