2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.tesla.internal.handler;
15 import static org.openhab.binding.tesla.internal.TeslaBindingConstants.*;
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;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.locks.ReentrantLock;
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;
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;
54 import com.google.gson.Gson;
55 import com.google.gson.JsonObject;
56 import com.google.gson.JsonParser;
59 * The {@link TeslaAccountHandler} is responsible for handling commands, which are sent
60 * to one of the channels.
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
66 public class TeslaAccountHandler extends BaseBridgeHandler {
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());
74 private final Logger logger = LoggerFactory.getLogger(TeslaAccountHandler.class);
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;
84 private final TeslaSSOHandler ssoHandler;
85 private final ThingTypeMigrationService thingTypeMigrationService;
87 // Threading and Job related variables
88 protected ScheduledFuture<?> connectJob;
90 protected long lastTimeStamp;
91 protected long apiIntervalTimestamp;
92 protected int apiIntervalErrors;
93 protected long eventIntervalTimestamp;
94 protected int eventIntervalErrors;
95 protected ReentrantLock lock;
97 private final Gson gson = new Gson();
99 private TokenResponse logonToken;
100 private final Set<VehicleListener> vehicleListeners = new HashSet<>();
102 public TeslaAccountHandler(Bridge bridge, Client teslaClient, HttpClientFactory httpClientFactory,
103 ThingTypeMigrationService thingTypeMigrationService) {
105 this.teslaTarget = teslaClient.target(URI_OWNERS);
106 this.ssoHandler = new TeslaSSOHandler(httpClientFactory.getCommonHttpClient());
107 this.thingTypeMigrationService = thingTypeMigrationService;
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);
118 public void initialize() {
119 logger.debug("Initializing the Tesla account handler for {}", this.getStorageKey());
121 updateStatus(ThingStatus.UNKNOWN);
123 lock = new ReentrantLock();
127 if (connectJob == null || connectJob.isCancelled()) {
128 connectJob = scheduler.scheduleWithFixedDelay(connectRunnable, 0, CONNECT_RETRY_INTERVAL,
129 TimeUnit.MILLISECONDS);
137 public void dispose() {
138 logger.debug("Disposing the Tesla account handler for {}", getThing().getUID());
142 if (connectJob != null && !connectJob.isCancelled()) {
143 connectJob.cancel(true);
151 public void scanForVehicles() {
152 scheduler.execute(this::queryVehicles);
155 public void addVehicleListener(VehicleListener listener) {
156 this.vehicleListeners.add(listener);
159 public void removeVehicleListener(VehicleListener listener) {
160 this.vehicleListeners.remove(listener);
164 public void handleCommand(ChannelUID channelUID, Command command) {
165 // we do not have any channels -> nothing to do here
168 public String getAuthHeader() {
169 if (logonToken != null) {
170 return "Bearer " + logonToken.access_token;
176 public String getAccessToken() {
177 return logonToken.access_token;
180 protected boolean checkResponse(Response response, boolean immediatelyFail) {
181 if (response != null && response.getStatus() == 200) {
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());
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");
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;
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;
209 protected Vehicle[] queryVehicles() {
210 String authHeader = getAuthHeader();
212 if (authHeader != null) {
213 // get a list of vehicles
214 Response response = vehiclesTarget.request(MediaType.APPLICATION_JSON_TYPE)
215 .header("Authorization", authHeader).get();
217 logger.debug("Querying the vehicle: Response: {}: {}", response.getStatus(),
218 response.getStatusInfo().getReasonPhrase());
220 if (!checkResponse(response, true)) {
221 logger.debug("An error occurred while querying the vehicle");
225 JsonObject jsonObject = JsonParser.parseString(response.readEntity(String.class)).getAsJsonObject();
226 Vehicle[] vehicleArray = gson.fromJson(jsonObject.getAsJsonArray("response"), Vehicle[].class);
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;
234 for (VehicleListener listener : vehicleListeners) {
235 listener.vehicleFound(vehicle, vehicleConfig);
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
245 thingTypeMigrationService.migrateThingType(vehicleThing, vehicleConfig.identifyModel(),
246 vehicleThing.getConfiguration());
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,
260 return new Vehicle[0];
264 private String getStorageKey() {
265 return this.getThing().getUID().getId();
268 ThingStatusInfo authenticate() {
269 TokenResponse token = logonToken;
271 boolean hasExpired = true;
272 logger.debug("Current authentication time {}", DATE_FORMATTER.format(Instant.now()));
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));
280 if (tokenExpiresInstant.isBefore(Instant.now())) {
281 logger.debug("The access token has expired");
284 logger.debug("The access token has not expired yet");
290 String refreshToken = (String) getConfig().get(CONFIG_REFRESHTOKEN);
292 if (refreshToken == null || refreshToken.isEmpty()) {
293 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
294 "No refresh token is provided.");
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.");
304 return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
307 protected String invokeAndParse(String vehicleId, String command, String payLoad, WebTarget target,
309 logger.debug("Invoking: {}", command);
311 if (vehicleId != null) {
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));
320 response = target.resolveTemplate("vid", vehicleId).request()
321 .header("Authorization", "Bearer " + logonToken.access_token)
322 .post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
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();
329 response = target.resolveTemplate("vid", vehicleId).request(MediaType.APPLICATION_JSON_TYPE)
330 .header("Authorization", "Bearer " + logonToken.access_token).get();
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) {
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) {
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());
361 protected Runnable connectRunnable = () -> {
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");
371 ThingStatusInfo authenticationResult = authenticate();
372 updateStatus(authenticationResult.getStatus(), authenticationResult.getStatusDetail(),
373 authenticationResult.getDescription());
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();
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))) {
392 "Found the vehicle with VIN '{}' in the list of vehicles you own",
393 getConfig().get(VIN));
394 apiIntervalErrors = 0;
395 apiIntervalTimestamp = System.currentTimeMillis();
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.");
408 } else if (response != null) {
409 logger.error("Error fetching the list of vehicles : {}:{}", response.getStatus(),
410 response.getStatusInfo());
411 updateStatus(ThingStatus.OFFLINE);
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());
419 } catch (Exception e) {
420 logger.error("An exception occurred while connecting to the Tesla back-end: '{}'", e.getMessage(), e);
426 private boolean hasUnidentifiedVehicles() {
427 return getThing().getThings().stream()
428 .anyMatch(vehicle -> TeslaBindingConstants.THING_TYPE_VEHICLE.equals(vehicle.getThingTypeUID()));
431 protected class Request implements Runnable {
433 private static final int NO_OF_RETRIES = 3;
435 private TeslaVehicleHandler handler;
436 private String request;
437 private String payLoad;
438 private WebTarget target;
439 private boolean allowWakeUpForCommands;
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;
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);
462 } catch (Exception e) {
463 logger.error("An exception occurred while executing a request to the vehicle: '{}'", e.getMessage(), e);
468 public Request newRequest(TeslaVehicleHandler teslaVehicleHandler, String command, String payLoad, WebTarget target,
469 boolean allowWakeUpForCommands) {
470 return new Request(teslaVehicleHandler, command, payLoad, target, allowWakeUpForCommands);
474 public Collection<Class<? extends ThingHandlerService>> getServices() {
475 return List.of(TeslaVehicleDiscoveryService.class);