2 * Copyright (c) 2010-2023 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.mercedesme.internal.handler;
15 import static org.openhab.binding.mercedesme.internal.Constants.*;
17 import java.io.IOException;
19 import java.net.http.HttpRequest;
20 import java.net.http.HttpResponse;
21 import java.nio.charset.StandardCharsets;
22 import java.time.Duration;
23 import java.time.Instant;
24 import java.util.ArrayList;
25 import java.util.Base64;
26 import java.util.Collections;
27 import java.util.HashMap;
28 import java.util.Iterator;
29 import java.util.List;
31 import java.util.Optional;
32 import java.util.concurrent.ExecutionException;
33 import java.util.concurrent.ScheduledFuture;
34 import java.util.concurrent.TimeUnit;
35 import java.util.concurrent.TimeoutException;
37 import javax.measure.quantity.Length;
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.eclipse.jetty.client.HttpClient;
42 import org.eclipse.jetty.client.api.ContentResponse;
43 import org.eclipse.jetty.client.api.Request;
44 import org.eclipse.jetty.http.HttpHeader;
45 import org.eclipse.jetty.util.MultiMap;
46 import org.eclipse.jetty.util.UrlEncoded;
47 import org.json.JSONArray;
48 import org.json.JSONObject;
49 import org.openhab.binding.mercedesme.internal.Constants;
50 import org.openhab.binding.mercedesme.internal.MercedesMeCommandOptionProvider;
51 import org.openhab.binding.mercedesme.internal.MercedesMeStateOptionProvider;
52 import org.openhab.binding.mercedesme.internal.config.VehicleConfiguration;
53 import org.openhab.binding.mercedesme.internal.utils.ChannelStateMap;
54 import org.openhab.binding.mercedesme.internal.utils.Mapper;
55 import org.openhab.core.i18n.TimeZoneProvider;
56 import org.openhab.core.library.types.DateTimeType;
57 import org.openhab.core.library.types.OnOffType;
58 import org.openhab.core.library.types.QuantityType;
59 import org.openhab.core.library.types.RawType;
60 import org.openhab.core.library.unit.Units;
61 import org.openhab.core.storage.Storage;
62 import org.openhab.core.storage.StorageService;
63 import org.openhab.core.thing.Bridge;
64 import org.openhab.core.thing.ChannelUID;
65 import org.openhab.core.thing.Thing;
66 import org.openhab.core.thing.ThingStatus;
67 import org.openhab.core.thing.ThingStatusDetail;
68 import org.openhab.core.thing.binding.BaseThingHandler;
69 import org.openhab.core.thing.binding.BridgeHandler;
70 import org.openhab.core.types.Command;
71 import org.openhab.core.types.CommandOption;
72 import org.openhab.core.types.RefreshType;
73 import org.openhab.core.types.State;
74 import org.openhab.core.types.StateOption;
75 import org.slf4j.Logger;
76 import org.slf4j.LoggerFactory;
79 * The {@link VehicleHandler} is responsible for handling commands, which are
80 * sent to one of the channels.
82 * @author Bernd Weymann - Initial contribution
85 public class VehicleHandler extends BaseThingHandler {
86 private static final int REQUEST_TIMEOUT_MS = 10_000;
87 private static final String EXT_IMG_RES = "ExtImageResources_";
88 private static final String INITIALIZE_COMMAND = "Initialze";
90 private final Logger logger = LoggerFactory.getLogger(VehicleHandler.class);
91 private final Map<String, Long> timeHash = new HashMap<String, Long>();
92 private final MercedesMeCommandOptionProvider mmcop;
93 private final MercedesMeStateOptionProvider mmsop;
94 private final TimeZoneProvider timeZoneProvider;
95 private final StorageService storageService;
96 private final HttpClient httpClient;
97 private final String uid;
99 private Optional<ScheduledFuture<?>> refreshJob = Optional.empty();
100 private Optional<AccountHandler> accountHandler = Optional.empty();
101 private Optional<QuantityType<?>> rangeElectric = Optional.empty();
102 private Optional<Storage<String>> imageStorage = Optional.empty();
103 private Optional<VehicleConfiguration> config = Optional.empty();
104 private Optional<QuantityType<?>> rangeFuel = Optional.empty();
105 private Instant nextRefresh;
106 private boolean online = false;
108 public VehicleHandler(Thing thing, HttpClient hc, String uid, StorageService storageService,
109 MercedesMeCommandOptionProvider mmcop, MercedesMeStateOptionProvider mmsop, TimeZoneProvider tzp) {
115 timeZoneProvider = tzp;
116 this.storageService = storageService;
117 nextRefresh = Instant.now();
121 public void handleCommand(ChannelUID channelUID, Command command) {
122 logger.trace("Received {} {} {}", channelUID.getAsString(), command.toFullString(), channelUID.getId());
123 if (command instanceof RefreshType) {
125 * Refresh requested e.g. after adding new item
126 * Adding several items will frequently raise RefreshType command. Calling API each time shall be avoided
127 * API update is performed after 5 seconds for all items which should be sufficient for a frequent update
129 if (Instant.now().isAfter(nextRefresh)) {
130 nextRefresh = Instant.now().plus(Duration.ofSeconds(5));
131 logger.trace("Refresh granted - next at {}", nextRefresh);
132 scheduler.schedule(this::getData, 5, TimeUnit.SECONDS);
134 } else if ("image-view".equals(channelUID.getIdWithoutGroup())) {
135 if (imageStorage.isPresent()) {
136 if (INITIALIZE_COMMAND.equals(command.toFullString())) {
139 String key = command.toFullString() + "_" + config.get().vin;
140 String encodedImage = EMPTY;
141 if (imageStorage.get().containsKey(key)) {
142 encodedImage = imageStorage.get().get(key);
143 logger.trace("Image {} found in storage", key);
145 logger.trace("Request Image {} ", key);
146 encodedImage = getImage(command.toFullString());
147 if (!encodedImage.isEmpty()) {
148 imageStorage.get().put(key, encodedImage);
151 if (encodedImage != null && !encodedImage.isEmpty()) {
152 RawType image = new RawType(Base64.getDecoder().decode(encodedImage),
153 MIME_PREFIX + config.get().format);
154 updateState(new ChannelUID(thing.getUID(), GROUP_IMAGE, "image-data"), image);
156 logger.debug("Image {} is empty", key);
159 } else if ("clear-cache".equals(channelUID.getIdWithoutGroup()) && command.equals(OnOffType.ON)) {
160 List<String> removals = new ArrayList<String>();
161 imageStorage.get().getKeys().forEach(entry -> {
162 if (entry.contains("_" + config.get().vin)) {
166 removals.forEach(entry -> {
167 imageStorage.get().remove(entry);
169 updateState(new ChannelUID(thing.getUID(), GROUP_IMAGE, "clear-cache"), OnOffType.OFF);
175 public void initialize() {
176 config = Optional.of(getConfigAs(VehicleConfiguration.class));
177 Bridge bridge = getBridge();
178 if (bridge != null) {
179 updateStatus(ThingStatus.UNKNOWN);
180 BridgeHandler handler = bridge.getHandler();
181 if (handler != null) {
182 accountHandler = Optional.of((AccountHandler) handler);
183 startSchedule(config.get().refreshInterval);
184 if (!config.get().vin.equals(NOT_SET)) {
185 imageStorage = Optional.of(storageService.getStorage(BINDING_ID + "_" + config.get().vin));
186 if (!imageStorage.get().containsKey(EXT_IMG_RES + config.get().vin)) {
191 updateState(new ChannelUID(thing.getUID(), GROUP_IMAGE, "clear-cache"), OnOffType.OFF);
193 throw new IllegalStateException("BridgeHandler is null");
196 String textKey = Constants.STATUS_TEXT_PREFIX + "vehicle" + Constants.STATUS_BRIDGE_MISSING;
197 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, textKey);
201 private void startSchedule(int interval) {
202 refreshJob.ifPresentOrElse(job -> {
203 if (job.isCancelled()) {
204 refreshJob = Optional
205 .of(scheduler.scheduleWithFixedDelay(this::getData, 0, interval, TimeUnit.MINUTES));
206 } // else - scheduler is already running!
208 refreshJob = Optional.of(scheduler.scheduleWithFixedDelay(this::getData, 0, interval, TimeUnit.MINUTES));
213 public void dispose() {
214 refreshJob.ifPresent(job -> job.cancel(true));
217 public void getData() {
218 if (accountHandler.isEmpty()) {
219 logger.warn("AccountHandler not set");
222 String token = accountHandler.get().getToken();
223 if (token.isEmpty()) {
224 String textKey = Constants.STATUS_TEXT_PREFIX + "vehicle" + Constants.STATUS_BRIDGE_ATHORIZATION;
225 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, textKey);
227 } else if (!online) { // only update if thing isn't already ONLINE
228 updateStatus(ThingStatus.ONLINE);
231 // Mileage for all cars
232 String odoUrl = String.format(ODO_URL, config.get().vin);
233 if (accountConfigAvailable()) {
234 if (accountHandler.get().config.get().odoScope) {
237 logger.trace("{} Odo scope not activated", this.getThing().getLabel());
240 logger.trace("{} Account not properly configured", this.getThing().getLabel());
243 // Electric status for hybrid and electric
244 if (uid.equals(BEV) || uid.equals(HYBRID)) {
245 String evUrl = String.format(EV_URL, config.get().vin);
246 if (accountConfigAvailable()) {
247 if (accountHandler.get().config.get().evScope) {
250 logger.trace("{} Electric Status scope not activated", this.getThing().getLabel());
253 logger.trace("{} Account not properly configured", this.getThing().getLabel());
257 // Fuel for hybrid and combustion
258 if (uid.equals(COMBUSTION) || uid.equals(HYBRID)) {
259 String fuelUrl = String.format(FUEL_URL, config.get().vin);
260 if (accountConfigAvailable()) {
261 if (accountHandler.get().config.get().fuelScope) {
264 logger.trace("{} Fuel scope not activated", this.getThing().getLabel());
267 logger.trace("{} Account not properly configured", this.getThing().getLabel());
271 // Status and Lock for all
272 String statusUrl = String.format(STATUS_URL, config.get().vin);
273 if (accountConfigAvailable()) {
274 if (accountHandler.get().config.get().vehicleScope) {
277 logger.trace("{} Vehicle Status scope not activated", this.getThing().getLabel());
280 logger.trace("{} Account not properly configured", this.getThing().getLabel());
282 String lockUrl = String.format(LOCK_URL, config.get().vin);
283 if (accountConfigAvailable()) {
284 if (accountHandler.get().config.get().lockScope) {
287 logger.trace("{} Lock scope not activated", this.getThing().getLabel());
290 logger.trace("{} Account not properly configured", this.getThing().getLabel());
293 // Range radius for all types
297 private boolean accountConfigAvailable() {
298 if (accountHandler.isPresent()) {
299 if (accountHandler.get().config.isPresent()) {
306 private void getImageResources() {
307 if (accountHandler.get().getImageApiKey().equals(NOT_SET)) {
308 logger.debug("Image API key not set");
311 // add config parameters
312 MultiMap<String> parameterMap = new MultiMap<String>();
313 parameterMap.add("background", Boolean.toString(config.get().background));
314 parameterMap.add("night", Boolean.toString(config.get().night));
315 parameterMap.add("cropped", Boolean.toString(config.get().cropped));
316 parameterMap.add("roofOpen", Boolean.toString(config.get().roofOpen));
317 parameterMap.add("fileFormat", config.get().format);
318 String params = UrlEncoded.encode(parameterMap, StandardCharsets.UTF_8, false);
319 String url = String.format(IMAGE_EXTERIOR_RESOURCE_URL, config.get().vin) + "?" + params;
320 logger.debug("Get Image resources {} {} ", accountHandler.get().getImageApiKey(), url);
321 Request req = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
322 req.header("x-api-key", accountHandler.get().getImageApiKey());
323 req.header(HttpHeader.ACCEPT, "application/json");
325 ContentResponse cr = req.send();
326 if (cr.getStatus() == 200) {
327 imageStorage.get().put(EXT_IMG_RES + config.get().vin, cr.getContentAsString());
330 logger.debug("Failed to get image resources {} {}", cr.getStatus(), cr.getContentAsString());
332 } catch (InterruptedException | TimeoutException | ExecutionException e) {
333 logger.debug("Error getting image resources {}", e.getMessage());
337 private void setImageOtions() {
338 List<String> entries = new ArrayList<String>();
339 if (imageStorage.get().containsKey(EXT_IMG_RES + config.get().vin)) {
340 String resources = imageStorage.get().get(EXT_IMG_RES + config.get().vin);
341 JSONObject jo = new JSONObject(resources);
342 jo.keySet().forEach(entry -> {
346 Collections.sort(entries);
347 List<CommandOption> commandOptions = new ArrayList<CommandOption>();
348 List<StateOption> stateOptions = new ArrayList<StateOption>();
349 entries.forEach(entry -> {
350 CommandOption co = new CommandOption(entry, null);
351 commandOptions.add(co);
352 StateOption so = new StateOption(entry, null);
353 stateOptions.add(so);
355 if (commandOptions.isEmpty()) {
356 commandOptions.add(new CommandOption("Initilaze", null));
357 stateOptions.add(new StateOption("Initilaze", null));
359 ChannelUID cuid = new ChannelUID(thing.getUID(), GROUP_IMAGE, "image-view");
360 mmcop.setCommandOptions(cuid, commandOptions);
361 mmsop.setStateOptions(cuid, stateOptions);
364 private String getImage(String key) {
365 if (accountHandler.get().getImageApiKey().equals(NOT_SET)) {
366 logger.debug("Image API key not set");
369 String imageId = EMPTY;
370 if (imageStorage.get().containsKey(EXT_IMG_RES + config.get().vin)) {
371 String resources = imageStorage.get().get(EXT_IMG_RES + config.get().vin);
372 JSONObject jo = new JSONObject(resources);
374 imageId = jo.getString(key);
381 String url = IMAGE_BASE_URL + "/images/" + imageId;
382 Request req = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
383 req.header("x-api-key", accountHandler.get().getImageApiKey());
384 req.header(HttpHeader.ACCEPT, "*/*");
388 byte[] response = cr.getContent();
389 return Base64.getEncoder().encodeToString(response);
390 } catch (InterruptedException | TimeoutException | ExecutionException e) {
391 logger.warn("Get Image {} error {}", url, e.getMessage());
396 private void call(String url) {
397 String requestUrl = String.format(url, config.get().vin);
398 // Calculate endpoint for debugging
399 String[] endpoint = requestUrl.split("/");
400 String finalEndpoint = endpoint[endpoint.length - 1];
401 // debug prefix contains Thing label and call endpoint for propper debugging
402 String debugPrefix = this.getThing().getLabel() + Constants.COLON + finalEndpoint;
404 Request req = httpClient.newRequest(requestUrl).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
405 req.header(HttpHeader.AUTHORIZATION, "Bearer " + accountHandler.get().getToken());
407 ContentResponse cr = req.send();
408 logger.trace("{} Response {} {}", debugPrefix, cr.getStatus(), cr.getContentAsString());
409 if (cr.getStatus() == 200) {
410 distributeContent(cr.getContentAsString().trim());
412 } catch (InterruptedException | TimeoutException | ExecutionException e) {
413 logger.info("{} Error getting data {}", debugPrefix, e.getMessage());
414 fallbackCall(requestUrl);
419 * Fallback solution with Java11 classes
420 * Performs try with Java11 HttpClient - https://zetcode.com/java/getpostrequest/ to identify Community problem
421 * https://community.openhab.org/t/mercedes-me-binding/136852/21
425 private void fallbackCall(String requestUrl) {
426 // Calculate endpoint for debugging
427 String[] endpoint = requestUrl.split("/");
428 String finalEndpoint = endpoint[endpoint.length - 1];
429 // debug prefix contains Thing label and call endpoint for propper debugging
430 String debugPrefix = this.getThing().getLabel() + Constants.COLON + finalEndpoint;
432 java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient();
433 HttpRequest request = HttpRequest.newBuilder().uri(URI.create(requestUrl))
434 .header(HttpHeader.AUTHORIZATION.toString(), "Bearer " + accountHandler.get().getToken()).GET().build();
436 HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
437 logger.debug("{} Fallback Response {} {}", debugPrefix, response.statusCode(), response.body());
438 if (response.statusCode() == 200) {
439 distributeContent(response.body().trim());
441 } catch (IOException | InterruptedException e) {
442 logger.warn("{} Error getting data via fallback {}", debugPrefix, e.getMessage());
446 private void distributeContent(String json) {
447 if (json.startsWith("[") && json.endsWith("]")) {
448 JSONArray ja = new JSONArray(json);
449 for (Iterator<Object> iterator = ja.iterator(); iterator.hasNext();) {
450 JSONObject jo = (JSONObject) iterator.next();
451 ChannelStateMap csm = Mapper.getChannelStateMap(jo);
456 * handle some specific channels
458 // store ChannelMap for range radius calculation
459 String channel = csm.getChannel();
460 if ("range-electric".equals(channel)) {
461 rangeElectric = Optional.of((QuantityType<?>) csm.getState());
462 } else if ("range-fuel".equals(channel)) {
463 rangeFuel = Optional.of((QuantityType<?>) csm.getState());
464 } else if ("soc".equals(channel)) {
465 if (config.get().batteryCapacity > 0) {
466 float socValue = ((QuantityType<?>) csm.getState()).floatValue();
467 float batteryCapacity = config.get().batteryCapacity;
468 float chargedValue = Math.round(socValue * 1000 * batteryCapacity / 1000) / (float) 100;
469 ChannelStateMap charged = new ChannelStateMap("charged", GROUP_RANGE,
470 QuantityType.valueOf(chargedValue, Units.KILOWATT_HOUR), csm.getTimestamp());
471 updateChannel(charged);
472 float unchargedValue = Math.round((100 - socValue) * 1000 * batteryCapacity / 1000)
474 ChannelStateMap uncharged = new ChannelStateMap("uncharged", GROUP_RANGE,
475 QuantityType.valueOf(unchargedValue, Units.KILOWATT_HOUR), csm.getTimestamp());
476 updateChannel(uncharged);
478 logger.debug("No battery capacity given");
480 } else if ("fuel-level".equals(channel)) {
481 if (config.get().fuelCapacity > 0) {
482 float fuelLevelValue = ((QuantityType<?>) csm.getState()).floatValue();
483 float fuelCapacity = config.get().fuelCapacity;
484 float litersInTank = Math.round(fuelLevelValue * 1000 * fuelCapacity / 1000) / (float) 100;
485 ChannelStateMap tankFilled = new ChannelStateMap("tank-remain", GROUP_RANGE,
486 QuantityType.valueOf(litersInTank, Units.LITRE), csm.getTimestamp());
487 updateChannel(tankFilled);
488 float litersFree = Math.round((100 - fuelLevelValue) * 1000 * fuelCapacity / 1000)
490 ChannelStateMap tankOpen = new ChannelStateMap("tank-open", GROUP_RANGE,
491 QuantityType.valueOf(litersFree, Units.LITRE), csm.getTimestamp());
492 updateChannel(tankOpen);
494 logger.debug("No fuel capacity given");
498 logger.warn("Unable to deliver state for {}", jo);
502 logger.debug("JSON Array expected but received {}", json);
506 private void updateRadius() {
507 if (rangeElectric.isPresent()) {
508 // update electric radius
509 ChannelStateMap radiusElectric = new ChannelStateMap("radius-electric", GROUP_RANGE,
510 guessRangeRadius(rangeElectric.get()), 0);
511 updateChannel(radiusElectric);
512 if (rangeFuel.isPresent()) {
513 // update fuel & hybrid radius
514 ChannelStateMap radiusFuel = new ChannelStateMap("radius-fuel", GROUP_RANGE,
515 guessRangeRadius(rangeFuel.get()), 0);
516 updateChannel(radiusFuel);
517 int hybridKm = rangeElectric.get().intValue() + rangeFuel.get().intValue();
518 QuantityType<Length> hybridRangeState = QuantityType.valueOf(hybridKm, KILOMETRE_UNIT);
519 ChannelStateMap rangeHybrid = new ChannelStateMap("range-hybrid", GROUP_RANGE, hybridRangeState, 0);
520 updateChannel(rangeHybrid);
521 ChannelStateMap radiusHybrid = new ChannelStateMap("radius-hybrid", GROUP_RANGE,
522 guessRangeRadius(hybridRangeState), 0);
523 updateChannel(radiusHybrid);
525 } else if (rangeFuel.isPresent()) {
526 // update fuel & hybrid radius
527 ChannelStateMap radiusFuel = new ChannelStateMap("radius-fuel", GROUP_RANGE,
528 guessRangeRadius(rangeFuel.get()), 0);
529 updateChannel(radiusFuel);
534 * Easy function but there's some measures behind:
535 * Guessing the range of the Vehicle on Map. If you can drive x kilometers with your Vehicle it's not feasible to
536 * project this x km Radius on Map. The roads to be taken are causing some overhead because they are not a straight
537 * line from Location A to B.
538 * I've taken some measurements to calculate the overhead factor based on Google Maps
539 * Berlin - Dresden: Road Distance: 193 air-line Distance 167 = Factor 87%
540 * Kassel - Frankfurt: Road Distance: 199 air-line Distance 143 = Factor 72%
541 * After measuring more distances you'll find out that the outcome is between 70% and 90%. So
543 * This depends also on the roads of a concrete route but this is only a guess without any Route Navigation behind
546 * @return mapping from air-line distance to "real road" distance
548 public static State guessRangeRadius(QuantityType<?> s) {
549 double radius = s.intValue() * 0.8;
550 return QuantityType.valueOf(Math.round(radius), KILOMETRE_UNIT);
553 protected void updateChannel(ChannelStateMap csm) {
554 updateTime(csm.getGroup(), csm.getTimestamp());
555 updateState(new ChannelUID(thing.getUID(), csm.getGroup(), csm.getChannel()), csm.getState());
558 private void updateTime(String group, long timestamp) {
559 boolean updateTime = false;
560 Long l = timeHash.get(group);
562 if (l.longValue() < timestamp) {
569 timeHash.put(group, timestamp);
570 DateTimeType dtt = new DateTimeType(Instant.ofEpochMilli(timestamp).atZone(timeZoneProvider.getTimeZone()));
571 updateState(new ChannelUID(thing.getUID(), group, "last-update"), dtt);
576 public void updateStatus(ThingStatus ts, ThingStatusDetail tsd, @Nullable String details) {
577 online = ts.equals(ThingStatus.ONLINE);
578 super.updateStatus(ts, tsd, details);