Update speaker-weather-forecast.groovy
[smartapps.git] / official / plantlink-connector.groovy
1 /**
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.
5  *
6  *  Copyright 2015 Oso Technologies
7  *
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:
10  *
11  *      http://www.apache.org/licenses/LICENSE-2.0
12  *
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.
16  *
17  */
18 import groovy.json.JsonBuilder
19 import java.util.regex.Matcher
20 import java.util.regex.Pattern
21  
22 definition(
23     name: "PlantLink Connector",
24     namespace: "Osotech",
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"
31 ) {
32     appSetting "client_id"
33     appSetting "client_secret"
34     appSetting "https_plantLinkServer"
35 }
36
37 preferences {
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){
40         section {
41             input "plantlinksensors", "capability.sensor", title: "Select PlantLink sensors", multiple: true
42         }
43     }
44 }
45
46 mappings {
47     path("/swapToken") {
48         action: [
49                 GET: "swapToken"
50         ]
51     }
52 }
53
54 def authPage(){
55     if(!atomicState.accessToken){
56         createAccessToken()
57         atomicState.accessToken = state.accessToken
58     }
59
60     def redirectUrl = oauthInitUrl()
61     def uninstallAllowed = false
62     def oauthTokenProvided = false
63     if(atomicState.authToken){
64         uninstallAllowed = true
65         oauthTokenProvided = true
66     }
67
68     if (!oauthTokenProvided) {
69         return dynamicPage(name: "auth", title: "Step 1 of 2", nextPage:null, uninstall:uninstallAllowed) {
70             section(){
71                 href(name:"login",
72                      url:redirectUrl, 
73                      style:"embedded",
74                      title:"PlantLink", 
75                      image:"https://dashboard.myplantlink.com/images/PLlogo.png", 
76                      description:"Tap to login to myplantlink.com")
77             }
78         }
79     }else{
80         return dynamicPage(name: "auth", title: "Step 1 of 2 - Completed", nextPage:"deviceList", uninstall:uninstallAllowed) {
81             section(){
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")
84             }
85         }
86     }
87 }
88
89 def installed() {
90     log.debug "Installed with settings: ${settings}"
91     initialize()
92 }
93
94 def updated() {
95     log.debug "Updated with settings: ${settings}"
96     unsubscribe()
97     initialize()
98 }
99
100 def uninstalled() {
101     if (plantlinksensors){
102         plantlinksensors.each{ sensor_device ->
103             sensor_device.setInstallSmartApp("needSmartApp")
104         }
105     }
106 }
107
108 def initialize() {
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")
116         }
117     }
118 }
119
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()
128     ]
129     def plant_post_body_map = [
130             plant_type_key: 999999,
131             soil_type_key : 1000004
132     ]
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",
138     ]
139     log.debug "Creating new plant on myplantlink.com - ${expected_plant_name}"
140     try {
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()
149                     try {
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
155                             }
156                         }
157                     } catch (Exception f) {
158                         log.debug "call failed $f"
159                     }
160                 } else {
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)
166                 }
167             }
168         }
169     } catch (Exception e) {
170         log.debug "call failed $e"
171     }
172     return true
173 }
174
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"
180     ]
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
186         ]
187         def plant_put_body_json_builder = new JsonBuilder(plant_put_body_map)
188         plant_put_params["body"] = plant_put_body_json_builder.toString()
189         try {
190             httpPut(plant_put_params) { plant_put_response ->
191                 parse_api_response(plant_put_response, 'updating plant name')
192             }
193         } catch (Exception e) {
194             log.debug "call failed $e"
195         }
196     }
197 }
198
199 def moistureHandler(event){
200     def expected_plant_name = "SmartThings - ${event.displayName}"
201     def device_serial = getDeviceSerialFromEvent(event)
202     
203     if (!atomicState.attached_sensors.containsKey(device_serial)){
204         dock_sensor(device_serial, expected_plant_name)
205     }else{
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",
211                 body: event.value
212         ]
213         try {
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
219                     log.debug 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)
226                             }
227                             if (plant.last_measurements && plant.last_measurements[0].battery){
228                                 sensor_device.setBatteryLevel(plant.last_measurements[0].battery * 100 as int)
229                             }
230                         }
231                     }
232                 }
233             }
234         } catch (Exception e) {
235             log.debug "call failed $e"
236         }
237     }
238 }
239
240 def batteryHandler(event){
241     def expected_plant_name = "SmartThings - ${event.displayName}"
242     def device_serial = getDeviceSerialFromEvent(event)
243     
244     if (!atomicState.attached_sensors.containsKey(device_serial)){
245         dock_sensor(device_serial, expected_plant_name)
246     }else{
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",
252                 body: event.value
253         ]
254         try {
255             httpPost(measurement_post_params) { measurement_post_response ->
256                 parse_api_response(measurement_post_response, 'creating battery measurement')
257             }
258         } catch (Exception e) {
259             log.debug "call failed $e"
260         }
261     }
262 }
263
264 def getDeviceSerialFromEvent(event){
265     def pattern = /.*"zigbeedeviceid"\s*:\s*"(\w+)".*/
266     def match_result = (event.value =~ pattern)
267     return match_result[0][1]
268 }
269
270 def oauthInitUrl(){
271     atomicState.oauthInitState = UUID.randomUUID().toString()
272     def oauthParams = [
273             response_type: "code",
274             client_id: appSettings.client_id,
275             state: atomicState.oauthInitState,
276             redirect_uri: buildRedirectUrl()
277     ]
278     return appSettings.https_plantLinkServer + "/oauth/oauth2/authorize?" + toQueryString(oauthParams)
279 }
280
281 def buildRedirectUrl(){
282     return getServerUrl() + "/api/token/${atomicState.accessToken}/smartapps/installations/${app.id}/swapToken"
283 }
284
285 def swapToken(){
286     def code = params.code
287     def oauthState = params.state
288     def stcid = appSettings.client_id
289     def postParams = [
290             method: 'POST',
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()],
295     ]
296
297     def jsonMap
298     try {
299         httpPost(postParams) { resp ->
300             jsonMap = resp.data
301         }
302     } catch (Exception e) {
303         log.debug "call failed $e"
304     }
305
306     atomicState.refreshToken = jsonMap.refresh_token
307     atomicState.authToken = jsonMap.access_token
308
309     def html = """
310 <html>
311 <head>
312 <meta name="viewport" content="width=device-width, initial-scale=1">
313 <style>
314     .container {
315         padding:25px;
316     }
317     .flex1 {
318         width:33%;
319         float:left;
320         text-align: center;
321     }
322     p {
323         font-size: 2em;
324         font-family: Verdana, Geneva, sans-serif;
325         text-align: center;
326         color: #777;
327     }
328 </style>
329 </head>
330 <body>
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>
335         <br clear="all">
336   </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>
340     </div>
341 </body>
342 </html>
343 """
344     render contentType: 'text/html', data: html
345 }
346
347 private refreshAuthToken() {
348     def stcid = appSettings.client_id
349     def refreshParams = [
350             method: 'POST',
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],
355     ]
356     try{
357         def jsonMap
358         httpPost(refreshParams) { resp ->
359             if(resp.status == 200){
360                 log.debug "OAuth Token refreshed"
361                 jsonMap = resp.data
362                 if (resp.data) {
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
367                         "{data.action}"()
368                         data.action = ""
369                     }
370                 }
371                 data.action = ""
372             }else{
373                 log.debug "refresh failed ${resp.status} : ${resp.status.code}"
374             }
375         }
376     }
377     catch(Exception e){
378         log.debug "caught exception refreshing auth token: " + e
379     }
380 }
381
382 def parse_api_response(resp, message) {
383     if (resp.status == 200) {
384         return true
385     } else {
386         log.error "sent ${message} Json & got http status ${resp.status} - ${resp.status.code}"
387         if (resp.status == 401) {
388             refreshAuthToken()
389             return false
390         } else {
391             debugEvent("Authentication error, invalid authentication method, lack of credentials, etc.", true)
392             return false
393         }
394     }
395 }
396
397 def getServerUrl() { return getApiServerUrl() }
398
399 def debugEvent(message, displayEvent) {
400     def results = [
401             name: "appdebug",
402             descriptionText: message,
403             displayed: displayEvent
404     ]
405     log.debug "Generating AppDebug Event: ${results}"
406     sendEvent (results)
407 }
408
409 def toQueryString(Map m){
410     return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
411 }