]> git.basschouten.com Git - openhab-addons.git/blob
269aee15b01a027cda7f71e3ea744890f03632f4
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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 int REQUEST_TIMEOUT_MS = 10_000;
87     private static final String EXT_IMG_RES = "ExtImageResources_";
88     private static final String INITIALIZE_COMMAND = "Initialze";
89
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;
98
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;
107
108     public VehicleHandler(Thing thing, HttpClient hc, String uid, StorageService storageService,
109             MercedesMeCommandOptionProvider mmcop, MercedesMeStateOptionProvider mmsop, TimeZoneProvider tzp) {
110         super(thing);
111         httpClient = hc;
112         this.uid = uid;
113         this.mmcop = mmcop;
114         this.mmsop = mmsop;
115         timeZoneProvider = tzp;
116         this.storageService = storageService;
117         nextRefresh = Instant.now();
118     }
119
120     @Override
121     public void handleCommand(ChannelUID channelUID, Command command) {
122         logger.trace("Received {} {} {}", channelUID.getAsString(), command.toFullString(), channelUID.getId());
123         if (command instanceof RefreshType) {
124             /**
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
128              */
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);
133             }
134         } else if ("image-view".equals(channelUID.getIdWithoutGroup())) {
135             if (imageStorage.isPresent()) {
136                 if (INITIALIZE_COMMAND.equals(command.toFullString())) {
137                     getImageResources();
138                 }
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);
144                 } else {
145                     logger.trace("Request Image {} ", key);
146                     encodedImage = getImage(command.toFullString());
147                     if (!encodedImage.isEmpty()) {
148                         imageStorage.get().put(key, encodedImage);
149                     }
150                 }
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);
155                 } else {
156                     logger.debug("Image {} is empty", key);
157                 }
158             }
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)) {
163                     removals.add(entry);
164                 }
165             });
166             removals.forEach(entry -> {
167                 imageStorage.get().remove(entry);
168             });
169             updateState(new ChannelUID(thing.getUID(), GROUP_IMAGE, "clear-cache"), OnOffType.OFF);
170             getImageResources();
171         }
172     }
173
174     @Override
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)) {
187                         getImageResources();
188                     }
189                     setImageOtions();
190                 }
191                 updateState(new ChannelUID(thing.getUID(), GROUP_IMAGE, "clear-cache"), OnOffType.OFF);
192             } else {
193                 throw new IllegalStateException("BridgeHandler is null");
194             }
195         } else {
196             String textKey = Constants.STATUS_TEXT_PREFIX + "vehicle" + Constants.STATUS_BRIDGE_MISSING;
197             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, textKey);
198         }
199     }
200
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!
207         }, () -> {
208             refreshJob = Optional.of(scheduler.scheduleWithFixedDelay(this::getData, 0, interval, TimeUnit.MINUTES));
209         });
210     }
211
212     @Override
213     public void dispose() {
214         refreshJob.ifPresent(job -> job.cancel(true));
215     }
216
217     public void getData() {
218         if (accountHandler.isEmpty()) {
219             logger.warn("AccountHandler not set");
220             return;
221         }
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);
226             return;
227         } else if (!online) { // only update if thing isn't already ONLINE
228             updateStatus(ThingStatus.ONLINE);
229         }
230
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) {
235                 call(odoUrl);
236             } else {
237                 logger.trace("{} Odo scope not activated", this.getThing().getLabel());
238             }
239         } else {
240             logger.trace("{} Account not properly configured", this.getThing().getLabel());
241         }
242
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) {
248                     call(evUrl);
249                 } else {
250                     logger.trace("{} Electric Status scope not activated", this.getThing().getLabel());
251                 }
252             } else {
253                 logger.trace("{} Account not properly configured", this.getThing().getLabel());
254             }
255         }
256
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) {
262                     call(fuelUrl);
263                 } else {
264                     logger.trace("{} Fuel scope not activated", this.getThing().getLabel());
265                 }
266             } else {
267                 logger.trace("{} Account not properly configured", this.getThing().getLabel());
268             }
269         }
270
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) {
275                 call(statusUrl);
276             } else {
277                 logger.trace("{} Vehicle Status scope not activated", this.getThing().getLabel());
278             }
279         } else {
280             logger.trace("{} Account not properly configured", this.getThing().getLabel());
281         }
282         String lockUrl = String.format(LOCK_URL, config.get().vin);
283         if (accountConfigAvailable()) {
284             if (accountHandler.get().config.get().lockScope) {
285                 call(lockUrl);
286             } else {
287                 logger.trace("{} Lock scope not activated", this.getThing().getLabel());
288             }
289         } else {
290             logger.trace("{} Account not properly configured", this.getThing().getLabel());
291         }
292
293         // Range radius for all types
294         updateRadius();
295     }
296
297     private boolean accountConfigAvailable() {
298         if (accountHandler.isPresent()) {
299             if (accountHandler.get().config.isPresent()) {
300                 return true;
301             }
302         }
303         return false;
304     }
305
306     private void getImageResources() {
307         if (accountHandler.get().getImageApiKey().equals(NOT_SET)) {
308             logger.debug("Image API key not set");
309             return;
310         }
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");
324         try {
325             ContentResponse cr = req.send();
326             if (cr.getStatus() == 200) {
327                 imageStorage.get().put(EXT_IMG_RES + config.get().vin, cr.getContentAsString());
328                 setImageOtions();
329             } else {
330                 logger.debug("Failed to get image resources {} {}", cr.getStatus(), cr.getContentAsString());
331             }
332         } catch (InterruptedException | TimeoutException | ExecutionException e) {
333             logger.debug("Error getting image resources {}", e.getMessage());
334         }
335     }
336
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 -> {
343                 entries.add(entry);
344             });
345         }
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);
354         });
355         if (commandOptions.isEmpty()) {
356             commandOptions.add(new CommandOption("Initilaze", null));
357             stateOptions.add(new StateOption("Initilaze", null));
358         }
359         ChannelUID cuid = new ChannelUID(thing.getUID(), GROUP_IMAGE, "image-view");
360         mmcop.setCommandOptions(cuid, commandOptions);
361         mmsop.setStateOptions(cuid, stateOptions);
362     }
363
364     private String getImage(String key) {
365         if (accountHandler.get().getImageApiKey().equals(NOT_SET)) {
366             logger.debug("Image API key not set");
367             return EMPTY;
368         }
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);
373             if (jo.has(key)) {
374                 imageId = jo.getString(key);
375             }
376         } else {
377             getImageResources();
378             return EMPTY;
379         }
380
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, "*/*");
385         ContentResponse cr;
386         try {
387             cr = req.send();
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());
392         }
393         return EMPTY;
394     }
395
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;
403
404         Request req = httpClient.newRequest(requestUrl).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
405         req.header(HttpHeader.AUTHORIZATION, "Bearer " + accountHandler.get().getToken());
406         try {
407             ContentResponse cr = req.send();
408             logger.trace("{} Response {} {}", debugPrefix, cr.getStatus(), cr.getContentAsString());
409             if (cr.getStatus() == 200) {
410                 distributeContent(cr.getContentAsString().trim());
411             }
412         } catch (InterruptedException | TimeoutException | ExecutionException e) {
413             logger.info("{} Error getting data {}", debugPrefix, e.getMessage());
414             fallbackCall(requestUrl);
415         }
416     }
417
418     /**
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
422      *
423      * @param requestUrl
424      */
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;
431
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();
435         try {
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());
440             }
441         } catch (IOException | InterruptedException e) {
442             logger.warn("{} Error getting data via fallback {}", debugPrefix, e.getMessage());
443         }
444     }
445
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);
452                 if (csm.isValid()) {
453                     updateChannel(csm);
454
455                     /**
456                      * handle some specific channels
457                      */
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)
473                                     / (float) 100;
474                             ChannelStateMap uncharged = new ChannelStateMap("uncharged", GROUP_RANGE,
475                                     QuantityType.valueOf(unchargedValue, Units.KILOWATT_HOUR), csm.getTimestamp());
476                             updateChannel(uncharged);
477                         } else {
478                             logger.debug("No battery capacity given");
479                         }
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)
489                                     / (float) 100;
490                             ChannelStateMap tankOpen = new ChannelStateMap("tank-open", GROUP_RANGE,
491                                     QuantityType.valueOf(litersFree, Units.LITRE), csm.getTimestamp());
492                             updateChannel(tankOpen);
493                         } else {
494                             logger.debug("No fuel capacity given");
495                         }
496                     }
497                 } else {
498                     logger.warn("Unable to deliver state for {}", jo);
499                 }
500             }
501         } else {
502             logger.debug("JSON Array expected but received {}", json);
503         }
504     }
505
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);
524             }
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);
530         }
531     }
532
533     /**
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
542      *
543      * This depends also on the roads of a concrete route but this is only a guess without any Route Navigation behind
544      *
545      * @param s
546      * @return mapping from air-line distance to "real road" distance
547      */
548     public static State guessRangeRadius(QuantityType<?> s) {
549         double radius = s.intValue() * 0.8;
550         return QuantityType.valueOf(Math.round(radius), KILOMETRE_UNIT);
551     }
552
553     protected void updateChannel(ChannelStateMap csm) {
554         updateTime(csm.getGroup(), csm.getTimestamp());
555         updateState(new ChannelUID(thing.getUID(), csm.getGroup(), csm.getChannel()), csm.getState());
556     }
557
558     private void updateTime(String group, long timestamp) {
559         boolean updateTime = false;
560         Long l = timeHash.get(group);
561         if (l != null) {
562             if (l.longValue() < timestamp) {
563                 updateTime = true;
564             }
565         } else {
566             updateTime = true;
567         }
568         if (updateTime) {
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);
572         }
573     }
574
575     @Override
576     public void updateStatus(ThingStatus ts, ThingStatusDetail tsd, @Nullable String details) {
577         online = ts.equals(ThingStatus.ONLINE);
578         super.updateStatus(ts, tsd, details);
579     }
580 }