2 * Copyright (c) 2010-2022 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.Collections;
22 import java.util.HashSet;
23 import java.util.List;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27 import java.util.concurrent.locks.ReentrantLock;
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;
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;
52 import com.google.gson.Gson;
53 import com.google.gson.JsonObject;
54 import com.google.gson.JsonParser;
57 * The {@link TeslaAccountHandler} is responsible for handling commands, which are sent
58 * to one of the channels.
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
64 public class TeslaAccountHandler extends BaseBridgeHandler {
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());
72 private final Logger logger = LoggerFactory.getLogger(TeslaAccountHandler.class);
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;
82 private final TeslaSSOHandler ssoHandler;
84 // Threading and Job related variables
85 protected ScheduledFuture<?> connectJob;
87 protected long lastTimeStamp;
88 protected long apiIntervalTimestamp;
89 protected int apiIntervalErrors;
90 protected long eventIntervalTimestamp;
91 protected int eventIntervalErrors;
92 protected ReentrantLock lock;
94 private final Gson gson = new Gson();
96 private TokenResponse logonToken;
97 private final Set<VehicleListener> vehicleListeners = new HashSet<>();
99 public TeslaAccountHandler(Bridge bridge, Client teslaClient, HttpClientFactory httpClientFactory) {
101 this.teslaTarget = teslaClient.target(URI_OWNERS);
102 this.ssoHandler = new TeslaSSOHandler(httpClientFactory.getCommonHttpClient());
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);
112 public void initialize() {
113 logger.trace("Initializing the Tesla account handler for {}", this.getStorageKey());
115 updateStatus(ThingStatus.UNKNOWN);
117 lock = new ReentrantLock();
121 if (connectJob == null || connectJob.isCancelled()) {
122 connectJob = scheduler.scheduleWithFixedDelay(connectRunnable, 0, CONNECT_RETRY_INTERVAL,
123 TimeUnit.MILLISECONDS);
131 public void dispose() {
132 logger.trace("Disposing the Tesla account handler for {}", getThing().getUID());
136 if (connectJob != null && !connectJob.isCancelled()) {
137 connectJob.cancel(true);
145 public void scanForVehicles() {
146 scheduler.execute(() -> queryVehicles());
149 public void addVehicleListener(VehicleListener listener) {
150 this.vehicleListeners.add(listener);
153 public void removeVehicleListener(VehicleListener listener) {
154 this.vehicleListeners.remove(listener);
158 public void handleCommand(ChannelUID channelUID, Command command) {
159 // we do not have any channels -> nothing to do here
162 public String getAuthHeader() {
163 if (logonToken != null) {
164 return "Bearer " + logonToken.access_token;
170 protected boolean checkResponse(Response response, boolean immediatelyFail) {
171 if (response != null && response.getStatus() == 200) {
175 if (immediatelyFail || apiIntervalErrors >= API_MAXIMUM_ERRORS_IN_INTERVAL) {
176 if (immediatelyFail) {
177 logger.warn("Got an unsuccessful result, setting vehicle to offline and will try again");
179 logger.warn("Reached the maximum number of errors ({}) for the current interval ({} seconds)",
180 API_MAXIMUM_ERRORS_IN_INTERVAL, API_ERROR_INTERVAL_SECONDS);
183 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
184 } else if ((System.currentTimeMillis() - apiIntervalTimestamp) > 1000 * API_ERROR_INTERVAL_SECONDS) {
185 logger.trace("Resetting the error counter. ({} errors in the last interval)", apiIntervalErrors);
186 apiIntervalTimestamp = System.currentTimeMillis();
187 apiIntervalErrors = 0;
194 protected Vehicle[] queryVehicles() {
195 String authHeader = getAuthHeader();
197 if (authHeader != null) {
198 // get a list of vehicles
199 Response response = vehiclesTarget.request(MediaType.APPLICATION_JSON_TYPE)
200 .header("Authorization", authHeader).get();
202 logger.debug("Querying the vehicle: Response: {}: {}", response.getStatus(),
203 response.getStatusInfo().getReasonPhrase());
205 if (!checkResponse(response, true)) {
206 logger.error("An error occurred while querying the vehicle");
210 JsonObject jsonObject = JsonParser.parseString(response.readEntity(String.class)).getAsJsonObject();
211 Vehicle[] vehicleArray = gson.fromJson(jsonObject.getAsJsonArray("response"), Vehicle[].class);
213 for (Vehicle vehicle : vehicleArray) {
214 String responseString = invokeAndParse(vehicle.id, VEHICLE_CONFIG, null, dataRequestTarget, 0);
215 if (responseString == null || responseString.isBlank()) {
218 VehicleConfig vehicleConfig = gson.fromJson(responseString, VehicleConfig.class);
219 for (VehicleListener listener : vehicleListeners) {
220 listener.vehicleFound(vehicle, vehicleConfig);
222 for (Thing vehicleThing : getThing().getThings()) {
223 if (vehicle.vin.equals(vehicleThing.getConfiguration().get(VIN))) {
224 TeslaVehicleHandler vehicleHandler = (TeslaVehicleHandler) vehicleThing.getHandler();
225 if (vehicleHandler != null) {
226 logger.debug("Querying the vehicle: VIN {}", vehicle.vin);
227 String vehicleJSON = gson.toJson(vehicle);
228 vehicleHandler.parseAndUpdate("queryVehicle", null, vehicleJSON);
229 logger.trace("Vehicle is id {}/vehicle_id {}/tokens {}", vehicle.id, vehicle.vehicle_id,
237 return new Vehicle[0];
241 private String getStorageKey() {
242 return this.getThing().getUID().getId();
245 private ThingStatusInfo authenticate() {
246 TokenResponse token = logonToken;
248 boolean hasExpired = true;
251 Instant tokenCreationInstant = Instant.ofEpochMilli(token.created_at * 1000);
252 logger.debug("Found a request token created at {}", dateFormatter.format(tokenCreationInstant));
253 Instant tokenExpiresInstant = Instant.ofEpochMilli(token.created_at * 1000 + 60 * token.expires_in);
255 if (tokenExpiresInstant.isBefore(Instant.now())) {
256 logger.debug("The token has expired at {}", dateFormatter.format(tokenExpiresInstant));
264 String refreshToken = (String) getConfig().get(CONFIG_REFRESHTOKEN);
266 if (refreshToken == null || refreshToken.isEmpty()) {
267 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
268 "No refresh token is provided.");
271 this.logonToken = ssoHandler.getAccessToken(refreshToken);
272 if (this.logonToken == null) {
273 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
274 "Failed to obtain access token for API.");
278 return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
281 protected String invokeAndParse(String vehicleId, String command, String payLoad, WebTarget target,
283 logger.debug("Invoking: {}", command);
285 if (vehicleId != null) {
288 if (payLoad != null) {
289 if (command != null) {
290 response = target.resolveTemplate("cmd", command).resolveTemplate("vid", vehicleId).request()
291 .header("Authorization", "Bearer " + logonToken.access_token)
292 .post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
294 response = target.resolveTemplate("vid", vehicleId).request()
295 .header("Authorization", "Bearer " + logonToken.access_token)
296 .post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
299 if (command != null) {
300 response = target.resolveTemplate("cmd", command).resolveTemplate("vid", vehicleId)
301 .request(MediaType.APPLICATION_JSON_TYPE)
302 .header("Authorization", "Bearer " + logonToken.access_token).get();
304 response = target.resolveTemplate("vid", vehicleId).request(MediaType.APPLICATION_JSON_TYPE)
305 .header("Authorization", "Bearer " + logonToken.access_token).get();
309 if (!checkResponse(response, false)) {
310 logger.debug("An error occurred while communicating with the vehicle during request {}: {}: {}",
311 command, (response != null) ? response.getStatus() : "",
312 (response != null) ? response.getStatusInfo().getReasonPhrase() : "No Response");
313 if (response.getStatus() == 408 && noOfretries > 0) {
315 // we give the vehicle a moment to wake up and try the request again
316 Thread.sleep(TimeUnit.SECONDS.toMillis(API_ERROR_INTERVAL_SECONDS));
317 logger.debug("Retrying to send the command {}.", command);
318 return invokeAndParse(vehicleId, command, payLoad, target, noOfretries - 1);
319 } catch (InterruptedException e) {
327 JsonObject jsonObject = JsonParser.parseString(response.readEntity(String.class)).getAsJsonObject();
328 logger.trace("Request : {}:{} yields {}", command, payLoad, jsonObject.get("response"));
329 return jsonObject.get("response").toString();
330 } catch (Exception e) {
331 logger.error("An exception occurred while invoking a REST request: '{}'", e.getMessage());
338 protected Runnable connectRunnable = () -> {
342 ThingStatusInfo status = getThing().getStatusInfo();
343 if (status.getStatus() != ThingStatus.ONLINE
344 && status.getStatusDetail() != ThingStatusDetail.CONFIGURATION_ERROR) {
345 logger.debug("Setting up an authenticated connection to the Tesla back-end");
347 ThingStatusInfo authenticationResult = authenticate();
348 updateStatus(authenticationResult.getStatus(), authenticationResult.getStatusDetail(),
349 authenticationResult.getDescription());
351 if (authenticationResult.getStatus() == ThingStatus.ONLINE) {
352 // get a list of vehicles
353 Response response = vehiclesTarget.request(MediaType.APPLICATION_JSON_TYPE)
354 .header("Authorization", "Bearer " + logonToken.access_token).get();
356 if (response != null && response.getStatus() == 200 && response.hasEntity()) {
357 updateStatus(ThingStatus.ONLINE);
358 for (Vehicle vehicle : queryVehicles()) {
359 Bridge bridge = getBridge();
360 if (bridge != null) {
361 List<Thing> things = bridge.getThings();
362 for (int i = 0; i < things.size(); i++) {
363 Thing thing = things.get(i);
364 TeslaVehicleHandler handler = (TeslaVehicleHandler) thing.getHandler();
365 if (handler != null) {
366 if (vehicle.vin.equals(thing.getConfiguration().get(VIN))) {
368 "Found the vehicle with VIN '{}' in the list of vehicles you own",
369 getConfig().get(VIN));
370 apiIntervalErrors = 0;
371 apiIntervalTimestamp = System.currentTimeMillis();
374 "Unable to find the vehicle with VIN '{}' in the list of vehicles you own",
375 getConfig().get(VIN));
376 handler.updateStatus(ThingStatus.OFFLINE,
377 ThingStatusDetail.CONFIGURATION_ERROR,
378 "Vin is not available through this account.");
385 if (response != null) {
386 logger.error("Error fetching the list of vehicles : {}:{}", response.getStatus(),
387 response.getStatusInfo());
388 updateStatus(ThingStatus.OFFLINE);
391 } else if (authenticationResult.getStatusDetail() == ThingStatusDetail.CONFIGURATION_ERROR) {
392 // make sure to set thing to CONFIGURATION_ERROR in case of failed authentication in order not to
393 // hit request limit on retries on the Tesla SSO endpoints.
394 updateStatus(ThingStatus.OFFLINE, authenticationResult.getStatusDetail());
398 } catch (Exception e) {
399 logger.error("An exception occurred while connecting to the Tesla back-end: '{}'", e.getMessage(), e);
405 protected class Request implements Runnable {
407 private static final int NO_OF_RETRIES = 3;
409 private TeslaVehicleHandler handler;
410 private String request;
411 private String payLoad;
412 private WebTarget target;
413 private boolean allowWakeUpForCommands;
415 public Request(TeslaVehicleHandler handler, String request, String payLoad, WebTarget target,
416 boolean allowWakeUpForCommands) {
417 this.handler = handler;
418 this.request = request;
419 this.payLoad = payLoad;
420 this.target = target;
421 this.allowWakeUpForCommands = allowWakeUpForCommands;
429 if (getThing().getStatus() == ThingStatus.ONLINE) {
430 result = invokeAndParse(handler.getVehicleId(), request, payLoad, target,
431 allowWakeUpForCommands ? NO_OF_RETRIES : 0);
432 if (result != null && !"".equals(result)) {
433 handler.parseAndUpdate(request, payLoad, result);
436 } catch (Exception e) {
437 logger.error("An exception occurred while executing a request to the vehicle: '{}'", e.getMessage(), e);
442 public Request newRequest(TeslaVehicleHandler teslaVehicleHandler, String command, String payLoad, WebTarget target,
443 boolean allowWakeUpForCommands) {
444 return new Request(teslaVehicleHandler, command, payLoad, target, allowWakeUpForCommands);
448 public Collection<Class<? extends ThingHandlerService>> getServices() {
449 return Collections.singletonList(TeslaVehicleDiscoveryService.class);