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.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;
28 import java.util.concurrent.ScheduledFuture;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.locks.ReentrantLock;
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;
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;
60 import com.google.gson.Gson;
61 import com.google.gson.JsonObject;
62 import com.google.gson.JsonParser;
65 * The {@link TeslaAccountHandler} is responsible for handling commands, which are sent
66 * to one of the channels.
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
72 public class TeslaAccountHandler extends BaseBridgeHandler {
74 public static final int API_MAXIMUM_ERRORS_IN_INTERVAL = 2;
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());
80 private final Logger logger = LoggerFactory.getLogger(TeslaAccountHandler.class);
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;
90 private final TeslaSSOHandler ssoHandler;
92 // Threading and Job related variables
93 protected ScheduledFuture<?> connectJob;
95 protected long lastTimeStamp;
96 protected long apiIntervalTimestamp;
97 protected int apiIntervalErrors;
98 protected long eventIntervalTimestamp;
99 protected int eventIntervalErrors;
100 protected ReentrantLock lock;
102 private final Gson gson = new Gson();
104 private TokenResponse logonToken;
105 private final Set<VehicleListener> vehicleListeners = new HashSet<>();
107 public TeslaAccountHandler(Bridge bridge, Client teslaClient, HttpClientFactory httpClientFactory) {
109 this.teslaTarget = teslaClient.target(URI_OWNERS);
110 this.ssoHandler = new TeslaSSOHandler(httpClientFactory.getCommonHttpClient());
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);
120 public void initialize() {
121 logger.trace("Initializing the Tesla account handler for {}", this.getStorageKey());
123 updateStatus(ThingStatus.UNKNOWN);
125 lock = new ReentrantLock();
129 if (connectJob == null || connectJob.isCancelled()) {
130 connectJob = scheduler.scheduleWithFixedDelay(connectRunnable, 0, CONNECT_RETRY_INTERVAL,
131 TimeUnit.MILLISECONDS);
139 public void dispose() {
140 logger.trace("Disposing the Tesla account handler for {}", getThing().getUID());
144 if (connectJob != null && !connectJob.isCancelled()) {
145 connectJob.cancel(true);
153 public void scanForVehicles() {
154 scheduler.execute(() -> queryVehicles());
157 public void addVehicleListener(VehicleListener listener) {
158 this.vehicleListeners.add(listener);
161 public void removeVehicleListener(VehicleListener listener) {
162 this.vehicleListeners.remove(listener);
166 public void handleCommand(ChannelUID channelUID, Command command) {
167 // we do not have any channels -> nothing to do here
170 public String getAuthHeader() {
171 if (logonToken != null) {
172 return "Bearer " + logonToken.access_token;
178 protected boolean checkResponse(Response response, boolean immediatelyFail) {
179 if (response != null && response.getStatus() == 200) {
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");
187 logger.warn("Reached the maximum number of errors ({}) for the current interval ({} seconds)",
188 API_MAXIMUM_ERRORS_IN_INTERVAL, API_ERROR_INTERVAL_SECONDS);
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;
202 protected Vehicle[] queryVehicles() {
203 String authHeader = getAuthHeader();
205 if (authHeader != null) {
206 // get a list of vehicles
207 Response response = vehiclesTarget.request(MediaType.APPLICATION_JSON_TYPE)
208 .header("Authorization", authHeader).get();
210 logger.debug("Querying the vehicle: Response: {}: {}", response.getStatus(),
211 response.getStatusInfo().getReasonPhrase());
213 if (!checkResponse(response, true)) {
214 logger.error("An error occurred while querying the vehicle");
218 JsonObject jsonObject = JsonParser.parseString(response.readEntity(String.class)).getAsJsonObject();
219 Vehicle[] vehicleArray = gson.fromJson(jsonObject.getAsJsonArray("response"), Vehicle[].class);
221 for (Vehicle vehicle : vehicleArray) {
222 String responseString = invokeAndParse(vehicle.id, VEHICLE_CONFIG, null, dataRequestTarget);
223 if (responseString == null || responseString.isBlank()) {
226 VehicleConfig vehicleConfig = gson.fromJson(responseString, VehicleConfig.class);
227 for (VehicleListener listener : vehicleListeners) {
228 listener.vehicleFound(vehicle, vehicleConfig);
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,
245 return new Vehicle[0];
249 private String getStorageKey() {
250 return this.getThing().getUID().getId();
253 private ThingStatusInfo authenticate() {
254 TokenResponse token = logonToken;
256 boolean hasExpired = true;
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);
263 if (tokenExpiresInstant.isBefore(Instant.now())) {
264 logger.debug("The token has expired at {}", dateFormatter.format(tokenExpiresInstant));
272 String username = (String) getConfig().get(CONFIG_USERNAME);
273 String password = (String) getConfig().get(CONFIG_PASSWORD);
274 String refreshToken = (String) getConfig().get(CONFIG_REFRESHTOKEN);
276 if (refreshToken == null || refreshToken.isEmpty()) {
277 if (username != null && !username.isEmpty() && password != null && !password.isEmpty()) {
279 refreshToken = ssoHandler.authenticate(username, password);
280 } catch (Exception e) {
281 logger.error("An exception occurred while obtaining refresh token with username/password: '{}'",
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);
292 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
293 "Failed to obtain refresh token with username/password.");
296 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
297 "Neither a refresh token nor credentials are provided.");
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.");
308 return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
311 protected String invokeAndParse(String vehicleId, String command, String payLoad, WebTarget target) {
312 logger.debug("Invoking: {}", command);
314 if (vehicleId != null) {
317 if (payLoad != null) {
318 if (command != null) {
319 response = target.resolveTemplate("cmd", command).resolveTemplate("vid", vehicleId).request()
320 .header("Authorization", "Bearer " + logonToken.access_token)
321 .post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
323 response = target.resolveTemplate("vid", vehicleId).request()
324 .header("Authorization", "Bearer " + logonToken.access_token)
325 .post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
328 if (command != null) {
329 response = target.resolveTemplate("cmd", command).resolveTemplate("vid", vehicleId)
330 .request(MediaType.APPLICATION_JSON_TYPE)
331 .header("Authorization", "Bearer " + logonToken.access_token).get();
333 response = target.resolveTemplate("vid", vehicleId).request(MediaType.APPLICATION_JSON_TYPE)
334 .header("Authorization", "Bearer " + logonToken.access_token).get();
338 if (!checkResponse(response, false)) {
339 logger.debug("An error occurred while communicating with the vehicle during request {}: {}: {}",
340 command, (response != null) ? response.getStatus() : "",
341 (response != null) ? response.getStatusInfo().getReasonPhrase() : "No Response");
346 JsonObject jsonObject = JsonParser.parseString(response.readEntity(String.class)).getAsJsonObject();
347 logger.trace("Request : {}:{}:{} yields {}", command, payLoad, target, jsonObject.get("response"));
348 return jsonObject.get("response").toString();
349 } catch (Exception e) {
350 logger.error("An exception occurred while invoking a REST request: '{}'", e.getMessage());
357 protected Runnable connectRunnable = () -> {
361 ThingStatusInfo status = getThing().getStatusInfo();
362 if (status.getStatus() != ThingStatus.ONLINE
363 && status.getStatusDetail() != ThingStatusDetail.CONFIGURATION_ERROR) {
364 logger.debug("Setting up an authenticated connection to the Tesla back-end");
366 ThingStatusInfo authenticationResult = authenticate();
367 updateStatus(authenticationResult.getStatus(), authenticationResult.getStatusDetail(),
368 authenticationResult.getDescription());
370 if (authenticationResult.getStatus() == ThingStatus.ONLINE) {
371 // get a list of vehicles
372 Response response = vehiclesTarget.request(MediaType.APPLICATION_JSON_TYPE)
373 .header("Authorization", "Bearer " + logonToken.access_token).get();
375 if (response != null && response.getStatus() == 200 && response.hasEntity()) {
376 updateStatus(ThingStatus.ONLINE);
377 for (Vehicle vehicle : queryVehicles()) {
378 Bridge bridge = getBridge();
379 if (bridge != null) {
380 List<Thing> things = bridge.getThings();
381 for (int i = 0; i < things.size(); i++) {
382 Thing thing = things.get(i);
383 TeslaVehicleHandler handler = (TeslaVehicleHandler) thing.getHandler();
384 if (handler != null) {
385 if (vehicle.vin.equals(thing.getConfiguration().get(VIN))) {
387 "Found the vehicle with VIN '{}' in the list of vehicles you own",
388 getConfig().get(VIN));
389 apiIntervalErrors = 0;
390 apiIntervalTimestamp = System.currentTimeMillis();
393 "Unable to find the vehicle with VIN '{}' in the list of vehicles you own",
394 getConfig().get(VIN));
395 handler.updateStatus(ThingStatus.OFFLINE,
396 ThingStatusDetail.CONFIGURATION_ERROR,
397 "Vin is not available through this account.");
404 if (response != null) {
405 logger.error("Error fetching the list of vehicles : {}:{}", response.getStatus(),
406 response.getStatusInfo());
407 updateStatus(ThingStatus.OFFLINE);
410 } else if (authenticationResult.getStatusDetail() == ThingStatusDetail.CONFIGURATION_ERROR) {
411 // make sure to set thing to CONFIGURATION_ERROR in case of failed authentication in order not to
412 // hit request limit on retries on the Tesla SSO endpoints.
413 updateStatus(ThingStatus.OFFLINE, authenticationResult.getStatusDetail());
417 } catch (Exception e) {
418 logger.error("An exception occurred while connecting to the Tesla back-end: '{}'", e.getMessage(), e);
424 public static class Authenticator implements ClientRequestFilter {
425 private final String user;
426 private final String password;
428 public Authenticator(String user, String password) {
430 this.password = password;
434 public void filter(ClientRequestContext requestContext) throws IOException {
435 MultivaluedMap<String, Object> headers = requestContext.getHeaders();
436 final String basicAuthentication = getBasicAuthentication();
437 headers.add("Authorization", basicAuthentication);
440 private String getBasicAuthentication() {
441 String token = this.user + ":" + this.password;
442 return "Basic " + Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8));
446 protected class Request implements Runnable {
448 private TeslaVehicleHandler handler;
449 private String request;
450 private String payLoad;
451 private WebTarget target;
453 public Request(TeslaVehicleHandler handler, String request, String payLoad, WebTarget target) {
454 this.handler = handler;
455 this.request = request;
456 this.payLoad = payLoad;
457 this.target = target;
465 if (getThing().getStatus() == ThingStatus.ONLINE) {
466 result = invokeAndParse(handler.getVehicleId(), request, payLoad, target);
467 if (result != null && !"".equals(result)) {
468 handler.parseAndUpdate(request, payLoad, result);
471 } catch (Exception e) {
472 logger.error("An exception occurred while executing a request to the vehicle: '{}'", e.getMessage(), e);
477 public Request newRequest(TeslaVehicleHandler teslaVehicleHandler, String command, String payLoad,
479 return new Request(teslaVehicleHandler, command, payLoad, target);
483 public Collection<Class<? extends ThingHandlerService>> getServices() {
484 return Collections.singletonList(TeslaVehicleDiscoveryService.class);