Update step-notifier.groovy
[smartapps.git] / official / tesla-connect.groovy
1 /**
2  *  Copyright 2015 SmartThings
3  *
4  *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5  *  in compliance with the License. You may obtain a copy of the License at:
6  *
7  *      http://www.apache.org/licenses/LICENSE-2.0
8  *
9  *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
10  *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
11  *  for the specific language governing permissions and limitations under the License.
12  *
13  *      Tesla Service Manager
14  *
15  *      Author: juano23@gmail.com
16  *      Date: 2013-08-15
17  */
18
19 definition(
20     name: "Tesla (Connect)",
21     namespace: "smartthings",
22     author: "SmartThings",
23     description: "Integrate your Tesla car with SmartThings.",
24     category: "SmartThings Labs",
25     iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/tesla-app%402x.png",
26     iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/tesla-app%403x.png",
27     singleInstance: true
28 )
29
30 preferences {
31         page(name: "loginToTesla", title: "Tesla")
32         page(name: "selectCars", title: "Tesla")
33 }
34
35 def loginToTesla() {
36         def showUninstall = username != null && password != null
37         return dynamicPage(name: "loginToTesla", title: "Connect your Tesla", nextPage:"selectCars", uninstall:showUninstall) {
38                 section("Log in to your Tesla account:") {
39                         input "username", "text", title: "Username", required: true, autoCorrect:false
40                         input "password", "password", title: "Password", required: true, autoCorrect:false
41                 }
42                 section("To use Tesla, SmartThings encrypts and securely stores your Tesla credentials.") {}
43         }
44 }
45
46 def selectCars() {
47         def loginResult = forceLogin()
48
49         if(loginResult.success)
50         {
51                 def options = carsDiscovered() ?: []
52
53                 return dynamicPage(name: "selectCars", title: "Tesla", install:true, uninstall:true) {
54                         section("Select which Tesla to connect"){
55                                 input(name: "selectedCars", type: "enum", required:false, multiple:true, options:options)
56                         }
57                 }
58         }
59         else
60         {
61                 log.error "login result false"
62         return dynamicPage(name: "selectCars", title: "Tesla", install:false, uninstall:true, nextPage:"") {
63                         section("") {
64                                 paragraph "Please check your username and password"
65                         }
66                 }
67         }
68 }
69
70
71 def installed() {
72         log.debug "Installed"
73         initialize()
74 }
75
76 def updated() {
77         log.debug "Updated"
78
79         unsubscribe()
80         initialize()
81 }
82
83 def uninstalled() {
84         removeChildDevices(getChildDevices())
85 }
86
87 def initialize() {
88
89         if (selectCars) {
90                 addDevice()
91         }
92
93         // Delete any that are no longer in settings
94         def delete = getChildDevices().findAll { !selectedCars }
95         log.info delete
96     //removeChildDevices(delete)
97 }
98
99 //CHILD DEVICE METHODS
100 def addDevice() {
101     def devices = getcarList()
102     log.trace "Adding childs $devices - $selectedCars"
103         selectedCars.each { dni ->
104                 def d = getChildDevice(dni)
105                 if(!d) {
106                         def newCar = devices.find { (it.dni) == dni }
107                         d = addChildDevice("smartthings", "Tesla", dni, null, [name:"Tesla", label:"Tesla"])
108                         log.trace "created ${d.name} with id $dni"
109                 } else {
110                         log.trace "found ${d.name} with id $key already exists"
111                 }
112         }
113 }
114
115 private removeChildDevices(delete)
116 {
117         log.debug "deleting ${delete.size()} Teslas"
118         delete.each {
119                 state.suppressDelete[it.deviceNetworkId] = true
120                 deleteChildDevice(it.deviceNetworkId)
121                 state.suppressDelete.remove(it.deviceNetworkId)
122         }
123 }
124
125 def getcarList() {
126         def devices = []
127
128         def carListParams = [
129                 uri: "https://portal.vn.teslamotors.com/",
130         path: "/vehicles",
131                 headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()]
132         ]
133     
134         httpGet(carListParams) { resp ->
135                 log.debug "Getting car list"
136                 if(resp.status == 200) {
137             def vehicleId = resp.data.id.value[0].toString()
138             def vehicleVIN = resp.data.vin[0]
139             def dni = vehicleVIN + ":" + vehicleId
140                         def name = "Tesla [${vehicleId}]"
141             // CHECK HERE IF MOBILE IS ENABLE
142             // path: "/vehicles/${vehicleId}/mobile_enabled",
143             // if (enable)
144             devices += ["name" : "${name}", "dni" : "${dni}"]
145             // else return [errorMessage:"Mobile communication isn't enable on all of your vehicles."]
146                 } else if(resp.status == 302) {
147                 // Token expired or incorrect
148                         singleUrl = resp.headers.Location.value
149                 } else {
150                         // ERROR
151                         log.error "car list: unknown response"
152                 }        
153         }
154     return devices
155 }
156
157 Map carsDiscovered() {
158         def devices =  getcarList()
159     log.trace "Map $devices"    
160         def map = [:]
161         if (devices instanceof java.util.Map) {
162                 devices.each {
163                         def value = "${it?.name}"
164                         def key = it?.dni
165                         map["${key}"] = value
166                 }
167         } else { //backwards compatable
168                 devices.each {
169                         def value = "${it?.name}"
170                         def key = it?.dni
171                         map["${key}"] = value
172                 }
173         }
174         map
175 }
176
177 def removeChildFromSettings(child) {
178         def device = child.device
179         def dni = device.deviceNetworkId
180         log.debug "removing child device $device with dni ${dni}"
181         if(!state?.suppressDelete?.get(dni))
182         {
183                 def newSettings = settings.cars?.findAll { it != dni } ?: []
184                 app.updateSetting("cars", newSettings)
185         }
186 }
187
188 private forceLogin() {
189         updateCookie(null)
190         login()
191 }
192
193
194 private login() {
195         if(getCookieValueIsValid()) {
196                 return [success:true]
197         }
198         return doLogin()
199 }
200
201 private doLogin() {
202         def loginParams = [
203                 uri: "https://portal.vn.teslamotors.com",
204         path: "/login",
205                 contentType: "application/x-www-form-urlencoded",
206                 body: "user_session%5Bemail%5D=${username}&user_session%5Bpassword%5D=${password}"
207         ]
208
209         def result = [success:false]
210         
211     try {
212         httpPost(loginParams) { resp ->
213             if (resp.status == 302) {
214                 log.debug "login 302 json headers: " + resp.headers.collect { "${it.name}:${it.value}" }
215                 def cookie = resp?.headers?.'Set-Cookie'?.split(";")?.getAt(0)
216                 if (cookie) {
217                     log.debug "login setting cookie to $cookie"
218                     updateCookie(cookie)                
219                     result.success = true
220                 } else {
221                     // ERROR: any more information we can give?
222                     result.reason = "Bad login"
223                 }
224             } else {
225                 // ERROR: any more information we can give?
226                 result.reason = "Bad login"
227             }
228         }        
229         } catch (groovyx.net.http.HttpResponseException e) {
230                         result.reason = "Bad login"
231         }
232         return result
233 }
234
235 private command(String dni, String command, String value = '') {
236         def id = getVehicleId(dni)
237     def commandPath
238         switch (command) {
239                 case "flash":
240                 commandPath = "/vehicles/${id}/command/flash_lights"
241             break;
242                 case "honk":
243                 commandPath = "/vehicles/${id}/command/honk_horn"  
244             break;
245                 case "doorlock":
246                 commandPath = "/vehicles/${id}/command/door_lock"  
247             break;            
248                 case "doorunlock":
249                 commandPath = "/vehicles/${id}/command/door_unlock"  
250             break;  
251                 case "climaon":
252                 commandPath = "/vehicles/${id}/command/auto_conditioning_start"  
253             break;            
254                 case "climaoff":
255                 commandPath = "/vehicles/${id}/command/auto_conditioning_stop"  
256             break;             
257                 case "roof":
258                 commandPath = "/vehicles/${id}/command/sun_roof_control?state=${value}" 
259             break;    
260                 case "temp":
261                 commandPath = "/vehicles/${id}/command/set_temps?driver_temp=${value}&passenger_temp=${value}"
262             break;              
263                 default:
264                         break; 
265     }   
266     
267         def commandParams = [
268                 uri: "https://portal.vn.teslamotors.com",
269                 path: commandPath,
270                 headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()]
271         ]
272
273         def loginRequired = false
274
275         httpGet(commandParams) { resp ->
276
277                 if(resp.status == 403) {
278                         loginRequired = true
279                 } else if (resp.status == 200) {
280                         def data = resp.data
281             sendNotification(data.toString())
282                 } else {
283                         log.error "unknown response: ${resp.status} - ${resp.headers.'Content-Type'}"
284                 }
285         }
286         if(loginRequired) { throw new Exception("Login Required") }
287 }
288
289 private honk(String dni) {
290         def id = getVehicleId(dni)
291         def honkParams = [
292                 uri: "https://portal.vn.teslamotors.com",
293                 path: "/vehicles/${id}/command/honk_horn",
294                 headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()]
295         ]
296
297         def loginRequired = false
298
299         httpGet(honkParams) { resp ->
300
301                 if(resp.status == 403) {
302                         loginRequired = true
303                 } else if (resp.status == 200) {
304                         def data = resp.data
305                 } else {
306                         log.error "unknown response: ${resp.status} - ${resp.headers.'Content-Type'}"
307                 }
308         }
309
310         if(loginRequired) {
311                 throw new Exception("Login Required")
312         }
313 }
314
315 private poll(String dni) {
316         def id = getVehicleId(dni)
317         def pollParams1 = [
318                 uri: "https://portal.vn.teslamotors.com",
319                 path: "/vehicles/${id}/command/climate_state",
320                 headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()]
321         ]
322
323         def childDevice = getChildDevice(dni)
324     
325         def loginRequired = false
326
327         httpGet(pollParams1) { resp ->
328
329                 if(resp.status == 403) {
330                         loginRequired = true
331                 } else if (resp.status == 200) {
332                         def data = resp.data
333             childDevice?.sendEvent(name: 'temperature', value: cToF(data.inside_temp).toString())
334             if (data.is_auto_conditioning_on)            
335                 childDevice?.sendEvent(name: 'clima', value: 'on')
336             else
337                 childDevice?.sendEvent(name: 'clima', value: 'off')
338             childDevice?.sendEvent(name: 'thermostatSetpoint', value: cToF(data.driver_temp_setting).toString())            
339                 } else {
340                         log.error "unknown response: ${resp.status} - ${resp.headers.'Content-Type'}"
341                 }
342         }
343
344         def pollParams2 = [
345                 uri: "https://portal.vn.teslamotors.com",
346                 path: "/vehicles/${id}/command/vehicle_state",
347                 headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()]
348         ]
349
350         httpGet(pollParams2) { resp ->
351                 if(resp.status == 403) {
352                         loginRequired = true
353                 } else if (resp.status == 200) {
354                         def data = resp.data
355             if (data.sun_roof_percent_open == 0)
356                 childDevice?.sendEvent(name: 'roof', value: 'close')
357                         else if (data.sun_roof_percent_open > 0 && data.sun_roof_percent_open < 70)
358                 childDevice?.sendEvent(name: 'roof', value: 'vent')
359                         else if (data.sun_roof_percent_open >= 70 && data.sun_roof_percent_open <= 80)                
360                 childDevice?.sendEvent(name: 'roof', value: 'comfort')
361             else if (data.sun_roof_percent_open > 80 && data.sun_roof_percent_open <= 100)
362                 childDevice?.sendEvent(name: 'roof', value: 'open')           
363             if (data.locked)            
364                 childDevice?.sendEvent(name: 'door', value: 'lock')
365             else
366                 childDevice?.sendEvent(name: 'door', value: 'unlock')
367                 } else {
368                         log.error "unknown response: ${resp.status} - ${resp.headers.'Content-Type'}"
369                 }
370         }
371
372         def pollParams3 = [
373                 uri: "https://portal.vn.teslamotors.com",
374                 path: "/vehicles/${id}/command/charge_state",
375                 headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()]
376         ]
377
378         httpGet(pollParams3) { resp ->
379                 if(resp.status == 403) {
380                         loginRequired = true
381                 } else if (resp.status == 200) {
382                         def data = resp.data
383             childDevice?.sendEvent(name: 'connected', value: data.charging_state.toString())
384             childDevice?.sendEvent(name: 'miles', value: data.battery_range.toString())
385             childDevice?.sendEvent(name: 'battery', value: data.battery_level.toString())
386                 } else {
387                         log.error "unknown response: ${resp.status} - ${resp.headers.'Content-Type'}"
388                 }
389         }
390
391         if(loginRequired) {
392                 throw new Exception("Login Required")
393         }
394 }
395
396 private getVehicleId(String dni) {
397     return dni.split(":").last()
398 }
399
400 private Boolean getCookieValueIsValid()
401 {
402         // TODO: make a call with the cookie to verify that it works
403         return getCookieValue()
404 }
405
406 private updateCookie(String cookie) {
407         state.cookie = cookie
408 }
409
410 private getCookieValue() {
411         state.cookie
412 }
413
414 def cToF(temp) {
415     return temp * 1.8 + 32
416 }
417
418 private validUserAgent() {
419         "curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8x zlib/1.2.5"
420 }