2 * Copyright (c) 2010-2021 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.security.GeneralSecurityException;
20 import java.time.Instant;
21 import java.time.ZoneId;
22 import java.time.format.DateTimeFormatter;
23 import java.util.Base64;
24 import java.util.Collection;
25 import java.util.Collections;
26 import java.util.HashSet;
27 import java.util.List;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
31 import java.util.concurrent.locks.ReentrantLock;
33 import javax.ws.rs.ProcessingException;
34 import javax.ws.rs.client.Client;
35 import javax.ws.rs.client.ClientRequestContext;
36 import javax.ws.rs.client.ClientRequestFilter;
37 import javax.ws.rs.client.Entity;
38 import javax.ws.rs.client.WebTarget;
39 import javax.ws.rs.core.MediaType;
40 import javax.ws.rs.core.MultivaluedMap;
41 import javax.ws.rs.core.Response;
43 import org.apache.commons.lang.StringUtils;
44 import org.openhab.binding.tesla.internal.TeslaBindingConstants;
45 import org.openhab.binding.tesla.internal.discovery.TeslaVehicleDiscoveryService;
46 import org.openhab.binding.tesla.internal.protocol.TokenRequest;
47 import org.openhab.binding.tesla.internal.protocol.TokenRequestPassword;
48 import org.openhab.binding.tesla.internal.protocol.TokenRequestRefreshToken;
49 import org.openhab.binding.tesla.internal.protocol.TokenResponse;
50 import org.openhab.binding.tesla.internal.protocol.Vehicle;
51 import org.openhab.binding.tesla.internal.protocol.VehicleConfig;
52 import org.openhab.core.config.core.Configuration;
53 import org.openhab.core.thing.Bridge;
54 import org.openhab.core.thing.ChannelUID;
55 import org.openhab.core.thing.Thing;
56 import org.openhab.core.thing.ThingStatus;
57 import org.openhab.core.thing.ThingStatusDetail;
58 import org.openhab.core.thing.ThingStatusInfo;
59 import org.openhab.core.thing.binding.BaseBridgeHandler;
60 import org.openhab.core.thing.binding.ThingHandlerService;
61 import org.openhab.core.types.Command;
62 import org.slf4j.Logger;
63 import org.slf4j.LoggerFactory;
65 import com.google.gson.Gson;
66 import com.google.gson.JsonObject;
67 import com.google.gson.JsonParser;
70 * The {@link TeslaAccountHandler} is responsible for handling commands, which are sent
71 * to one of the channels.
73 * @author Karel Goderis - Initial contribution
74 * @author Nicolai Grødum - Adding token based auth
75 * @author Kai Kreuzer - refactored to use separate vehicle handlers
77 public class TeslaAccountHandler extends BaseBridgeHandler {
79 public static final int API_MAXIMUM_ERRORS_IN_INTERVAL = 2;
80 public static final int API_ERROR_INTERVAL_SECONDS = 15;
81 private static final int CONNECT_RETRY_INTERVAL = 15000;
82 private static final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
83 .withZone(ZoneId.systemDefault());
85 private final Logger logger = LoggerFactory.getLogger(TeslaAccountHandler.class);
87 // REST Client API variables
88 private final WebTarget teslaTarget;
89 private final WebTarget tokenTarget;
90 WebTarget vehiclesTarget; // this cannot be marked final as it is used in the runnable
91 final WebTarget vehicleTarget;
92 final WebTarget dataRequestTarget;
93 final WebTarget commandTarget;
94 final WebTarget wakeUpTarget;
96 // Threading and Job related variables
97 protected ScheduledFuture<?> connectJob;
99 protected long lastTimeStamp;
100 protected long apiIntervalTimestamp;
101 protected int apiIntervalErrors;
102 protected long eventIntervalTimestamp;
103 protected int eventIntervalErrors;
104 protected ReentrantLock lock;
106 private final Gson gson = new Gson();
107 private final JsonParser parser = new JsonParser();
109 private TokenResponse logonToken;
110 private final Set<VehicleListener> vehicleListeners = new HashSet<>();
112 public TeslaAccountHandler(Bridge bridge, Client teslaClient) {
114 this.teslaTarget = teslaClient.target(URI_OWNERS);
115 this.tokenTarget = teslaTarget.path(URI_ACCESS_TOKEN);
116 this.vehiclesTarget = teslaTarget.path(API_VERSION).path(VEHICLES);
117 this.vehicleTarget = vehiclesTarget.path(PATH_VEHICLE_ID);
118 this.dataRequestTarget = vehicleTarget.path(PATH_DATA_REQUEST);
119 this.commandTarget = vehicleTarget.path(PATH_COMMAND);
120 this.wakeUpTarget = vehicleTarget.path(PATH_WAKE_UP);
124 public void initialize() {
125 logger.trace("Initializing the Tesla account handler for {}", this.getStorageKey());
127 updateStatus(ThingStatus.UNKNOWN);
129 lock = new ReentrantLock();
133 if (connectJob == null || connectJob.isCancelled()) {
134 connectJob = scheduler.scheduleWithFixedDelay(connectRunnable, 0, CONNECT_RETRY_INTERVAL,
135 TimeUnit.MILLISECONDS);
143 public void dispose() {
144 logger.trace("Disposing the Tesla account handler for {}", getThing().getUID());
148 if (connectJob != null && !connectJob.isCancelled()) {
149 connectJob.cancel(true);
157 public void scanForVehicles() {
158 scheduler.execute(() -> queryVehicles());
161 public void addVehicleListener(VehicleListener listener) {
162 this.vehicleListeners.add(listener);
165 public void removeVehicleListener(VehicleListener listener) {
166 this.vehicleListeners.remove(listener);
170 public void handleCommand(ChannelUID channelUID, Command command) {
171 // we do not have any channels -> nothing to do here
174 public String getAuthHeader() {
175 if (logonToken != null) {
176 return "Bearer " + logonToken.access_token;
182 protected boolean checkResponse(Response response, boolean immediatelyFail) {
183 if (response != null && response.getStatus() == 200) {
187 if (immediatelyFail || apiIntervalErrors >= API_MAXIMUM_ERRORS_IN_INTERVAL) {
188 if (immediatelyFail) {
189 logger.warn("Got an unsuccessful result, setting vehicle to offline and will try again");
191 logger.warn("Reached the maximum number of errors ({}) for the current interval ({} seconds)",
192 API_MAXIMUM_ERRORS_IN_INTERVAL, API_ERROR_INTERVAL_SECONDS);
195 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
196 } else if ((System.currentTimeMillis() - apiIntervalTimestamp) > 1000 * API_ERROR_INTERVAL_SECONDS) {
197 logger.trace("Resetting the error counter. ({} errors in the last interval)", apiIntervalErrors);
198 apiIntervalTimestamp = System.currentTimeMillis();
199 apiIntervalErrors = 0;
206 protected Vehicle[] queryVehicles() {
207 String authHeader = getAuthHeader();
209 if (authHeader != null) {
210 // get a list of vehicles
211 Response response = vehiclesTarget.request(MediaType.APPLICATION_JSON_TYPE)
212 .header("Authorization", authHeader).get();
214 logger.debug("Querying the vehicle: Response: {}:{}", response.getStatus(), response.getStatusInfo());
216 if (!checkResponse(response, true)) {
217 logger.error("An error occurred while querying the vehicle");
221 JsonObject jsonObject = parser.parse(response.readEntity(String.class)).getAsJsonObject();
222 Vehicle[] vehicleArray = gson.fromJson(jsonObject.getAsJsonArray("response"), Vehicle[].class);
224 for (Vehicle vehicle : vehicleArray) {
225 String responseString = invokeAndParse(vehicle.id, VEHICLE_CONFIG, null, dataRequestTarget);
226 if (StringUtils.isBlank(responseString)) {
229 VehicleConfig vehicleConfig = gson.fromJson(responseString, VehicleConfig.class);
230 for (VehicleListener listener : vehicleListeners) {
231 listener.vehicleFound(vehicle, vehicleConfig);
233 for (Thing vehicleThing : getThing().getThings()) {
234 if (vehicle.vin.equals(vehicleThing.getConfiguration().get(VIN))) {
235 TeslaVehicleHandler vehicleHandler = (TeslaVehicleHandler) vehicleThing.getHandler();
236 if (vehicleHandler != null) {
237 logger.debug("Querying the vehicle: VIN {}", vehicle.vin);
238 String vehicleJSON = gson.toJson(vehicle);
239 vehicleHandler.parseAndUpdate("queryVehicle", null, vehicleJSON);
240 logger.trace("Vehicle is id {}/vehicle_id {}/tokens {}", vehicle.id, vehicle.vehicle_id,
248 return new Vehicle[0];
252 private String getStorageKey() {
253 return this.getThing().getUID().getId();
256 private ThingStatusInfo authenticate() {
257 TokenResponse token = logonToken;
259 boolean hasExpired = true;
262 Instant tokenCreationInstant = Instant.ofEpochMilli(token.created_at * 1000);
263 logger.debug("Found a request token created at {}", dateFormatter.format(tokenCreationInstant));
264 Instant tokenExpiresInstant = Instant.ofEpochMilli(token.created_at * 1000 + 60 * token.expires_in);
266 if (tokenExpiresInstant.isBefore(Instant.now())) {
267 logger.debug("The token has expired at {}", dateFormatter.format(tokenExpiresInstant));
275 String username = (String) getConfig().get(CONFIG_USERNAME);
276 String refreshToken = (String) getConfig().get(CONFIG_REFRESHTOKEN);
277 if (refreshToken == null || StringUtils.isEmpty(refreshToken)) {
278 if (!StringUtils.isEmpty(username)) {
279 String password = (String) getConfig().get(CONFIG_PASSWORD);
280 return authenticate(username, password);
282 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
283 "Neither a refresh token nor credentials are provided.");
287 TokenRequestRefreshToken tokenRequest = null;
289 tokenRequest = new TokenRequestRefreshToken(refreshToken);
290 } catch (GeneralSecurityException e) {
291 logger.error("An exception occurred while requesting a new token: '{}'", e.getMessage(), e);
294 String payLoad = gson.toJson(tokenRequest);
295 Response response = null;
297 response = tokenTarget.request().post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
298 } catch (ProcessingException e) {
299 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
302 logger.debug("Authenticating: Response: {}:{}", response.getStatus(), response.getStatusInfo());
304 if (response.getStatus() == 200 && response.hasEntity()) {
305 String responsePayLoad = response.readEntity(String.class);
306 TokenResponse tokenResponse = gson.fromJson(responsePayLoad.trim(), TokenResponse.class);
307 if (!refreshToken.equals(tokenResponse.refresh_token)) {
308 Configuration configuration = editConfiguration();
309 configuration.put(CONFIG_REFRESHTOKEN, tokenResponse.refresh_token);
310 updateConfiguration(configuration);
313 if (!StringUtils.isEmpty(tokenResponse.access_token)) {
314 this.logonToken = tokenResponse;
315 logger.trace("Access Token is {}", logonToken.access_token);
317 return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
318 } else if (response.getStatus() == 401) {
319 if (!StringUtils.isEmpty(username)) {
320 String password = (String) getConfig().get(CONFIG_PASSWORD);
321 return authenticate(username, password);
323 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
324 "Refresh token is not valid and no credentials are provided.");
327 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
328 "HTTP returncode " + response.getStatus());
331 return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
334 private ThingStatusInfo authenticate(String username, String password) {
335 TokenRequest token = null;
337 token = new TokenRequestPassword(username, password);
338 } catch (GeneralSecurityException e) {
339 logger.error("An exception occurred while building a password request token: '{}'", e.getMessage(), e);
343 String payLoad = gson.toJson(token);
345 Response response = tokenTarget.request().post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
347 if (response != null) {
348 logger.debug("Authenticating: Response : {}:{}", response.getStatus(), response.getStatusInfo());
350 if (response.getStatus() == 200 && response.hasEntity()) {
351 String responsePayLoad = response.readEntity(String.class);
352 TokenResponse tokenResponse = gson.fromJson(responsePayLoad.trim(), TokenResponse.class);
354 if (StringUtils.isNotEmpty(tokenResponse.access_token)) {
355 this.logonToken = tokenResponse;
356 Configuration cfg = editConfiguration();
357 cfg.put(TeslaBindingConstants.CONFIG_REFRESHTOKEN, logonToken.refresh_token);
358 cfg.remove(TeslaBindingConstants.CONFIG_PASSWORD);
359 updateConfiguration(cfg);
360 return new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
362 } else if (response.getStatus() == 401) {
363 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
364 "Invalid credentials.");
366 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
367 "HTTP returncode " + response.getStatus());
370 logger.debug("Authenticating: Response was null");
371 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
372 "Failed retrieving a response from the server.");
375 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
376 "Cannot build request from credentials.");
379 protected String invokeAndParse(String vehicleId, String command, String payLoad, WebTarget target) {
380 logger.debug("Invoking: {}", command);
382 if (vehicleId != null) {
385 if (payLoad != null) {
386 if (command != null) {
387 response = target.resolveTemplate("cmd", command).resolveTemplate("vid", vehicleId).request()
388 .header("Authorization", "Bearer " + logonToken.access_token)
389 .post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
391 response = target.resolveTemplate("vid", vehicleId).request()
392 .header("Authorization", "Bearer " + logonToken.access_token)
393 .post(Entity.entity(payLoad, MediaType.APPLICATION_JSON_TYPE));
396 if (command != null) {
397 response = target.resolveTemplate("cmd", command).resolveTemplate("vid", vehicleId)
398 .request(MediaType.APPLICATION_JSON_TYPE)
399 .header("Authorization", "Bearer " + logonToken.access_token).get();
401 response = target.resolveTemplate("vid", vehicleId).request(MediaType.APPLICATION_JSON_TYPE)
402 .header("Authorization", "Bearer " + logonToken.access_token).get();
406 if (!checkResponse(response, false)) {
407 logger.debug("An error occurred while communicating with the vehicle during request {}: {}:{}", command,
408 (response != null) ? response.getStatus() : "",
409 (response != null) ? response.getStatusInfo() : "No Response");
414 JsonObject jsonObject = parser.parse(response.readEntity(String.class)).getAsJsonObject();
415 logger.trace("Request : {}:{}:{} yields {}", command, payLoad, target, jsonObject.get("response"));
416 return jsonObject.get("response").toString();
417 } catch (Exception e) {
418 logger.error("An exception occurred while invoking a REST request: '{}'", e.getMessage());
425 protected Runnable connectRunnable = () -> {
429 if (getThing().getStatus() != ThingStatus.ONLINE) {
430 logger.debug("Setting up an authenticated connection to the Tesla back-end");
432 ThingStatusInfo authenticationResult = authenticate();
433 updateStatus(authenticationResult.getStatus(), authenticationResult.getStatusDetail(),
434 authenticationResult.getDescription());
436 if (authenticationResult.getStatus() == ThingStatus.ONLINE) {
437 // get a list of vehicles
438 Response response = vehiclesTarget.request(MediaType.APPLICATION_JSON_TYPE)
439 .header("Authorization", "Bearer " + logonToken.access_token).get();
441 if (response != null && response.getStatus() == 200 && response.hasEntity()) {
442 updateStatus(ThingStatus.ONLINE);
443 for (Vehicle vehicle : queryVehicles()) {
444 Bridge bridge = getBridge();
445 if (bridge != null) {
446 List<Thing> things = bridge.getThings();
447 for (int i = 0; i < things.size(); i++) {
448 Thing thing = things.get(i);
449 TeslaVehicleHandler handler = (TeslaVehicleHandler) thing.getHandler();
450 if (handler != null) {
451 if (vehicle.vin.equals(thing.getConfiguration().get(VIN))) {
453 "Found the vehicle with VIN '{}' in the list of vehicles you own",
454 getConfig().get(VIN));
455 apiIntervalErrors = 0;
456 apiIntervalTimestamp = System.currentTimeMillis();
459 "Unable to find the vehicle with VIN '{}' in the list of vehicles you own",
460 getConfig().get(VIN));
461 handler.updateStatus(ThingStatus.OFFLINE,
462 ThingStatusDetail.CONFIGURATION_ERROR,
463 "Vin is not available through this account.");
470 if (response != null) {
471 logger.error("Error fetching the list of vehicles : {}:{}", response.getStatus(),
472 response.getStatusInfo());
473 updateStatus(ThingStatus.OFFLINE);
478 } catch (Exception e) {
479 logger.error("An exception occurred while connecting to the Tesla back-end: '{}'", e.getMessage(), e);
485 public static class Authenticator implements ClientRequestFilter {
486 private final String user;
487 private final String password;
489 public Authenticator(String user, String password) {
491 this.password = password;
495 public void filter(ClientRequestContext requestContext) throws IOException {
496 MultivaluedMap<String, Object> headers = requestContext.getHeaders();
497 final String basicAuthentication = getBasicAuthentication();
498 headers.add("Authorization", basicAuthentication);
501 private String getBasicAuthentication() {
502 String token = this.user + ":" + this.password;
503 return "Basic " + Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8));
507 protected class Request implements Runnable {
509 private TeslaVehicleHandler handler;
510 private String request;
511 private String payLoad;
512 private WebTarget target;
514 public Request(TeslaVehicleHandler handler, String request, String payLoad, WebTarget target) {
515 this.handler = handler;
516 this.request = request;
517 this.payLoad = payLoad;
518 this.target = target;
526 if (getThing().getStatus() == ThingStatus.ONLINE) {
527 result = invokeAndParse(handler.getVehicleId(), request, payLoad, target);
528 if (result != null && !"".equals(result)) {
529 handler.parseAndUpdate(request, payLoad, result);
532 } catch (Exception e) {
533 logger.error("An exception occurred while executing a request to the vehicle: '{}'", e.getMessage(), e);
538 public Request newRequest(TeslaVehicleHandler teslaVehicleHandler, String command, String payLoad,
540 return new Request(teslaVehicleHandler, command, payLoad, target);
544 public Collection<Class<? extends ThingHandlerService>> getServices() {
545 return Collections.singletonList(TeslaVehicleDiscoveryService.class);