2 * Required PlantLink Connector
3 * This SmartApp forwards the raw data of the deviceType to myplantlink.com
4 * and returns it back to your device after calculating soil and plant type.
6 * Copyright 2015 Oso Technologies
8 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
9 * in compliance with the License. You may obtain a copy of the License at:
11 * http://www.apache.org/licenses/LICENSE-2.0
13 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
14 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
15 * for the specific language governing permissions and limitations under the License.
18 import groovy.json.JsonBuilder
19 import java.util.regex.Matcher
20 import java.util.regex.Pattern
23 name: "PlantLink Connector",
25 author: "Oso Technologies",
26 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.",
27 category: "Convenience",
28 iconUrl: "https://dashboard.myplantlink.com/images/apple-touch-icon-76x76-precomposed.png",
29 iconX2Url: "https://dashboard.myplantlink.com/images/apple-touch-icon-120x120-precomposed.png",
30 iconX3Url: "https://dashboard.myplantlink.com/images/apple-touch-icon-152x152-precomposed.png"
32 appSetting "client_id"
33 appSetting "client_secret"
34 appSetting "https_plantLinkServer"
38 page(name: "auth", title: "Step 1 of 2", nextPage:"deviceList", content:"authPage")
39 page(name: "deviceList", title: "Step 2 of 2", install:true, uninstall:false){
41 input "plantlinksensors", "capability.sensor", title: "Select PlantLink sensors", multiple: true
55 if(!atomicState.accessToken){
57 atomicState.accessToken = state.accessToken
60 def redirectUrl = oauthInitUrl()
61 def uninstallAllowed = false
62 def oauthTokenProvided = false
63 if(atomicState.authToken){
64 uninstallAllowed = true
65 oauthTokenProvided = true
68 if (!oauthTokenProvided) {
69 return dynamicPage(name: "auth", title: "Step 1 of 2", nextPage:null, uninstall:uninstallAllowed) {
75 image:"https://dashboard.myplantlink.com/images/PLlogo.png",
76 description:"Tap to login to myplantlink.com")
80 return dynamicPage(name: "auth", title: "Step 1 of 2 - Completed", nextPage:"deviceList", uninstall:uninstallAllowed) {
82 paragraph "You are logged in to myplantlink.com, tap next to continue", image: iconUrl
83 href(url:redirectUrl, title:"Or", description:"tap to switch accounts")
90 log.debug "Installed with settings: ${settings}"
95 log.debug "Updated with settings: ${settings}"
101 if (plantlinksensors){
102 plantlinksensors.each{ sensor_device ->
103 sensor_device.setInstallSmartApp("needSmartApp")
109 atomicState.attached_sensors = [:]
110 if (plantlinksensors){
111 subscribe(plantlinksensors, "moisture_status", moistureHandler)
112 subscribe(plantlinksensors, "battery_status", batteryHandler)
113 plantlinksensors.each{ sensor_device ->
114 sensor_device.setStatusIcon("Waiting on First Measurement")
115 sensor_device.setInstallSmartApp("connectedToSmartApp")
120 def dock_sensor(device_serial, expected_plant_name) {
121 def docking_body_json_builder = new JsonBuilder([version: '1c', smartthings_device_id: device_serial])
122 def docking_params = [
123 uri : appSettings.https_plantLinkServer,
124 path : "/api/v1/smartthings/links",
125 headers : ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"],
126 contentType: "application/json",
127 body: docking_body_json_builder.toString()
129 def plant_post_body_map = [
130 plant_type_key: 999999,
131 soil_type_key : 1000004
133 def plant_post_params = [
134 uri : appSettings.https_plantLinkServer,
135 path : "/api/v1/plants",
136 headers : ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"],
137 contentType: "application/json",
139 log.debug "Creating new plant on myplantlink.com - ${expected_plant_name}"
141 httpPost(docking_params) { docking_response ->
142 if (parse_api_response(docking_response, "Docking a link")) {
143 if (docking_response.data.plants.size() == 0) {
144 log.debug "creating plant for - ${expected_plant_name}"
145 plant_post_body_map["name"] = expected_plant_name
146 plant_post_body_map['links_key'] = [docking_response.data.key]
147 def plant_post_body_json_builder = new JsonBuilder(plant_post_body_map)
148 plant_post_params["body"] = plant_post_body_json_builder.toString()
150 httpPost(plant_post_params) { plant_post_response ->
151 if(parse_api_response(plant_post_response, 'creating plant')){
152 def attached_map = atomicState.attached_sensors
153 attached_map[device_serial] = plant_post_response.data
154 atomicState.attached_sensors = attached_map
157 } catch (Exception f) {
158 log.debug "call failed $f"
161 def plant = docking_response.data.plants[0]
162 def attached_map = atomicState.attached_sensors
163 attached_map[device_serial] = plant
164 atomicState.attached_sensors = attached_map
165 checkAndUpdatePlantIfNeeded(plant, expected_plant_name)
169 } catch (Exception e) {
170 log.debug "call failed $e"
175 def checkAndUpdatePlantIfNeeded(plant, expected_plant_name){
176 def plant_put_params = [
177 uri : appSettings.https_plantLinkServer,
178 headers : ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"],
179 contentType : "application/json"
181 if (plant.name != expected_plant_name) {
182 log.debug "updating plant for - ${expected_plant_name}"
183 plant_put_params["path"] = "/api/v1/plants/${plant.key}"
184 def plant_put_body_map = [
185 name: expected_plant_name
187 def plant_put_body_json_builder = new JsonBuilder(plant_put_body_map)
188 plant_put_params["body"] = plant_put_body_json_builder.toString()
190 httpPut(plant_put_params) { plant_put_response ->
191 parse_api_response(plant_put_response, 'updating plant name')
193 } catch (Exception e) {
194 log.debug "call failed $e"
199 def moistureHandler(event){
200 def expected_plant_name = "SmartThings - ${event.displayName}"
201 def device_serial = getDeviceSerialFromEvent(event)
203 if (!atomicState.attached_sensors.containsKey(device_serial)){
204 dock_sensor(device_serial, expected_plant_name)
206 def measurement_post_params = [
207 uri: appSettings.https_plantLinkServer,
208 path: "/api/v1/smartthings/links/${device_serial}/measurements",
209 headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"],
210 contentType: "application/json",
214 httpPost(measurement_post_params) { measurement_post_response ->
215 if (parse_api_response(measurement_post_response, 'creating moisture measurement') &&
216 measurement_post_response.data.size() >0){
217 def measurement = measurement_post_response.data[0]
218 def plant = measurement.plant
220 checkAndUpdatePlantIfNeeded(plant, expected_plant_name)
221 plantlinksensors.each{ sensor_device ->
222 if (sensor_device.id == event.deviceId){
223 sensor_device.setStatusIcon(plant.status)
224 if (plant.last_measurements && plant.last_measurements[0].moisture){
225 sensor_device.setPlantFuelLevel(plant.last_measurements[0].moisture * 100 as int)
227 if (plant.last_measurements && plant.last_measurements[0].battery){
228 sensor_device.setBatteryLevel(plant.last_measurements[0].battery * 100 as int)
234 } catch (Exception e) {
235 log.debug "call failed $e"
240 def batteryHandler(event){
241 def expected_plant_name = "SmartThings - ${event.displayName}"
242 def device_serial = getDeviceSerialFromEvent(event)
244 if (!atomicState.attached_sensors.containsKey(device_serial)){
245 dock_sensor(device_serial, expected_plant_name)
247 def measurement_post_params = [
248 uri: appSettings.https_plantLinkServer,
249 path: "/api/v1/smartthings/links/${device_serial}/measurements",
250 headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"],
251 contentType: "application/json",
255 httpPost(measurement_post_params) { measurement_post_response ->
256 parse_api_response(measurement_post_response, 'creating battery measurement')
258 } catch (Exception e) {
259 log.debug "call failed $e"
264 def getDeviceSerialFromEvent(event){
265 def pattern = /.*"zigbeedeviceid"\s*:\s*"(\w+)".*/
266 def match_result = (event.value =~ pattern)
267 return match_result[0][1]
271 atomicState.oauthInitState = UUID.randomUUID().toString()
273 response_type: "code",
274 client_id: appSettings.client_id,
275 state: atomicState.oauthInitState,
276 redirect_uri: buildRedirectUrl()
278 return appSettings.https_plantLinkServer + "/oauth/oauth2/authorize?" + toQueryString(oauthParams)
281 def buildRedirectUrl(){
282 return getServerUrl() + "/api/token/${atomicState.accessToken}/smartapps/installations/${app.id}/swapToken"
286 def code = params.code
287 def oauthState = params.state
288 def stcid = appSettings.client_id
291 uri: "https://oso-tech.appspot.com",
292 path: "/api/v1/oauth-token",
293 query: [grant_type:'authorization_code', code:params.code, client_id:stcid,
294 client_secret:appSettings.client_secret, redirect_uri: buildRedirectUrl()],
299 httpPost(postParams) { resp ->
302 } catch (Exception e) {
303 log.debug "call failed $e"
306 atomicState.refreshToken = jsonMap.refresh_token
307 atomicState.authToken = jsonMap.access_token
312 <meta name="viewport" content="width=device-width, initial-scale=1">
324 font-family: Verdana, Geneva, sans-serif;
331 <div class="container">
332 <div class="flex1"><img src="https://dashboard.myplantlink.com/images/PLlogo.png" alt="PlantLink" height="75"/></div>
333 <div class="flex1"><img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected to" height="25" style="padding-top:25px;" /></div>
334 <div class="flex1"><img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings" height="75"/></div>
337 <div class="container">
338 <p>Your PlantLink Account is now connected to SmartThings!</p>
339 <p style="color:green;">Click <strong>Done</strong> at the top right to finish setup.</p>
344 render contentType: 'text/html', data: html
347 private refreshAuthToken() {
348 def stcid = appSettings.client_id
349 def refreshParams = [
351 uri: "https://hardware-dot-oso-tech.appspot.com",
352 path: "/api/v1/oauth-token",
353 query: [grant_type:'refresh_token', code:"${atomicState.refreshToken}", client_id:stcid,
354 client_secret:appSettings.client_secret],
358 httpPost(refreshParams) { resp ->
359 if(resp.status == 200){
360 log.debug "OAuth Token refreshed"
363 atomicState.refreshToken = resp?.data?.refresh_token
364 atomicState.authToken = resp?.data?.access_token
365 if (data?.action && data?.action != "") {
366 log.debug data.action
373 log.debug "refresh failed ${resp.status} : ${resp.status.code}"
378 log.debug "caught exception refreshing auth token: " + e
382 def parse_api_response(resp, message) {
383 if (resp.status == 200) {
386 log.error "sent ${message} Json & got http status ${resp.status} - ${resp.status.code}"
387 if (resp.status == 401) {
391 debugEvent("Authentication error, invalid authentication method, lack of credentials, etc.", true)
397 def getServerUrl() { return getApiServerUrl() }
399 def debugEvent(message, displayEvent) {
402 descriptionText: message,
403 displayed: displayEvent
405 log.debug "Generating AppDebug Event: ${results}"
409 def toQueryString(Map m){
410 return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")