]> git.basschouten.com Git - openhab-addons.git/blob
9ebe078b18549a0a97d789a4de12b5a568eca4ce
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.tesla.internal.handler;
14
15 import static org.openhab.binding.tesla.internal.TeslaBindingConstants.*;
16
17 import java.io.IOException;
18 import java.nio.charset.StandardCharsets;
19 import java.security.GeneralSecurityException;
20 import java.time.Instant;
21 import java.time.ZoneId;
22 import java.time.format.DateTimeFormatter;
23 import java.util.Base64;
24 import java.util.Collection;
25 import java.util.Collections;
26 import java.util.HashSet;
27 import java.util.List;
28 import java.util.Set;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
31 import java.util.concurrent.locks.ReentrantLock;
32
33 import javax.ws.rs.ProcessingException;
34 import javax.ws.rs.client.Client;
35 import javax.ws.rs.client.ClientRequestContext;
36 import javax.ws.rs.client.ClientRequestFilter;
37 import javax.ws.rs.client.Entity;
38 import javax.ws.rs.client.WebTarget;
39 import javax.ws.rs.core.MediaType;
40 import javax.ws.rs.core.MultivaluedMap;
41 import javax.ws.rs.core.Response;
42
43 import org.openhab.binding.tesla.internal.TeslaBindingConstants;
44 import org.openhab.binding.tesla.internal.discovery.TeslaVehicleDiscoveryService;
45 import org.openhab.binding.tesla.internal.protocol.TokenRequest;
46 import org.openhab.binding.tesla.internal.protocol.TokenRequestPassword;
47 import org.openhab.binding.tesla.internal.protocol.TokenRequestRefreshToken;
48 import org.openhab.binding.tesla.internal.protocol.TokenResponse;
49 import org.openhab.binding.tesla.internal.protocol.Vehicle;
50 import org.openhab.binding.tesla.internal.protocol.VehicleConfig;
51 import org.openhab.core.config.core.Configuration;
52 import org.openhab.core.thing.Bridge;
53 import org.openhab.core.thing.ChannelUID;
54 import org.openhab.core.thing.Thing;
55 import org.openhab.core.thing.ThingStatus;
56 import org.openhab.core.thing.ThingStatusDetail;
57 import org.openhab.core.thing.ThingStatusInfo;
58 import org.openhab.core.thing.binding.BaseBridgeHandler;
59 import org.openhab.core.thing.binding.ThingHandlerService;
60 import org.openhab.core.types.Command;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
63
64 import com.google.gson.Gson;
65 import com.google.gson.JsonObject;
66 import com.google.gson.JsonParser;
67
68 /**
69  * The {@link TeslaAccountHandler} is responsible for handling commands, which are sent
70  * to one of the channels.
71  *
72  * @author Karel Goderis - Initial contribution
73  * @author Nicolai Grødum - Adding token based auth
74  * @author Kai Kreuzer - refactored to use separate vehicle handlers
75  */
76 public class TeslaAccountHandler extends BaseBridgeHandler {
77
78     public static final int API_MAXIMUM_ERRORS_IN_INTERVAL = 2;
79     public static final int API_ERROR_INTERVAL_SECONDS = 15;
80     private static final int CONNECT_RETRY_INTERVAL = 15000;
81     private static final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
82             .withZone(ZoneId.systemDefault());
83
84     private final Logger logger = LoggerFactory.getLogger(TeslaAccountHandler.class);
85
86     // REST Client API variables
87     private final WebTarget teslaTarget;
88     private final WebTarget tokenTarget;
89     WebTarget vehiclesTarget; // this cannot be marked final as it is used in the runnable
90     final WebTarget vehicleTarget;
91     final WebTarget dataRequestTarget;
92     final WebTarget commandTarget;
93     final WebTarget wakeUpTarget;
94
95     // Threading and Job related variables
96     protected ScheduledFuture<?> connectJob;
97
98     protected long lastTimeStamp;
99     protected long apiIntervalTimestamp;
100     protected int apiIntervalErrors;
101     protected long eventIntervalTimestamp;
102     protected int eventIntervalErrors;
103     protected ReentrantLock lock;
104
105     private final Gson gson = new Gson();
106     private final JsonParser parser = new JsonParser();
107
108     private TokenResponse logonToken;
109     private final Set<VehicleListener> vehicleListeners = new HashSet<>();
110
111     public TeslaAccountHandler(Bridge bridge, Client teslaClient) {
112         super(bridge);
113         this.teslaTarget = teslaClient.target(URI_OWNERS);
114         this.tokenTarget = teslaTarget.path(URI_ACCESS_TOKEN);
115         this.vehiclesTarget = teslaTarget.path(API_VERSION).path(VEHICLES);
116         this.vehicleTarget = vehiclesTarget.path(PATH_VEHICLE_ID);
117         this.dataRequestTarget = vehicleTarget.path(PATH_DATA_REQUEST);
118         this.commandTarget = vehicleTarget.path(PATH_COMMAND);
119         this.wakeUpTarget = vehicleTarget.path(PATH_WAKE_UP);
120     }
121
122     @Override
123     public void initialize() {
124         logger.trace("Initializing the Tesla account handler for {}", this.getStorageKey());
125
126         updateStatus(ThingStatus.UNKNOWN);
127
128         lock = new ReentrantLock();
129         lock.lock();
130
131         try {
132             if (connectJob == null || connectJob.isCancelled()) {
133                 connectJob = scheduler.scheduleWithFixedDelay(connectRunnable, 0, CONNECT_RETRY_INTERVAL,
134                         TimeUnit.MILLISECONDS);
135             }
136         } finally {
137             lock.unlock();
138         }
139     }
140
141     @Override
142     public void dispose() {
143         logger.trace("Disposing the Tesla account handler for {}", getThing().getUID());
144
145         lock.lock();
146         try {
147             if (connectJob != null && !connectJob.isCancelled()) {
148                 connectJob.cancel(true);
149                 connectJob = null;
150             }
151         } finally {
152             lock.unlock();
153         }
154     }
155
156     public void scanForVehicles() {
157         scheduler.execute(() -> queryVehicles());
158     }
159
160     public void addVehicleListener(VehicleListener listener) {
161         this.vehicleListeners.add(listener);
162     }
163
164     public void removeVehicleListener(VehicleListener listener) {
165         this.vehicleListeners.remove(listener);
166     }
167
168     @Override
169     public void handleCommand(ChannelUID channelUID, Command command) {
170         // we do not have any channels -> nothing to do here
171     }
172
173     public String getAuthHeader() {
174         if (logonToken != null) {
175             return "Bearer " + logonToken.access_token;
176         } else {
177             return null;
178         }
179     }
180
181     protected boolean checkResponse(Response response, boolean immediatelyFail) {
182         if (response != null && response.getStatus() == 200) {
183             return true;
184         } else {
185             apiIntervalErrors++;
186             if (immediatelyFail || apiIntervalErrors >= API_MAXIMUM_ERRORS_IN_INTERVAL) {
187                 if (immediatelyFail) {
188                     logger.warn("Got an unsuccessful result, setting vehicle to offline and will try again");
189                 } else {
190                     logger.warn("Reached the maximum number of errors ({}) for the current interval ({} seconds)",
191                             API_MAXIMUM_ERRORS_IN_INTERVAL, API_ERROR_INTERVAL_SECONDS);
192                 }
193
194                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
195             } else if ((System.currentTimeMillis() - apiIntervalTimestamp) > 1000 * API_ERROR_INTERVAL_SECONDS) {
196                 logger.trace("Resetting the error counter. ({} errors in the last interval)", apiIntervalErrors);
197                 apiIntervalTimestamp = System.currentTimeMillis();
198                 apiIntervalErrors = 0;
199             }
200         }
201
202         return false;
203     }
204
205     protected Vehicle[] queryVehicles() {
206         String authHeader = getAuthHeader();
207
208         if (authHeader != null) {
209             // get a list of vehicles
210             Response response = vehiclesTarget.request(MediaType.APPLICATION_JSON_TYPE)
211                     .header("Authorization", authHeader).get();
212
213             logger.debug("Querying the vehicle: Response: {}:{}", response.getStatus(), response.getStatusInfo());
214
215             if (!checkResponse(response, true)) {
216                 logger.error("An error occurred while querying the vehicle");
217                 return null;
218             }
219
220             JsonObject jsonObject = parser.parse(response.readEntity(String.class)).getAsJsonObject();
221             Vehicle[] vehicleArray = gson.fromJson(jsonObject.getAsJsonArray("response"), Vehicle[].class);
222
223             for (Vehicle vehicle : vehicleArray) {
224                 String responseString = invokeAndParse(vehicle.id, VEHICLE_CONFIG, null, dataRequestTarget);
225                 if (responseString == null || responseString.isBlank()) {
226                     continue;
227                 }
228                 VehicleConfig vehicleConfig = gson.fromJson(responseString, VehicleConfig.class);
229                 for (VehicleListener listener : vehicleListeners) {
230                     listener.vehicleFound(vehicle, vehicleConfig);
231                 }
232                 for (Thing vehicleThing : getThing().getThings()) {
233                     if (vehicle.vin.equals(vehicleThing.getConfiguration().get(VIN))) {
234                         TeslaVehicleHandler vehicleHandler = (TeslaVehicleHandler) vehicleThing.getHandler();
235                         if (vehicleHandler != null) {
236                             logger.debug("Querying the vehicle: VIN {}", vehicle.vin);
237                             String vehicleJSON = gson.toJson(vehicle);
238                             vehicleHandler.parseAndUpdate("queryVehicle", null, vehicleJSON);
239                             logger.trace("Vehicle is id {}/vehicle_id {}/tokens {}", vehicle.id, vehicle.vehicle_id,
240                                     vehicle.tokens);
241                         }
242                     }
243                 }
244             }
245             return vehicleArray;
246         } else {
247             return new Vehicle[0];
248         }
249     }
250
251     private String getStorageKey() {
252         return this.getThing().getUID().getId();
253     }
254
255     private ThingStatusInfo authenticate() {
256         TokenResponse token = logonToken;
257
258         boolean hasExpired = true;
259
260         if (token != null) {
261             Instant tokenCreationInstant = Instant.ofEpochMilli(token.created_at * 1000);
262             logger.debug("Found a request token created at {}", dateFormatter.format(tokenCreationInstant));
263             Instant tokenExpiresInstant = Instant.ofEpochMilli(token.created_at * 1000 + 60 * token.expires_in);
264
265             if (tokenExpiresInstant.isBefore(Instant.now())) {
266                 logger.debug("The token has expired at {}", dateFormatter.format(tokenExpiresInstant));
267                 hasExpired = true;
268             } else {
269                 hasExpired = false;
270             }
271         }
272
273         if (hasExpired) {
274             String username = (String) getConfig().get(CONFIG_USERNAME);
275             String refreshToken = (String) getConfig().get(CONFIG_REFRESHTOKEN);
276             if (refreshToken == null || refreshToken.isEmpty()) {
277                 if (username != null && !username.isEmpty()) {
278                     String password = (String) getConfig().get(CONFIG_PASSWORD);
279                     return authenticate(username, password);
280                 } else {
281                     return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
282                             "Neither a refresh token nor credentials are provided.");
283                 }
284             }
285
286             TokenRequestRefreshToken tokenRequest = null;
287             try {
288                 tokenRequest = new TokenRequestRefreshToken(refreshToken);
289             } catch (GeneralSecurityException e) {
290                 logger.error("An exception occurred while requesting a new token: '{}'", e.getMessage(), e);
291             }
292
293             String payLoad = gson.toJson(tokenRequest);
294             Response response = null;
295             try {
296                 response = tokenTarget.request().post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
297             } catch (ProcessingException e) {
298                 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
299             }
300
301             logger.debug("Authenticating: Response: {}:{}", response.getStatus(), response.getStatusInfo());
302
303             if (response.getStatus() == 200 && response.hasEntity()) {
304                 String responsePayLoad = response.readEntity(String.class);
305                 TokenResponse tokenResponse = gson.fromJson(responsePayLoad.trim(), TokenResponse.class);
306                 if (!refreshToken.equals(tokenResponse.refresh_token)) {
307                     Configuration configuration = editConfiguration();
308                     configuration.put(CONFIG_REFRESHTOKEN, tokenResponse.refresh_token);
309                     updateConfiguration(configuration);
310                 }
311
312                 if (tokenResponse.access_token != null && !tokenResponse.access_token.isEmpty()) {
313                     this.logonToken = tokenResponse;
314                     logger.trace("Access Token is {}", logonToken.access_token);
315                 }
316                 return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
317             } else if (response.getStatus() == 401) {
318                 if (username != null && !username.isEmpty()) {
319                     String password = (String) getConfig().get(CONFIG_PASSWORD);
320                     return authenticate(username, password);
321                 } else {
322                     return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
323                             "Refresh token is not valid and no credentials are provided.");
324                 }
325             } else {
326                 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
327                         "HTTP returncode " + response.getStatus());
328             }
329         }
330         return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
331     }
332
333     private ThingStatusInfo authenticate(String username, String password) {
334         TokenRequest token = null;
335         try {
336             token = new TokenRequestPassword(username, password);
337         } catch (GeneralSecurityException e) {
338             logger.error("An exception occurred while building a password request token: '{}'", e.getMessage(), e);
339         }
340
341         if (token != null) {
342             String payLoad = gson.toJson(token);
343
344             Response response = tokenTarget.request().post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
345
346             if (response != null) {
347                 logger.debug("Authenticating: Response : {}:{}", response.getStatus(), response.getStatusInfo());
348
349                 if (response.getStatus() == 200 && response.hasEntity()) {
350                     String responsePayLoad = response.readEntity(String.class);
351                     TokenResponse tokenResponse = gson.fromJson(responsePayLoad.trim(), TokenResponse.class);
352                     if (tokenResponse.token_type != null && !tokenResponse.access_token.isEmpty()) {
353                         this.logonToken = tokenResponse;
354                         Configuration cfg = editConfiguration();
355                         cfg.put(TeslaBindingConstants.CONFIG_REFRESHTOKEN, logonToken.refresh_token);
356                         cfg.remove(TeslaBindingConstants.CONFIG_PASSWORD);
357                         updateConfiguration(cfg);
358                         return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
359                     }
360                 } else if (response.getStatus() == 401) {
361                     return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
362                             "Invalid credentials.");
363                 } else {
364                     return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
365                             "HTTP returncode " + response.getStatus());
366                 }
367             } else {
368                 logger.debug("Authenticating: Response was null");
369                 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
370                         "Failed retrieving a response from the server.");
371             }
372         }
373         return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
374                 "Cannot build request from credentials.");
375     }
376
377     protected String invokeAndParse(String vehicleId, String command, String payLoad, WebTarget target) {
378         logger.debug("Invoking: {}", command);
379
380         if (vehicleId != null) {
381             Response response;
382
383             if (payLoad != null) {
384                 if (command != null) {
385                     response = target.resolveTemplate("cmd", command).resolveTemplate("vid", vehicleId).request()
386                             .header("Authorization", "Bearer " + logonToken.access_token)
387                             .post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
388                 } else {
389                     response = target.resolveTemplate("vid", vehicleId).request()
390                             .header("Authorization", "Bearer " + logonToken.access_token)
391                             .post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
392                 }
393             } else {
394                 if (command != null) {
395                     response = target.resolveTemplate("cmd", command).resolveTemplate("vid", vehicleId)
396                             .request(MediaType.APPLICATION_JSON_TYPE)
397                             .header("Authorization", "Bearer " + logonToken.access_token).get();
398                 } else {
399                     response = target.resolveTemplate("vid", vehicleId).request(MediaType.APPLICATION_JSON_TYPE)
400                             .header("Authorization", "Bearer " + logonToken.access_token).get();
401                 }
402             }
403
404             if (!checkResponse(response, false)) {
405                 logger.debug("An error occurred while communicating with the vehicle during request {}: {}:{}", command,
406                         (response != null) ? response.getStatus() : "",
407                         (response != null) ? response.getStatusInfo() : "No Response");
408                 return null;
409             }
410
411             try {
412                 JsonObject jsonObject = parser.parse(response.readEntity(String.class)).getAsJsonObject();
413                 logger.trace("Request : {}:{}:{} yields {}", command, payLoad, target, jsonObject.get("response"));
414                 return jsonObject.get("response").toString();
415             } catch (Exception e) {
416                 logger.error("An exception occurred while invoking a REST request: '{}'", e.getMessage());
417             }
418         }
419
420         return null;
421     }
422
423     protected Runnable connectRunnable = () -> {
424         try {
425             lock.lock();
426
427             if (getThing().getStatus() != ThingStatus.ONLINE) {
428                 logger.debug("Setting up an authenticated connection to the Tesla back-end");
429
430                 ThingStatusInfo authenticationResult = authenticate();
431                 updateStatus(authenticationResult.getStatus(), authenticationResult.getStatusDetail(),
432                         authenticationResult.getDescription());
433
434                 if (authenticationResult.getStatus() == ThingStatus.ONLINE) {
435                     // get a list of vehicles
436                     Response response = vehiclesTarget.request(MediaType.APPLICATION_JSON_TYPE)
437                             .header("Authorization", "Bearer " + logonToken.access_token).get();
438
439                     if (response != null && response.getStatus() == 200 && response.hasEntity()) {
440                         updateStatus(ThingStatus.ONLINE);
441                         for (Vehicle vehicle : queryVehicles()) {
442                             Bridge bridge = getBridge();
443                             if (bridge != null) {
444                                 List<Thing> things = bridge.getThings();
445                                 for (int i = 0; i < things.size(); i++) {
446                                     Thing thing = things.get(i);
447                                     TeslaVehicleHandler handler = (TeslaVehicleHandler) thing.getHandler();
448                                     if (handler != null) {
449                                         if (vehicle.vin.equals(thing.getConfiguration().get(VIN))) {
450                                             logger.debug(
451                                                     "Found the vehicle with VIN '{}' in the list of vehicles you own",
452                                                     getConfig().get(VIN));
453                                             apiIntervalErrors = 0;
454                                             apiIntervalTimestamp = System.currentTimeMillis();
455                                         } else {
456                                             logger.warn(
457                                                     "Unable to find the vehicle with VIN '{}' in the list of vehicles you own",
458                                                     getConfig().get(VIN));
459                                             handler.updateStatus(ThingStatus.OFFLINE,
460                                                     ThingStatusDetail.CONFIGURATION_ERROR,
461                                                     "Vin is not available through this account.");
462                                         }
463                                     }
464                                 }
465                             }
466                         }
467                     } else {
468                         if (response != null) {
469                             logger.error("Error fetching the list of vehicles : {}:{}", response.getStatus(),
470                                     response.getStatusInfo());
471                             updateStatus(ThingStatus.OFFLINE);
472                         }
473                     }
474                 }
475             }
476         } catch (Exception e) {
477             logger.error("An exception occurred while connecting to the Tesla back-end: '{}'", e.getMessage(), e);
478         } finally {
479             lock.unlock();
480         }
481     };
482
483     public static class Authenticator implements ClientRequestFilter {
484         private final String user;
485         private final String password;
486
487         public Authenticator(String user, String password) {
488             this.user = user;
489             this.password = password;
490         }
491
492         @Override
493         public void filter(ClientRequestContext requestContext) throws IOException {
494             MultivaluedMap<String, Object> headers = requestContext.getHeaders();
495             final String basicAuthentication = getBasicAuthentication();
496             headers.add("Authorization", basicAuthentication);
497         }
498
499         private String getBasicAuthentication() {
500             String token = this.user + ":" + this.password;
501             return "Basic " + Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8));
502         }
503     }
504
505     protected class Request implements Runnable {
506
507         private TeslaVehicleHandler handler;
508         private String request;
509         private String payLoad;
510         private WebTarget target;
511
512         public Request(TeslaVehicleHandler handler, String request, String payLoad, WebTarget target) {
513             this.handler = handler;
514             this.request = request;
515             this.payLoad = payLoad;
516             this.target = target;
517         }
518
519         @Override
520         public void run() {
521             try {
522                 String result = "";
523
524                 if (getThing().getStatus() == ThingStatus.ONLINE) {
525                     result = invokeAndParse(handler.getVehicleId(), request, payLoad, target);
526                     if (result != null && !"".equals(result)) {
527                         handler.parseAndUpdate(request, payLoad, result);
528                     }
529                 }
530             } catch (Exception e) {
531                 logger.error("An exception occurred while executing a request to the vehicle: '{}'", e.getMessage(), e);
532             }
533         }
534     }
535
536     public Request newRequest(TeslaVehicleHandler teslaVehicleHandler, String command, String payLoad,
537             WebTarget target) {
538         return new Request(teslaVehicleHandler, command, payLoad, target);
539     }
540
541     @Override
542     public Collection<Class<? extends ThingHandlerService>> getServices() {
543         return Collections.singletonList(TeslaVehicleDiscoveryService.class);
544     }
545 }