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