]> git.basschouten.com Git - openhab-addons.git/blob
03e0fa8c307483b9bc6e1edd24ff00b5770d6db3
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.mercedesme.internal.handler;
14
15 import static org.openhab.binding.mercedesme.internal.Constants.*;
16
17 import java.io.IOException;
18 import java.net.URI;
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;
30 import java.util.Map;
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;
36
37 import javax.measure.quantity.Length;
38
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;
77
78 /**
79  * The {@link VehicleHandler} is responsible for handling commands, which are
80  * sent to one of the channels.
81  *
82  * @author Bernd Weymann - Initial contribution
83  */
84 @NonNullByDefault
85 public class VehicleHandler extends BaseThingHandler {
86     private static final String EXT_IMG_RES = "ExtImageResources_";
87     private static final String INITIALIZE_COMMAND = "Initialze";
88
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;
97
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;
106
107     public VehicleHandler(Thing thing, HttpClient hc, String uid, StorageService storageService,
108             MercedesMeCommandOptionProvider mmcop, MercedesMeStateOptionProvider mmsop, TimeZoneProvider tzp) {
109         super(thing);
110         httpClient = hc;
111         this.uid = uid;
112         this.mmcop = mmcop;
113         this.mmsop = mmsop;
114         timeZoneProvider = tzp;
115         this.storageService = storageService;
116         nextRefresh = Instant.now();
117     }
118
119     @Override
120     public void handleCommand(ChannelUID channelUID, Command command) {
121         logger.trace("Received {} {} {}", channelUID.getAsString(), command.toFullString(), channelUID.getId());
122         if (command instanceof RefreshType) {
123             /**
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
127              */
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);
132             }
133         } else if ("image-view".equals(channelUID.getIdWithoutGroup())) {
134             if (imageStorage.isPresent()) {
135                 if (INITIALIZE_COMMAND.equals(command.toFullString())) {
136                     getImageResources();
137                 }
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);
143                 } else {
144                     logger.trace("Request Image {} ", key);
145                     encodedImage = getImage(command.toFullString());
146                     if (!encodedImage.isEmpty()) {
147                         imageStorage.get().put(key, encodedImage);
148                     }
149                 }
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);
154                 } else {
155                     logger.debug("Image {} is empty", key);
156                 }
157             }
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)) {
162                     removals.add(entry);
163                 }
164             });
165             removals.forEach(entry -> {
166                 imageStorage.get().remove(entry);
167             });
168             updateState(new ChannelUID(thing.getUID(), GROUP_IMAGE, "clear-cache"), OnOffType.OFF);
169             getImageResources();
170         }
171     }
172
173     @Override
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)) {
186                         getImageResources();
187                     }
188                     setImageOtions();
189                 }
190                 updateState(new ChannelUID(thing.getUID(), GROUP_IMAGE, "clear-cache"), OnOffType.OFF);
191             } else {
192                 throw new IllegalStateException("BridgeHandler is null");
193             }
194         } else {
195             String textKey = Constants.STATUS_TEXT_PREFIX + "vehicle" + Constants.STATUS_BRIDGE_MISSING;
196             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, textKey);
197         }
198     }
199
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!
206         }, () -> {
207             refreshJob = Optional.of(scheduler.scheduleWithFixedDelay(this::getData, 0, interval, TimeUnit.MINUTES));
208         });
209     }
210
211     @Override
212     public void dispose() {
213         refreshJob.ifPresent(job -> job.cancel(true));
214     }
215
216     public void getData() {
217         if (accountHandler.isEmpty()) {
218             logger.warn("AccountHandler not set");
219             return;
220         }
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);
225             return;
226         } else if (!online) { // only update if thing isn't already ONLINE
227             updateStatus(ThingStatus.ONLINE);
228         }
229
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) {
234                 call(odoUrl);
235             } else {
236                 logger.trace("{} Odo scope not activated", this.getThing().getLabel());
237             }
238         } else {
239             logger.trace("{} Account not properly configured", this.getThing().getLabel());
240         }
241
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) {
247                     call(evUrl);
248                 } else {
249                     logger.trace("{} Electric Status scope not activated", this.getThing().getLabel());
250                 }
251             } else {
252                 logger.trace("{} Account not properly configured", this.getThing().getLabel());
253             }
254         }
255
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) {
261                     call(fuelUrl);
262                 } else {
263                     logger.trace("{} Fuel scope not activated", this.getThing().getLabel());
264                 }
265             } else {
266                 logger.trace("{} Account not properly configured", this.getThing().getLabel());
267             }
268         }
269
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) {
274                 call(statusUrl);
275             } else {
276                 logger.trace("{} Vehicle Status scope not activated", this.getThing().getLabel());
277             }
278         } else {
279             logger.trace("{} Account not properly configured", this.getThing().getLabel());
280         }
281         String lockUrl = String.format(LOCK_URL, config.get().vin);
282         if (accountConfigAvailable()) {
283             if (accountHandler.get().config.get().lockScope) {
284                 call(lockUrl);
285             } else {
286                 logger.trace("{} Lock scope not activated", this.getThing().getLabel());
287             }
288         } else {
289             logger.trace("{} Account not properly configured", this.getThing().getLabel());
290         }
291
292         // Range radius for all types
293         updateRadius();
294     }
295
296     private boolean accountConfigAvailable() {
297         if (accountHandler.isPresent()) {
298             if (accountHandler.get().config.isPresent()) {
299                 return true;
300             }
301         }
302         return false;
303     }
304
305     private void getImageResources() {
306         if (accountHandler.get().getImageApiKey().equals(NOT_SET)) {
307             logger.debug("Image API key not set");
308             return;
309         }
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");
323         try {
324             ContentResponse cr = req.send();
325             if (cr.getStatus() == 200) {
326                 imageStorage.get().put(EXT_IMG_RES + config.get().vin, cr.getContentAsString());
327                 setImageOtions();
328             } else {
329                 logger.debug("Failed to get image resources {} {}", cr.getStatus(), cr.getContentAsString());
330             }
331         } catch (InterruptedException | TimeoutException | ExecutionException e) {
332             logger.debug("Error getting image resources {}", e.getMessage());
333         }
334     }
335
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 -> {
342                 entries.add(entry);
343             });
344         }
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);
353         });
354         if (commandOptions.isEmpty()) {
355             commandOptions.add(new CommandOption("Initilaze", null));
356             stateOptions.add(new StateOption("Initilaze", null));
357         }
358         ChannelUID cuid = new ChannelUID(thing.getUID(), GROUP_IMAGE, "image-view");
359         mmcop.setCommandOptions(cuid, commandOptions);
360         mmsop.setStateOptions(cuid, stateOptions);
361     }
362
363     private String getImage(String key) {
364         if (accountHandler.get().getImageApiKey().equals(NOT_SET)) {
365             logger.debug("Image API key not set");
366             return EMPTY;
367         }
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);
372             if (jo.has(key)) {
373                 imageId = jo.getString(key);
374             }
375         } else {
376             getImageResources();
377             return EMPTY;
378         }
379
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, "*/*");
384         ContentResponse cr;
385         try {
386             cr = req.send();
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());
391         }
392         return EMPTY;
393     }
394
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;
402
403         Request req = httpClient.newRequest(requestUrl);
404         req.header(HttpHeader.AUTHORIZATION, "Bearer " + accountHandler.get().getToken());
405         try {
406             ContentResponse cr = req.send();
407             logger.trace("{} Response {} {}", debugPrefix, cr.getStatus(), cr.getContentAsString());
408             if (cr.getStatus() == 200) {
409                 distributeContent(cr.getContentAsString().trim());
410             }
411         } catch (InterruptedException | TimeoutException | ExecutionException e) {
412             logger.info("{} Error getting data {}", debugPrefix, e.getMessage());
413             fallbackCall(requestUrl);
414         }
415     }
416
417     /**
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
421      *
422      * @param requestUrl
423      */
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;
430
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();
434         try {
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());
439             }
440         } catch (IOException | InterruptedException e) {
441             logger.warn("{} Error getting data via fallback {}", debugPrefix, e.getMessage());
442         }
443     }
444
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);
451                 if (csm.isValid()) {
452                     updateChannel(csm);
453
454                     /**
455                      * handle some specific channels
456                      */
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)
472                                     / (float) 100;
473                             ChannelStateMap uncharged = new ChannelStateMap("uncharged", GROUP_RANGE,
474                                     QuantityType.valueOf(unchargedValue, Units.KILOWATT_HOUR), csm.getTimestamp());
475                             updateChannel(uncharged);
476                         } else {
477                             logger.debug("No battery capacity given");
478                         }
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)
488                                     / (float) 100;
489                             ChannelStateMap tankOpen = new ChannelStateMap("tank-open", GROUP_RANGE,
490                                     QuantityType.valueOf(litersFree, Units.LITRE), csm.getTimestamp());
491                             updateChannel(tankOpen);
492                         } else {
493                             logger.debug("No fuel capacity given");
494                         }
495                     }
496                 } else {
497                     logger.warn("Unable to deliver state for {}", jo);
498                 }
499             }
500         } else {
501             logger.debug("JSON Array expected but received {}", json);
502         }
503     }
504
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);
523             }
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);
529         }
530     }
531
532     /**
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
541      *
542      * This depends also on the roads of a concrete route but this is only a guess without any Route Navigation behind
543      *
544      * @param range
545      * @return mapping from air-line distance to "real road" distance
546      */
547     public static State guessRangeRadius(QuantityType<?> s) {
548         double radius = s.intValue() * 0.8;
549         return QuantityType.valueOf(Math.round(radius), KILOMETRE_UNIT);
550     }
551
552     protected void updateChannel(ChannelStateMap csm) {
553         updateTime(csm.getGroup(), csm.getTimestamp());
554         updateState(new ChannelUID(thing.getUID(), csm.getGroup(), csm.getChannel()), csm.getState());
555     }
556
557     private void updateTime(String group, long timestamp) {
558         boolean updateTime = false;
559         Long l = timeHash.get(group);
560         if (l != null) {
561             if (l.longValue() < timestamp) {
562                 updateTime = true;
563             }
564         } else {
565             updateTime = true;
566         }
567         if (updateTime) {
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);
571         }
572     }
573
574     @Override
575     public void updateStatus(ThingStatus ts, ThingStatusDetail tsd, @Nullable String details) {
576         online = ts.equals(ThingStatus.ONLINE);
577         super.updateStatus(ts, tsd, details);
578     }
579 }