From dcfc16cf12ca00d0b4eaef6a339ec2868dca2704 Mon Sep 17 00:00:00 2001 From: Oso Technologies Date: Fri, 23 Oct 2015 10:01:19 -0500 Subject: [PATCH] MSA-640: Updated version of the deviceType and SmartApp that now uses % moisture instead of fuel %. Improved server api performance. Previously worked with Donald Kirker and Shilpa Mathew. Needed vars for testing: client_id = 6479182133460992 clident_secret = a0c318b6-042f-4a91-8f56-654a6cc37c9a https_plantLinkServer = https://hardware-dot-oso-tech.appspot.com --- .../osotech/plantlink.src/plantlink.groovy | 163 ++++++++ .../plantlink-connector.groovy | 389 ++++++++++++++++++ 2 files changed, 552 insertions(+) create mode 100644 devicetypes/osotech/plantlink.src/plantlink.groovy create mode 100644 smartapps/osotech/plantlink-connector.src/plantlink-connector.groovy diff --git a/devicetypes/osotech/plantlink.src/plantlink.groovy b/devicetypes/osotech/plantlink.src/plantlink.groovy new file mode 100644 index 0000000..b1eb898 --- /dev/null +++ b/devicetypes/osotech/plantlink.src/plantlink.groovy @@ -0,0 +1,163 @@ +/** + * PlantLink + * + * This device type takes sensor data and converts it into a json packet to send to myplantlink.com + * where its values will be computed for soil and plant type to show user readable values of how your + * specific plant is doing. + * + * + * Copyright 2015 Oso Technologies + * + * 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. + * + */ + +import groovy.json.JsonBuilder + +metadata { + definition (name: "PlantLink", namespace: "OsoTech", author: "Oso Technologies") { + capability "Sensor" + + command "setStatusIcon" + command "setPlantFuelLevel" + command "setBatteryLevel" + command "setInstallSmartApp" + + attribute "plantStatus","string" + attribute "plantFuelLevel","number" + attribute "linkBatteryLevel","string" + attribute "installSmartApp","string" + + fingerprint profileId: "0104", inClusters: "0000,0001,0B04" + } + + simulator { + status "battery": "read attr - raw: C0720100010A000021340A, dni: C072, endpoint: 01, cluster: 0001, size: 0A, attrId: 0000, encoding: 21, value: 0a34" + status "moisture": "read attr - raw: C072010B040A0001290000, dni: C072, endpoint: 01, cluster: 0B04, size: 0A, attrId: 0100, encoding: 29, value: 0000" + } + + tiles { + standardTile("Title", "device.label") { + state("label", label:'PlantLink ${device.label}') + } + + valueTile("plantMoistureTile", "device.plantFuelLevel", width: 1, height: 1) { + state("plantMoisture", label: '${currentValue}% Moisture') + } + + valueTile("plantStatusTextTile", "device.plantStatus", decoration: "flat", width: 2, height: 2) { + state("plantStatusTextTile", label:'${currentValue}') + } + + valueTile("battery", "device.linkBatteryLevel" ) { + state("battery", label:'${currentValue}% battery') + } + + valueTile("installSmartApp","device.installSmartApp", decoration: "flat", width: 3, height: 1) { + state "needSmartApp", label:'Please install SmartApp "Required PlantLink Connector"', defaultState:true + state "connectedToSmartApp", label:'Connected to myplantlink.com' + } + + main "plantStatusTextTile" + details(['plantStatusTextTile', "plantMoistureTile", "battery", "installSmartApp"]) + } +} + +def setStatusIcon(value){ + def status = '' + switch (value) { + case '0': + status = 'Needs Water' + break + case '1': + status = 'Dry' + break + case '2': + case '3': + status = 'Good' + break + case '4': + status = 'Too Wet' + break + case 'No Soil': + status = 'Too Dry' + setPlantFuelLevel(0) + break + case 'Recently Watered': + status = 'Watered' + setPlantFuelLevel(100) + break + case 'Low Battery': + status = 'Low Battery' + break + case 'Waiting on First Measurement': + status = 'Calibrating' + break + default: + status = "?" + break + } + sendEvent("name":"plantStatus", "value":status, "description":statusText, displayed: true, isStateChange: true) +} + +def setPlantFuelLevel(value){ + sendEvent("name":"plantFuelLevel", "value":value, "description":statusText, displayed: true, isStateChange: true) +} + +def setBatteryLevel(value){ + sendEvent("name":"linkBatteryLevel", "value":value, "description":statusText, displayed: true, isStateChange: true) +} + +def setInstallSmartApp(value){ + sendEvent("name":"installSmartApp", "value":value) +} + +def parse(String description) { + + def description_map = parseDescriptionAsMap(description) + def event_name = "" + def measurement_map = [ + type: "link", + signal: "0x00", + zigbeedeviceid: device.zigbeeId, + created: new Date().time /1000 as int + ] + if (description_map.cluster == "0000"){ + /* version number, not used */ + + } else if (description_map.cluster == "0001"){ + /* battery voltage in mV (device needs minimium 2.1v to run) */ + log.debug "PlantLink - id ${device.zigbeeId} battery ${description_map.value}" + event_name = "battery_status" + measurement_map["battery"] = "0x${description_map.value}" + + } else if (description_map.cluster == "0B04"){ + /* raw moisture reading (needs to be sent to plantlink for soil/plant type conversion) */ + log.debug "PlantLink - id ${device.zigbeeId} raw moisture ${description_map.value}" + measurement_map["moisture"] = "0x${description_map.value}" + event_name = "moisture_status" + + } else{ + log.debug "PlantLink - id ${device.zigbeeId} Unknown '${description}'" + return + } + + def json_builder = new JsonBuilder(measurement_map) + def result = createEvent(name: event_name, value: json_builder.toString()) + return result +} + + +def parseDescriptionAsMap(description) { + (description - "read attr - ").split(",").inject([:]) { map, param -> + def nameAndValue = param.split(":") + map += [(nameAndValue[0].trim()):nameAndValue[1].trim()] + } +} \ No newline at end of file diff --git a/smartapps/osotech/plantlink-connector.src/plantlink-connector.groovy b/smartapps/osotech/plantlink-connector.src/plantlink-connector.groovy new file mode 100644 index 0000000..12cad96 --- /dev/null +++ b/smartapps/osotech/plantlink-connector.src/plantlink-connector.groovy @@ -0,0 +1,389 @@ +/** + * Required PlantLink Connector + * This SmartApp forwards the raw data of the deviceType to myplantlink.com + * and returns it back to your device after calculating soil and plant type. + * + * Copyright 2015 Oso Technologies + * + * 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. + * + */ +import groovy.json.JsonBuilder +import java.util.regex.Matcher +import java.util.regex.Pattern + +definition( + name: "PlantLink Connector", + namespace: "Osotech", + author: "Oso Technologies", + description: "This SmartApp connects to myplantlink.com and forwards the device data to it so it can calculate easy to read plant status for your specific plant's needs.", + category: "Convenience", + iconUrl: "https://dashboard.myplantlink.com/images/apple-touch-icon-76x76-precomposed.png", + iconX2Url: "https://dashboard.myplantlink.com/images/apple-touch-icon-120x120-precomposed.png", + iconX3Url: "https://dashboard.myplantlink.com/images/apple-touch-icon-152x152-precomposed.png" +) { + appSetting "client_id" + appSetting "client_secret" + appSetting "https_plantLinkServer" +} + +preferences { + page(name: "auth", title: "Step 1 of 2", nextPage:"deviceList", content:"authPage") + page(name: "deviceList", title: "Step 2 of 2", install:true, uninstall:false){ + section { + input "plantlinksensors", "capability.sensor", title: "Select PlantLink sensors", multiple: true + } + } +} + +mappings { + path("/swapToken") { + action: [ + GET: "swapToken" + ] + } +} + +def authPage(){ + if(!atomicState.accessToken){ + createAccessToken() + atomicState.accessToken = state.accessToken + } + + def redirectUrl = oauthInitUrl() + def uninstallAllowed = false + def oauthTokenProvided = false + if(atomicState.authToken){ + uninstallAllowed = true + oauthTokenProvided = true + } + + if (!oauthTokenProvided) { + return dynamicPage(name: "auth", title: "Step 1 of 2", nextPage:null, uninstall:uninstallAllowed) { + section(){ + href(name:"login", + url:redirectUrl, + style:"embedded", + title:"PlantLink", + image:"https://dashboard.myplantlink.com/images/PLlogo.png", + description:"Tap to login to myplantlink.com") + } + } + }else{ + return dynamicPage(name: "auth", title: "Step 1 of 2 - Completed", nextPage:"deviceList", uninstall:uninstallAllowed) { + section(){ + paragraph "You are logged in to myplantlink.com, tap next to continue", image: iconUrl + href(url:redirectUrl, title:"Or", description:"tap to switch accounts") + } + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def uninstalled() { + if (plantlinksensors){ + plantlinksensors.each{ sensor_device -> + sensor_device.setInstallSmartApp("needSmartApp") + } + } +} + +def initialize() { + atomicState.attached_sensors = [:] + if (plantlinksensors){ + subscribe(plantlinksensors, "moisture_status", moistureHandler) + subscribe(plantlinksensors, "battery_status", batteryHandler) + plantlinksensors.each{ sensor_device -> + sensor_device.setStatusIcon("Waiting on First Measurement") + sensor_device.setInstallSmartApp("connectedToSmartApp") + } + } +} + +def dock_sensor(device_serial, expected_plant_name) { + def docking_body_json_builder = new JsonBuilder([version: '1c', smartthings_device_id: device_serial]) + def docking_params = [ + uri : appSettings.https_plantLinkServer, + path : "/api/v1/smartthings/links", + headers : ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + contentType: "application/json", + body: docking_body_json_builder.toString() + ] + def plant_post_body_map = [ + plant_type_key: 999999, + soil_type_key : 1000004 + ] + def plant_post_params = [ + uri : appSettings.https_plantLinkServer, + path : "/api/v1/plants", + headers : ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + contentType: "application/json", + ] + log.debug "Creating new plant on myplantlink.com - ${expected_plant_name}" + httpPost(docking_params) { docking_response -> + if (parse_api_response(docking_response, "Docking a link")) { + if (docking_response.data.plants.size() == 0) { + log.debug "creating plant for - ${expected_plant_name}" + plant_post_body_map["name"] = expected_plant_name + plant_post_body_map['links_key'] = [docking_response.data.key] + def plant_post_body_json_builder = new JsonBuilder(plant_post_body_map) + plant_post_params["body"] = plant_post_body_json_builder.toString() + httpPost(plant_post_params) { plant_post_response -> + if(parse_api_response(plant_post_response, 'creating plant')){ + def attached_map = atomicState.attached_sensors + attached_map[device_serial] = plant_post_response.data + atomicState.attached_sensors = attached_map + } + } + } else { + def plant = docking_response.data.plants[0] + def attached_map = atomicState.attached_sensors + attached_map[device_serial] = plant + atomicState.attached_sensors = attached_map + checkAndUpdatePlantIfNeeded(plant, expected_plant_name) + } + } + } + return true +} + +def checkAndUpdatePlantIfNeeded(plant, expected_plant_name){ + def plant_put_params = [ + uri : appSettings.https_plantLinkServer, + headers : ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + contentType : "application/json" + ] + if (plant.name != expected_plant_name) { + log.debug "updating plant for - ${expected_plant_name}" + plant_put_params["path"] = "/api/v1/plants/${plant.key}" + def plant_put_body_map = [ + name: expected_plant_name + ] + def plant_put_body_json_builder = new JsonBuilder(plant_put_body_map) + plant_put_params["body"] = plant_put_body_json_builder.toString() + httpPut(plant_put_params) { plant_put_response -> + parse_api_response(plant_put_response, 'updating plant name') + } + } +} + +def moistureHandler(event){ + def expected_plant_name = "SmartThings - ${event.displayName}" + def device_serial = getDeviceSerialFromEvent(event) + + if (!atomicState.attached_sensors.containsKey(device_serial)){ + dock_sensor(device_serial, expected_plant_name) + }else{ + def measurement_post_params = [ + uri: appSettings.https_plantLinkServer, + path: "/api/v1/smartthings/links/${device_serial}/measurements", + headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + contentType: "application/json", + body: event.value + ] + httpPost(measurement_post_params) { measurement_post_response -> + if (parse_api_response(measurement_post_response, 'creating moisture measurement') && + measurement_post_response.data.size() >0){ + def measurement = measurement_post_response.data[0] + def plant = measurement.plant + log.debug plant + checkAndUpdatePlantIfNeeded(plant, expected_plant_name) + plantlinksensors.each{ sensor_device -> + if (sensor_device.id == event.deviceId){ + sensor_device.setStatusIcon(plant.status) + if (plant.last_measurements && plant.last_measurements[0].moisture){ + sensor_device.setPlantFuelLevel(plant.last_measurements[0].moisture * 100 as int) + } + if (plant.last_measurements && plant.last_measurements[0].battery){ + sensor_device.setBatteryLevel(plant.last_measurements[0].battery * 100 as int) + } + } + } + } + } + } +} + +def batteryHandler(event){ + def expected_plant_name = "SmartThings - ${event.displayName}" + def device_serial = getDeviceSerialFromEvent(event) + + if (!atomicState.attached_sensors.containsKey(device_serial)){ + dock_sensor(device_serial, expected_plant_name) + }else{ + def measurement_post_params = [ + uri: appSettings.https_plantLinkServer, + path: "/api/v1/smartthings/links/${device_serial}/measurements", + headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + contentType: "application/json", + body: event.value + ] + httpPost(measurement_post_params) { measurement_post_response -> + parse_api_response(measurement_post_response, 'creating battery measurement') + } + } +} + +def getDeviceSerialFromEvent(event){ + def pattern = /.*"zigbeedeviceid"\s*:\s*"(\w+)".*/ + def match_result = (event.value =~ pattern) + return match_result[0][1] +} + +def oauthInitUrl(){ + atomicState.oauthInitState = UUID.randomUUID().toString() + def oauthParams = [ + response_type: "code", + client_id: appSettings.client_id, + state: atomicState.oauthInitState, + redirect_uri: buildRedirectUrl() + ] + return appSettings.https_plantLinkServer + "/oauth/oauth2/authorize?" + toQueryString(oauthParams) +} + +def buildRedirectUrl(){ + return getServerUrl() + "/api/token/${atomicState.accessToken}/smartapps/installations/${app.id}/swapToken" +} + +def swapToken(){ + def code = params.code + def oauthState = params.state + def stcid = appSettings.client_id + def postParams = [ + method: 'POST', + uri: "https://oso-tech.appspot.com", + path: "/api/v1/oauth-token", + query: [grant_type:'authorization_code', code:params.code, client_id:stcid, + client_secret:appSettings.client_secret, redirect_uri: buildRedirectUrl()], + ] + + def jsonMap + httpPost(postParams) { resp -> + jsonMap = resp.data + } + + atomicState.refreshToken = jsonMap.refresh_token + atomicState.authToken = jsonMap.access_token + + def html = """ + + + + + + +
+
PlantLink
+
connected to
+
SmartThings
+
+
+
+

Your PlantLink Account is now connected to SmartThings!

+

Click Done at the top right to finish setup.

+
+ + +""" + render contentType: 'text/html', data: html +} + +private refreshAuthToken() { + def stcid = appSettings.client_id + def refreshParams = [ + method: 'POST', + uri: "https://hardware-dot-oso-tech.appspot.com", + path: "/api/v1/oauth-token", + query: [grant_type:'refresh_token', code:"${atomicState.refreshToken}", client_id:stcid, + client_secret:appSettings.client_secret], + ] + try{ + def jsonMap + httpPost(refreshParams) { resp -> + if(resp.status == 200){ + log.debug "OAuth Token refreshed" + jsonMap = resp.data + if (resp.data) { + atomicState.refreshToken = resp?.data?.refresh_token + atomicState.authToken = resp?.data?.access_token + if (data?.action && data?.action != "") { + log.debug data.action + "{data.action}"() + data.action = "" + } + } + data.action = "" + }else{ + log.debug "refresh failed ${resp.status} : ${resp.status.code}" + } + } + } + catch(Exception e){ + log.debug "caught exception refreshing auth token: " + e + } +} + +def parse_api_response(resp, message) { + if (resp.status == 200) { + return true + } else { + log.error "sent ${message} Json & got http status ${resp.status} - ${resp.status.code}" + if (resp.status == 401) { + refreshAuthToken() + return false + } else { + debugEvent("Authentication error, invalid authentication method, lack of credentials, etc.", true) + return false + } + } +} + +def getServerUrl() { + return "https://graph.api.smartthings.com" +} + +def debugEvent(message, displayEvent) { + def results = [ + name: "appdebug", + descriptionText: message, + displayed: displayEvent + ] + log.debug "Generating AppDebug Event: ${results}" + sendEvent (results) +} + +def toQueryString(Map m){ + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} \ No newline at end of file