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