mirror of
https://github.com/mtan93/SmartThingsPublic.git
synced 2026-03-08 05:31:56 +00:00
580 lines
16 KiB
Groovy
580 lines
16 KiB
Groovy
/**
|
|
* Copyright 2015 SmartThings
|
|
*
|
|
* 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.
|
|
*
|
|
* Withings Service Manager
|
|
*
|
|
* Author: SmartThings
|
|
* Date: 2013-09-26
|
|
*/
|
|
|
|
definition(
|
|
name: "Withings",
|
|
namespace: "smartthings",
|
|
author: "SmartThings",
|
|
description: "Connect your Withings scale to SmartThings.",
|
|
category: "Connections",
|
|
iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/withings.png",
|
|
iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png",
|
|
oauth: true,
|
|
singleInstance: true
|
|
) {
|
|
appSetting "clientId"
|
|
appSetting "clientSecret"
|
|
appSetting "serverUrl"
|
|
}
|
|
|
|
preferences {
|
|
page(name: "auth", title: "Withings", content:"authPage")
|
|
}
|
|
|
|
mappings {
|
|
path("/exchange") {
|
|
action: [
|
|
GET: "exchangeToken"
|
|
]
|
|
}
|
|
path("/load") {
|
|
action: [
|
|
GET: "load"
|
|
]
|
|
}
|
|
}
|
|
|
|
def authPage() {
|
|
log.debug "authPage()"
|
|
dynamicPage(name: "auth", title: "Withings", install:false, uninstall:true) {
|
|
section {
|
|
paragraph "This version is no longer supported. Please uninstall it."
|
|
}
|
|
}
|
|
}
|
|
|
|
def oauthInitUrl() {
|
|
def token = getToken()
|
|
//log.debug "initiateOauth got token: $token"
|
|
|
|
// store these for validate after the user takes the oauth journey
|
|
state.oauth_request_token = token.oauth_token
|
|
state.oauth_request_token_secret = token.oauth_token_secret
|
|
|
|
return buildOauthUrlWithToken(token.oauth_token, token.oauth_token_secret)
|
|
}
|
|
|
|
def getToken() {
|
|
def callback = getServerUrl() + "/api/smartapps/installations/${app.id}/exchange?access_token=${state.accessToken}"
|
|
def params = [
|
|
oauth_callback:URLEncoder.encode(callback),
|
|
]
|
|
def requestTokenBaseUrl = "https://oauth.withings.com/account/request_token"
|
|
def url = buildSignedUrl(requestTokenBaseUrl, params)
|
|
//log.debug "getToken - url: $url"
|
|
|
|
return getJsonFromUrl(url)
|
|
}
|
|
|
|
def buildOauthUrlWithToken(String token, String tokenSecret) {
|
|
def callback = getServerUrl() + "/api/smartapps/installations/${app.id}/exchange?access_token=${state.accessToken}"
|
|
def params = [
|
|
oauth_callback:URLEncoder.encode(callback),
|
|
oauth_token:token
|
|
]
|
|
def authorizeBaseUrl = "https://oauth.withings.com/account/authorize"
|
|
|
|
return buildSignedUrl(authorizeBaseUrl, params, tokenSecret)
|
|
}
|
|
|
|
/////////////////////////////////////////
|
|
/////////////////////////////////////////
|
|
// vvv vvv OAuth 1.0 vvv vvv //
|
|
/////////////////////////////////////////
|
|
/////////////////////////////////////////
|
|
String buildSignedUrl(String baseUrl, Map urlParams, String tokenSecret="") {
|
|
def params = [
|
|
oauth_consumer_key: smartThingsConsumerKey,
|
|
oauth_nonce: nonce(),
|
|
oauth_signature_method: "HMAC-SHA1",
|
|
oauth_timestamp: timestampInSeconds(),
|
|
oauth_version: 1.0
|
|
] + urlParams
|
|
def signatureBaseString = ["GET", baseUrl, toQueryString(params)].collect { URLEncoder.encode(it) }.join("&")
|
|
|
|
params.oauth_signature = hmac(signatureBaseString, getSmartThingsConsumerSecret(), tokenSecret)
|
|
|
|
// query string is different from what is used in generating the signature above b/c it includes "oauth_signature"
|
|
def url = [baseUrl, toQueryString(params)].join('?')
|
|
return url
|
|
}
|
|
|
|
String nonce() {
|
|
return UUID.randomUUID().toString().replaceAll("-", "")
|
|
}
|
|
|
|
Integer timestampInSeconds() {
|
|
return (int)(new Date().time/1000)
|
|
}
|
|
|
|
String toQueryString(Map m) {
|
|
return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
|
|
}
|
|
|
|
String hmac(String dataString, String consumerSecret, String tokenSecret="") throws java.security.SignatureException {
|
|
String result
|
|
|
|
def key = [consumerSecret, tokenSecret].join('&')
|
|
|
|
// get an hmac_sha1 key from the raw key bytes
|
|
def signingKey = new javax.crypto.spec.SecretKeySpec(key.getBytes(), "HmacSHA1")
|
|
|
|
// get an hmac_sha1 Mac instance and initialize with the signing key
|
|
def mac = javax.crypto.Mac.getInstance("HmacSHA1")
|
|
mac.init(signingKey)
|
|
|
|
// compute the hmac on input data bytes
|
|
byte[] rawHmac = mac.doFinal(dataString.getBytes())
|
|
|
|
result = org.apache.commons.codec.binary.Base64.encodeBase64String(rawHmac)
|
|
|
|
return result
|
|
}
|
|
/////////////////////////////////////////
|
|
/////////////////////////////////////////
|
|
// ^^^ ^^^ OAuth 1.0 ^^^ ^^^ //
|
|
/////////////////////////////////////////
|
|
/////////////////////////////////////////
|
|
|
|
/////////////////////////////////////////
|
|
/////////////////////////////////////////
|
|
// vvv vvv rest vvv vvv //
|
|
/////////////////////////////////////////
|
|
/////////////////////////////////////////
|
|
|
|
protected rest(Map params) {
|
|
new physicalgraph.device.RestAction(params)
|
|
}
|
|
|
|
/////////////////////////////////////////
|
|
/////////////////////////////////////////
|
|
// ^^^ ^^^ rest ^^^ ^^^ //
|
|
/////////////////////////////////////////
|
|
/////////////////////////////////////////
|
|
|
|
def exchangeToken() {
|
|
// oauth_token=abcd
|
|
// &userid=123
|
|
|
|
def newToken = params.oauth_token
|
|
def userid = params.userid
|
|
def tokenSecret = state.oauth_request_token_secret
|
|
|
|
def params = [
|
|
oauth_token: newToken,
|
|
userid: userid
|
|
]
|
|
|
|
def requestTokenBaseUrl = "https://oauth.withings.com/account/access_token"
|
|
def url = buildSignedUrl(requestTokenBaseUrl, params, tokenSecret)
|
|
//log.debug "signed url: $url with secret $tokenSecret"
|
|
|
|
def token = getJsonFromUrl(url)
|
|
|
|
state.userid = userid
|
|
state.oauth_token = token.oauth_token
|
|
state.oauth_token_secret = token.oauth_token_secret
|
|
|
|
log.debug "swapped token"
|
|
|
|
def location = getServerUrl() + "/api/smartapps/installations/${app.id}/load?access_token=${state.accessToken}"
|
|
redirect(location:location)
|
|
}
|
|
|
|
def load() {
|
|
def json = get(getMeasurement(new Date() - 30))
|
|
// removed logging of actual json payload. Can be put back for debugging
|
|
log.debug "swapped, then received json"
|
|
parse(data:json)
|
|
|
|
def html = """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta name="viewport" content="width=640">
|
|
<title>Withings Connection</title>
|
|
<style type="text/css">
|
|
@font-face {
|
|
font-family: 'Swiss 721 W01 Thin';
|
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
|
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
|
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
|
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
|
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
|
|
font-weight: normal;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Swiss 721 W01 Light';
|
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
|
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
|
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
|
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
|
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
|
|
font-weight: normal;
|
|
font-style: normal;
|
|
}
|
|
.container {
|
|
width: 560px;
|
|
padding: 40px;
|
|
/*background: #eee;*/
|
|
text-align: center;
|
|
}
|
|
img {
|
|
vertical-align: middle;
|
|
}
|
|
img:nth-child(2) {
|
|
margin: 0 30px;
|
|
}
|
|
p {
|
|
font-size: 2.2em;
|
|
font-family: 'Swiss 721 W01 Thin';
|
|
text-align: center;
|
|
color: #666666;
|
|
padding: 0 40px;
|
|
margin-bottom: 0;
|
|
}
|
|
/*
|
|
p:last-child {
|
|
margin-top: 0px;
|
|
}
|
|
*/
|
|
span {
|
|
font-family: 'Swiss 721 W01 Light';
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<img src="https://s3.amazonaws.com/smartapp-icons/Partner/withings@2x.png" alt="withings icon" />
|
|
<img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected device icon" />
|
|
<img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings logo" />
|
|
<p>Your Withings scale is now connected to SmartThings!</p>
|
|
<p>Click 'Done' to finish setup.</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
render contentType: 'text/html', data: html
|
|
}
|
|
|
|
Map getJsonFromUrl(String url) {
|
|
return [:] // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
|
|
|
|
def jsonString
|
|
httpGet(uri: url) { resp ->
|
|
jsonString = resp.data.toString()
|
|
}
|
|
|
|
return getJsonFromText(jsonString)
|
|
}
|
|
|
|
Map getJsonFromText(String jsonString) {
|
|
def jsonMap = jsonString.split("&").inject([:]) { c, it ->
|
|
def parts = it.split('=')
|
|
def k = parts[0]
|
|
def v = parts[1]
|
|
c[k] = v
|
|
return c
|
|
}
|
|
|
|
return jsonMap
|
|
}
|
|
|
|
def getMeasurement(Date since=null) {
|
|
return null // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
|
|
|
|
// TODO: add startdate and enddate ... esp. when in response to notify
|
|
def params = [
|
|
action:"getmeas",
|
|
oauth_consumer_key:getSmartThingsConsumerKey(),
|
|
oauth_nonce:nonce(),
|
|
oauth_signature_method:"HMAC-SHA1",
|
|
oauth_timestamp:timestampInSeconds(),
|
|
oauth_token:state.oauth_token,
|
|
oauth_version:1.0,
|
|
userid: state.userid
|
|
]
|
|
|
|
if(since)
|
|
{
|
|
params.startdate = dateToSeconds(since)
|
|
}
|
|
|
|
def requestTokenBaseUrl = "http://wbsapi.withings.net/measure"
|
|
def signatureBaseString = ["GET", requestTokenBaseUrl, toQueryString(params)].collect { URLEncoder.encode(it) }.join("&")
|
|
|
|
params.oauth_signature = hmac(signatureBaseString, getSmartThingsConsumerSecret(), state.oauth_token_secret)
|
|
|
|
return rest(
|
|
method: 'GET',
|
|
endpoint: "http://wbsapi.withings.net",
|
|
path: "/measure",
|
|
query: params,
|
|
synchronous: true
|
|
)
|
|
|
|
}
|
|
|
|
String get(measurementRestAction) {
|
|
return "" // stop making requests to Withings API. This entire SmartApp will be replaced with a fix
|
|
|
|
def httpGetParams = [
|
|
uri: measurementRestAction.endpoint,
|
|
path: measurementRestAction.path,
|
|
query: measurementRestAction.query
|
|
]
|
|
|
|
String json
|
|
httpGet(httpGetParams) {resp ->
|
|
json = resp.data.text.toString()
|
|
}
|
|
|
|
return json
|
|
}
|
|
|
|
def parse(Map response) {
|
|
def json = new org.codehaus.groovy.grails.web.json.JSONObject(response.data)
|
|
parseJson(json)
|
|
}
|
|
|
|
def parseJson(json) {
|
|
log.debug "parseJson: $json"
|
|
|
|
def lastDataPointMillis = (state.lastDataPointMillis ?: 0).toLong()
|
|
def i = 0
|
|
|
|
if(json.status == 0)
|
|
{
|
|
log.debug "parseJson measure group size: ${json.body.measuregrps.size()}"
|
|
|
|
state.errorCount = 0
|
|
|
|
def childDni = getWithingsDevice(json.body.measuregrps).deviceNetworkId
|
|
|
|
def latestMillis = lastDataPointMillis
|
|
json.body.measuregrps.sort { it.date }.each { group ->
|
|
|
|
def measurementDateSeconds = group.date
|
|
def dataPointMillis = measurementDateSeconds * 1000L
|
|
|
|
if(dataPointMillis > lastDataPointMillis)
|
|
{
|
|
group.measures.each { measure ->
|
|
i++
|
|
saveMeasurement(childDni, measure, measurementDateSeconds)
|
|
}
|
|
}
|
|
|
|
if(dataPointMillis > latestMillis)
|
|
{
|
|
latestMillis = dataPointMillis
|
|
}
|
|
|
|
}
|
|
|
|
if(latestMillis > lastDataPointMillis)
|
|
{
|
|
state.lastDataPointMillis = latestMillis
|
|
}
|
|
|
|
def weightData = state.findAll { it.key.startsWith("measure.") }
|
|
|
|
// remove old data
|
|
def old = "measure." + (new Date() - 30).format('yyyy-MM-dd')
|
|
state.findAll { it.key.startsWith("measure.") && it.key < old }.collect { it.key }.each { state.remove(it) }
|
|
}
|
|
else
|
|
{
|
|
def errorCount = (state.errorCount ?: 0).toInteger()
|
|
state.errorCount = errorCount + 1
|
|
|
|
// TODO: If we poll, consider waiting for a couple failures before showing an error
|
|
// But if we are only notified, then we need to raise the error right away
|
|
measurementError(json.status)
|
|
}
|
|
|
|
log.debug "Done adding $i measurements"
|
|
return
|
|
}
|
|
|
|
def measurementError(status) {
|
|
log.error "received api response status ${status}"
|
|
sendEvent(state.childDni, [name: "connection", value:"Connection error: ${status}", isStateChange:true, displayed:true])
|
|
}
|
|
|
|
def saveMeasurement(childDni, measure, measurementDateSeconds) {
|
|
def dateString = secondsToDate(measurementDateSeconds).format('yyyy-MM-dd')
|
|
|
|
def measurement = withingsEvent(measure)
|
|
sendEvent(state.childDni, measurement + [date:dateString], [dateCreated:secondsToDate(measurementDateSeconds)])
|
|
|
|
log.debug "sm: ${measure.type} (${measure.type == 1})"
|
|
|
|
if(measure.type == 6)
|
|
{
|
|
sendEvent(state.childDni, [name: "leanRatio", value:(100-measurement.value), date:dateString, isStateChange:true, display:true], [dateCreated:secondsToDate(measurementDateSeconds)])
|
|
}
|
|
else if(measure.type == 1)
|
|
{
|
|
state["measure." + dateString] = measurement.value
|
|
}
|
|
}
|
|
|
|
def eventValue(measure, roundDigits=1) {
|
|
def value = measure.value * 10.power(measure.unit)
|
|
|
|
if(roundDigits != null)
|
|
{
|
|
def significantDigits = 10.power(roundDigits)
|
|
value = (value * significantDigits).toInteger() / significantDigits
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
def withingsEvent(measure) {
|
|
def withingsTypes = [
|
|
(1):"weight",
|
|
(4):"height",
|
|
(5):"leanMass",
|
|
(6):"fatRatio",
|
|
(8):"fatMass",
|
|
(11):"pulse"
|
|
]
|
|
|
|
def value = eventValue(measure, (measure.type == 4 ? null : 1))
|
|
|
|
if(measure.type == 1) {
|
|
value *= 2.20462
|
|
} else if(measure.type == 4) {
|
|
value *= 39.3701
|
|
}
|
|
|
|
log.debug "m:${measure.type}, v:${value}"
|
|
|
|
return [
|
|
name: withingsTypes[measure.type],
|
|
value: value
|
|
]
|
|
}
|
|
|
|
Integer dateToSeconds(Date d) {
|
|
return d.time / 1000
|
|
}
|
|
|
|
Date secondsToDate(Number seconds) {
|
|
return new Date(seconds * 1000L)
|
|
}
|
|
|
|
def getWithingsDevice(measuregrps=null) {
|
|
// unfortunately, Withings doesn't seem to give us enough information to know which device(s) they have,
|
|
// ... so we have to guess and create a single device
|
|
|
|
if(state.childDni)
|
|
{
|
|
return getChildDevice(state.childDni)
|
|
}
|
|
else
|
|
{
|
|
def children = getChildDevices()
|
|
if(children.size() > 0)
|
|
{
|
|
return children[0]
|
|
}
|
|
else
|
|
{
|
|
// no child yet, create one
|
|
def dni = [app.id, UUID.randomUUID().toString()].join('.')
|
|
state.childDni = dni
|
|
|
|
def childDeviceType = getBodyAnalyzerChildName()
|
|
|
|
if(measuregrps)
|
|
{
|
|
def hasNoHeartRate = measuregrps.find { grp -> grp.measures.find { it.type == 11 } } == null
|
|
if(hasNoHeartRate)
|
|
{
|
|
childDeviceType = getScaleChildName()
|
|
}
|
|
}
|
|
|
|
def child = addChildDevice(getChildNamespace(), childDeviceType, dni, null, [label:"Withings"])
|
|
state.childId = child.id
|
|
return child
|
|
}
|
|
}
|
|
}
|
|
|
|
def installed() {
|
|
log.debug "Installed with settings: ${settings}"
|
|
initialize()
|
|
}
|
|
|
|
def updated() {
|
|
log.debug "Updated with settings: ${settings}"
|
|
unsubscribe()
|
|
initialize()
|
|
}
|
|
|
|
def initialize() {
|
|
// TODO: subscribe to attributes, devices, locations, etc.
|
|
}
|
|
|
|
def poll() {
|
|
if(shouldPoll())
|
|
{
|
|
return getMeasurement()
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
def shouldPoll() {
|
|
def lastPollString = state.lastPollMillisString
|
|
def lastPoll = lastPollString?.isNumber() ? lastPollString.toLong() : 0
|
|
def ONE_HOUR = 60 * 60 * 1000
|
|
|
|
def time = new Date().time
|
|
|
|
if(time > (lastPoll + ONE_HOUR))
|
|
{
|
|
log.debug "Executing poll b/c (now > last + 1hr): ${time} > ${lastPoll + ONE_HOUR} (last: ${lastPollString})"
|
|
state.lastPollMillisString = time
|
|
|
|
return true
|
|
}
|
|
|
|
log.debug "skipping poll b/c !(now > last + 1hr): ${time} > ${lastPoll + ONE_HOUR} (last: ${lastPollString})"
|
|
return false
|
|
}
|
|
|
|
def refresh() {
|
|
log.debug "Executing 'refresh'"
|
|
return getMeasurement()
|
|
}
|
|
|
|
def getChildNamespace() { "smartthings" }
|
|
def getScaleChildName() { "Wireless Scale" }
|
|
def getBodyAnalyzerChildName() { "Smart Body Analyzer" }
|
|
|
|
def getServerUrl() { appSettings.serverUrl }
|
|
def getSmartThingsConsumerKey() { appSettings.clientId }
|
|
def getSmartThingsConsumerSecret() { appSettings.clientSecret }
|