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