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