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 String EXT_IMG_RES = "ExtImageResources_";
87 private static final String INITIALIZE_COMMAND = "Initialze";
89 private final Logger logger = LoggerFactory.getLogger(VehicleHandler.class);
90 private final Map<String, Long> timeHash = new HashMap<String, Long>();
91 private final MercedesMeCommandOptionProvider mmcop;
92 private final MercedesMeStateOptionProvider mmsop;
93 private final TimeZoneProvider timeZoneProvider;
94 private final StorageService storageService;
95 private final HttpClient httpClient;
96 private final String uid;
98 private Optional<ScheduledFuture<?>> refreshJob = Optional.empty();
99 private Optional<AccountHandler> accountHandler = Optional.empty();
100 private Optional<QuantityType<?>> rangeElectric = Optional.empty();
101 private Optional<Storage<String>> imageStorage = Optional.empty();
102 private Optional<VehicleConfiguration> config = Optional.empty();
103 private Optional<QuantityType<?>> rangeFuel = Optional.empty();
104 private Instant nextRefresh;
105 private boolean online = false;
107 public VehicleHandler(Thing thing, HttpClient hc, String uid, StorageService storageService,
108 MercedesMeCommandOptionProvider mmcop, MercedesMeStateOptionProvider mmsop, TimeZoneProvider tzp) {
114 timeZoneProvider = tzp;
115 this.storageService = storageService;
116 nextRefresh = Instant.now();
120 public void handleCommand(ChannelUID channelUID, Command command) {
121 logger.trace("Received {} {} {}", channelUID.getAsString(), command.toFullString(), channelUID.getId());
122 if (command instanceof RefreshType) {
124 * Refresh requested e.g. after adding new item
125 * Adding several items will frequently raise RefreshType command. Calling API each time shall be avoided
126 * API update is performed after 5 seconds for all items which should be sufficient for a frequent update
128 if (Instant.now().isAfter(nextRefresh)) {
129 nextRefresh = Instant.now().plus(Duration.ofSeconds(5));
130 logger.trace("Refresh granted - next at {}", nextRefresh);
131 scheduler.schedule(this::getData, 5, TimeUnit.SECONDS);
133 } else if ("image-view".equals(channelUID.getIdWithoutGroup())) {
134 if (imageStorage.isPresent()) {
135 if (INITIALIZE_COMMAND.equals(command.toFullString())) {
138 String key = command.toFullString() + "_" + config.get().vin;
139 String encodedImage = EMPTY;
140 if (imageStorage.get().containsKey(key)) {
141 encodedImage = imageStorage.get().get(key);
142 logger.trace("Image {} found in storage", key);
144 logger.trace("Request Image {} ", key);
145 encodedImage = getImage(command.toFullString());
146 if (!encodedImage.isEmpty()) {
147 imageStorage.get().put(key, encodedImage);
150 if (encodedImage != null && !encodedImage.isEmpty()) {
151 RawType image = new RawType(Base64.getDecoder().decode(encodedImage),
152 MIME_PREFIX + config.get().format);
153 updateState(new ChannelUID(thing.getUID(), GROUP_IMAGE, "image-data"), image);
155 logger.debug("Image {} is empty", key);
158 } else if ("clear-cache".equals(channelUID.getIdWithoutGroup()) && command.equals(OnOffType.ON)) {
159 List<String> removals = new ArrayList<String>();
160 imageStorage.get().getKeys().forEach(entry -> {
161 if (entry.contains("_" + config.get().vin)) {
165 removals.forEach(entry -> {
166 imageStorage.get().remove(entry);
168 updateState(new ChannelUID(thing.getUID(), GROUP_IMAGE, "clear-cache"), OnOffType.OFF);
174 public void initialize() {
175 config = Optional.of(getConfigAs(VehicleConfiguration.class));
176 Bridge bridge = getBridge();
177 if (bridge != null) {
178 updateStatus(ThingStatus.UNKNOWN);
179 BridgeHandler handler = bridge.getHandler();
180 if (handler != null) {
181 accountHandler = Optional.of((AccountHandler) handler);
182 startSchedule(config.get().refreshInterval);
183 if (!config.get().vin.equals(NOT_SET)) {
184 imageStorage = Optional.of(storageService.getStorage(BINDING_ID + "_" + config.get().vin));
185 if (!imageStorage.get().containsKey(EXT_IMG_RES + config.get().vin)) {
190 updateState(new ChannelUID(thing.getUID(), GROUP_IMAGE, "clear-cache"), OnOffType.OFF);
192 throw new IllegalStateException("BridgeHandler is null");
195 String textKey = Constants.STATUS_TEXT_PREFIX + "vehicle" + Constants.STATUS_BRIDGE_MISSING;
196 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, textKey);
200 private void startSchedule(int interval) {
201 refreshJob.ifPresentOrElse(job -> {
202 if (job.isCancelled()) {
203 refreshJob = Optional
204 .of(scheduler.scheduleWithFixedDelay(this::getData, 0, interval, TimeUnit.MINUTES));
205 } // else - scheduler is already running!
207 refreshJob = Optional.of(scheduler.scheduleWithFixedDelay(this::getData, 0, interval, TimeUnit.MINUTES));
212 public void dispose() {
213 refreshJob.ifPresent(job -> job.cancel(true));
216 public void getData() {
217 if (accountHandler.isEmpty()) {
218 logger.warn("AccountHandler not set");
221 String token = accountHandler.get().getToken();
222 if (token.isEmpty()) {
223 String textKey = Constants.STATUS_TEXT_PREFIX + "vehicle" + Constants.STATUS_BRIDGE_ATHORIZATION;
224 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, textKey);
226 } else if (!online) { // only update if thing isn't already ONLINE
227 updateStatus(ThingStatus.ONLINE);
230 // Mileage for all cars
231 String odoUrl = String.format(ODO_URL, config.get().vin);
232 if (accountConfigAvailable()) {
233 if (accountHandler.get().config.get().odoScope) {
236 logger.trace("{} Odo scope not activated", this.getThing().getLabel());
239 logger.trace("{} Account not properly configured", this.getThing().getLabel());
242 // Electric status for hybrid and electric
243 if (uid.equals(BEV) || uid.equals(HYBRID)) {
244 String evUrl = String.format(EV_URL, config.get().vin);
245 if (accountConfigAvailable()) {
246 if (accountHandler.get().config.get().evScope) {
249 logger.trace("{} Electric Status scope not activated", this.getThing().getLabel());
252 logger.trace("{} Account not properly configured", this.getThing().getLabel());
256 // Fuel for hybrid and combustion
257 if (uid.equals(COMBUSTION) || uid.equals(HYBRID)) {
258 String fuelUrl = String.format(FUEL_URL, config.get().vin);
259 if (accountConfigAvailable()) {
260 if (accountHandler.get().config.get().fuelScope) {
263 logger.trace("{} Fuel scope not activated", this.getThing().getLabel());
266 logger.trace("{} Account not properly configured", this.getThing().getLabel());
270 // Status and Lock for all
271 String statusUrl = String.format(STATUS_URL, config.get().vin);
272 if (accountConfigAvailable()) {
273 if (accountHandler.get().config.get().vehicleScope) {
276 logger.trace("{} Vehicle Status scope not activated", this.getThing().getLabel());
279 logger.trace("{} Account not properly configured", this.getThing().getLabel());
281 String lockUrl = String.format(LOCK_URL, config.get().vin);
282 if (accountConfigAvailable()) {
283 if (accountHandler.get().config.get().lockScope) {
286 logger.trace("{} Lock scope not activated", this.getThing().getLabel());
289 logger.trace("{} Account not properly configured", this.getThing().getLabel());
292 // Range radius for all types
296 private boolean accountConfigAvailable() {
297 if (accountHandler.isPresent()) {
298 if (accountHandler.get().config.isPresent()) {
305 private void getImageResources() {
306 if (accountHandler.get().getImageApiKey().equals(NOT_SET)) {
307 logger.debug("Image API key not set");
310 // add config parameters
311 MultiMap<String> parameterMap = new MultiMap<String>();
312 parameterMap.add("background", Boolean.toString(config.get().background));
313 parameterMap.add("night", Boolean.toString(config.get().night));
314 parameterMap.add("cropped", Boolean.toString(config.get().cropped));
315 parameterMap.add("roofOpen", Boolean.toString(config.get().roofOpen));
316 parameterMap.add("fileFormat", config.get().format);
317 String params = UrlEncoded.encode(parameterMap, StandardCharsets.UTF_8, false);
318 String url = String.format(IMAGE_EXTERIOR_RESOURCE_URL, config.get().vin) + "?" + params;
319 logger.debug("Get Image resources {} {} ", accountHandler.get().getImageApiKey(), url);
320 Request req = httpClient.newRequest(url);
321 req.header("x-api-key", accountHandler.get().getImageApiKey());
322 req.header(HttpHeader.ACCEPT, "application/json");
324 ContentResponse cr = req.send();
325 if (cr.getStatus() == 200) {
326 imageStorage.get().put(EXT_IMG_RES + config.get().vin, cr.getContentAsString());
329 logger.debug("Failed to get image resources {} {}", cr.getStatus(), cr.getContentAsString());
331 } catch (InterruptedException | TimeoutException | ExecutionException e) {
332 logger.debug("Error getting image resources {}", e.getMessage());
336 private void setImageOtions() {
337 List<String> entries = new ArrayList<String>();
338 if (imageStorage.get().containsKey(EXT_IMG_RES + config.get().vin)) {
339 String resources = imageStorage.get().get(EXT_IMG_RES + config.get().vin);
340 JSONObject jo = new JSONObject(resources);
341 jo.keySet().forEach(entry -> {
345 Collections.sort(entries);
346 List<CommandOption> commandOptions = new ArrayList<CommandOption>();
347 List<StateOption> stateOptions = new ArrayList<StateOption>();
348 entries.forEach(entry -> {
349 CommandOption co = new CommandOption(entry, null);
350 commandOptions.add(co);
351 StateOption so = new StateOption(entry, null);
352 stateOptions.add(so);
354 if (commandOptions.isEmpty()) {
355 commandOptions.add(new CommandOption("Initilaze", null));
356 stateOptions.add(new StateOption("Initilaze", null));
358 ChannelUID cuid = new ChannelUID(thing.getUID(), GROUP_IMAGE, "image-view");
359 mmcop.setCommandOptions(cuid, commandOptions);
360 mmsop.setStateOptions(cuid, stateOptions);
363 private String getImage(String key) {
364 if (accountHandler.get().getImageApiKey().equals(NOT_SET)) {
365 logger.debug("Image API key not set");
368 String imageId = EMPTY;
369 if (imageStorage.get().containsKey(EXT_IMG_RES + config.get().vin)) {
370 String resources = imageStorage.get().get(EXT_IMG_RES + config.get().vin);
371 JSONObject jo = new JSONObject(resources);
373 imageId = jo.getString(key);
380 String url = IMAGE_BASE_URL + "/images/" + imageId;
381 Request req = httpClient.newRequest(url);
382 req.header("x-api-key", accountHandler.get().getImageApiKey());
383 req.header(HttpHeader.ACCEPT, "*/*");
387 byte[] response = cr.getContent();
388 return Base64.getEncoder().encodeToString(response);
389 } catch (InterruptedException | TimeoutException | ExecutionException e) {
390 logger.warn("Get Image {} error {}", url, e.getMessage());
395 private void call(String url) {
396 String requestUrl = String.format(url, config.get().vin);
397 // Calculate endpoint for debugging
398 String[] endpoint = requestUrl.split("/");
399 String finalEndpoint = endpoint[endpoint.length - 1];
400 // debug prefix contains Thing label and call endpoint for propper debugging
401 String debugPrefix = this.getThing().getLabel() + Constants.COLON + finalEndpoint;
403 Request req = httpClient.newRequest(requestUrl);
404 req.header(HttpHeader.AUTHORIZATION, "Bearer " + accountHandler.get().getToken());
406 ContentResponse cr = req.send();
407 logger.trace("{} Response {} {}", debugPrefix, cr.getStatus(), cr.getContentAsString());
408 if (cr.getStatus() == 200) {
409 distributeContent(cr.getContentAsString().trim());
411 } catch (InterruptedException | TimeoutException | ExecutionException e) {
412 logger.info("{} Error getting data {}", debugPrefix, e.getMessage());
413 fallbackCall(requestUrl);
418 * Fallback solution with Java11 classes
419 * Performs try with Java11 HttpClient - https://zetcode.com/java/getpostrequest/ to identify Community problem
420 * https://community.openhab.org/t/mercedes-me-binding/136852/21
424 private void fallbackCall(String requestUrl) {
425 // Calculate endpoint for debugging
426 String[] endpoint = requestUrl.split("/");
427 String finalEndpoint = endpoint[endpoint.length - 1];
428 // debug prefix contains Thing label and call endpoint for propper debugging
429 String debugPrefix = this.getThing().getLabel() + Constants.COLON + finalEndpoint;
431 java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient();
432 HttpRequest request = HttpRequest.newBuilder().uri(URI.create(requestUrl))
433 .header(HttpHeader.AUTHORIZATION.toString(), "Bearer " + accountHandler.get().getToken()).GET().build();
435 HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
436 logger.debug("{} Fallback Response {} {}", debugPrefix, response.statusCode(), response.body());
437 if (response.statusCode() == 200) {
438 distributeContent(response.body().trim());
440 } catch (IOException | InterruptedException e) {
441 logger.warn("{} Error getting data via fallback {}", debugPrefix, e.getMessage());
445 private void distributeContent(String json) {
446 if (json.startsWith("[") && json.endsWith("]")) {
447 JSONArray ja = new JSONArray(json);
448 for (Iterator<Object> iterator = ja.iterator(); iterator.hasNext();) {
449 JSONObject jo = (JSONObject) iterator.next();
450 ChannelStateMap csm = Mapper.getChannelStateMap(jo);
455 * handle some specific channels
457 // store ChannelMap for range radius calculation
458 String channel = csm.getChannel();
459 if ("range-electric".equals(channel)) {
460 rangeElectric = Optional.of((QuantityType<?>) csm.getState());
461 } else if ("range-fuel".equals(channel)) {
462 rangeFuel = Optional.of((QuantityType<?>) csm.getState());
463 } else if ("soc".equals(channel)) {
464 if (config.get().batteryCapacity > 0) {
465 float socValue = ((QuantityType<?>) csm.getState()).floatValue();
466 float batteryCapacity = config.get().batteryCapacity;
467 float chargedValue = Math.round(socValue * 1000 * batteryCapacity / 1000) / (float) 100;
468 ChannelStateMap charged = new ChannelStateMap("charged", GROUP_RANGE,
469 QuantityType.valueOf(chargedValue, Units.KILOWATT_HOUR), csm.getTimestamp());
470 updateChannel(charged);
471 float unchargedValue = Math.round((100 - socValue) * 1000 * batteryCapacity / 1000)
473 ChannelStateMap uncharged = new ChannelStateMap("uncharged", GROUP_RANGE,
474 QuantityType.valueOf(unchargedValue, Units.KILOWATT_HOUR), csm.getTimestamp());
475 updateChannel(uncharged);
477 logger.debug("No battery capacity given");
479 } else if ("fuel-level".equals(channel)) {
480 if (config.get().fuelCapacity > 0) {
481 float fuelLevelValue = ((QuantityType<?>) csm.getState()).floatValue();
482 float fuelCapacity = config.get().fuelCapacity;
483 float litersInTank = Math.round(fuelLevelValue * 1000 * fuelCapacity / 1000) / (float) 100;
484 ChannelStateMap tankFilled = new ChannelStateMap("tank-remain", GROUP_RANGE,
485 QuantityType.valueOf(litersInTank, Units.LITRE), csm.getTimestamp());
486 updateChannel(tankFilled);
487 float litersFree = Math.round((100 - fuelLevelValue) * 1000 * fuelCapacity / 1000)
489 ChannelStateMap tankOpen = new ChannelStateMap("tank-open", GROUP_RANGE,
490 QuantityType.valueOf(litersFree, Units.LITRE), csm.getTimestamp());
491 updateChannel(tankOpen);
493 logger.debug("No fuel capacity given");
497 logger.warn("Unable to deliver state for {}", jo);
501 logger.debug("JSON Array expected but received {}", json);
505 private void updateRadius() {
506 if (rangeElectric.isPresent()) {
507 // update electric radius
508 ChannelStateMap radiusElectric = new ChannelStateMap("radius-electric", GROUP_RANGE,
509 guessRangeRadius(rangeElectric.get()), 0);
510 updateChannel(radiusElectric);
511 if (rangeFuel.isPresent()) {
512 // update fuel & hybrid radius
513 ChannelStateMap radiusFuel = new ChannelStateMap("radius-fuel", GROUP_RANGE,
514 guessRangeRadius(rangeFuel.get()), 0);
515 updateChannel(radiusFuel);
516 int hybridKm = rangeElectric.get().intValue() + rangeFuel.get().intValue();
517 QuantityType<Length> hybridRangeState = QuantityType.valueOf(hybridKm, KILOMETRE_UNIT);
518 ChannelStateMap rangeHybrid = new ChannelStateMap("range-hybrid", GROUP_RANGE, hybridRangeState, 0);
519 updateChannel(rangeHybrid);
520 ChannelStateMap radiusHybrid = new ChannelStateMap("radius-hybrid", GROUP_RANGE,
521 guessRangeRadius(hybridRangeState), 0);
522 updateChannel(radiusHybrid);
524 } else if (rangeFuel.isPresent()) {
525 // update fuel & hybrid radius
526 ChannelStateMap radiusFuel = new ChannelStateMap("radius-fuel", GROUP_RANGE,
527 guessRangeRadius(rangeFuel.get()), 0);
528 updateChannel(radiusFuel);
533 * Easy function but there's some measures behind:
534 * Guessing the range of the Vehicle on Map. If you can drive x kilometers with your Vehicle it's not feasible to
535 * project this x km Radius on Map. The roads to be taken are causing some overhead because they are not a straight
536 * line from Location A to B.
537 * I've taken some measurements to calculate the overhead factor based on Google Maps
538 * Berlin - Dresden: Road Distance: 193 air-line Distance 167 = Factor 87%
539 * Kassel - Frankfurt: Road Distance: 199 air-line Distance 143 = Factor 72%
540 * After measuring more distances you'll find out that the outcome is between 70% and 90%. So
542 * This depends also on the roads of a concrete route but this is only a guess without any Route Navigation behind
545 * @return mapping from air-line distance to "real road" distance
547 public static State guessRangeRadius(QuantityType<?> s) {
548 double radius = s.intValue() * 0.8;
549 return QuantityType.valueOf(Math.round(radius), KILOMETRE_UNIT);
552 protected void updateChannel(ChannelStateMap csm) {
553 updateTime(csm.getGroup(), csm.getTimestamp());
554 updateState(new ChannelUID(thing.getUID(), csm.getGroup(), csm.getChannel()), csm.getState());
557 private void updateTime(String group, long timestamp) {
558 boolean updateTime = false;
559 Long l = timeHash.get(group);
561 if (l.longValue() < timestamp) {
568 timeHash.put(group, timestamp);
569 DateTimeType dtt = new DateTimeType(Instant.ofEpochMilli(timestamp).atZone(timeZoneProvider.getTimeZone()));
570 updateState(new ChannelUID(thing.getUID(), group, "last-update"), dtt);
575 public void updateStatus(ThingStatus ts, ThingStatusDetail tsd, @Nullable String details) {
576 online = ts.equals(ThingStatus.ONLINE);
577 super.updateStatus(ts, tsd, details);