]> git.basschouten.com Git - openhab-addons.git/blob
c14427db9f1bfbaa5d5c4d16dd20b73624546d65
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.volvooncall.internal.handler;
14
15 import static org.openhab.binding.volvooncall.internal.VolvoOnCallBindingConstants.*;
16 import static org.openhab.core.library.unit.MetricPrefix.KILO;
17 import static org.openhab.core.library.unit.SIUnits.*;
18 import static org.openhab.core.library.unit.SmartHomeUnits.*;
19
20 import java.time.ZonedDateTime;
21 import java.util.Collection;
22 import java.util.Collections;
23 import java.util.HashMap;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.Stack;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29 import java.util.stream.Collectors;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.openhab.binding.volvooncall.internal.VolvoOnCallException;
34 import org.openhab.binding.volvooncall.internal.action.VolvoOnCallActions;
35 import org.openhab.binding.volvooncall.internal.api.ActionResultController;
36 import org.openhab.binding.volvooncall.internal.api.VocHttpApi;
37 import org.openhab.binding.volvooncall.internal.config.VehicleConfiguration;
38 import org.openhab.binding.volvooncall.internal.dto.Attributes;
39 import org.openhab.binding.volvooncall.internal.dto.DoorsStatus;
40 import org.openhab.binding.volvooncall.internal.dto.Heater;
41 import org.openhab.binding.volvooncall.internal.dto.HvBattery;
42 import org.openhab.binding.volvooncall.internal.dto.Position;
43 import org.openhab.binding.volvooncall.internal.dto.PostResponse;
44 import org.openhab.binding.volvooncall.internal.dto.Status;
45 import org.openhab.binding.volvooncall.internal.dto.Trip;
46 import org.openhab.binding.volvooncall.internal.dto.TripDetail;
47 import org.openhab.binding.volvooncall.internal.dto.Trips;
48 import org.openhab.binding.volvooncall.internal.dto.TyrePressure;
49 import org.openhab.binding.volvooncall.internal.dto.Vehicles;
50 import org.openhab.binding.volvooncall.internal.dto.WindowsStatus;
51 import org.openhab.binding.volvooncall.internal.wrapper.VehiclePositionWrapper;
52 import org.openhab.core.library.types.DateTimeType;
53 import org.openhab.core.library.types.DecimalType;
54 import org.openhab.core.library.types.OnOffType;
55 import org.openhab.core.library.types.QuantityType;
56 import org.openhab.core.library.types.StringType;
57 import org.openhab.core.thing.Bridge;
58 import org.openhab.core.thing.Channel;
59 import org.openhab.core.thing.ChannelUID;
60 import org.openhab.core.thing.Thing;
61 import org.openhab.core.thing.ThingStatus;
62 import org.openhab.core.thing.ThingStatusDetail;
63 import org.openhab.core.thing.ThingStatusInfo;
64 import org.openhab.core.thing.binding.BaseThingHandler;
65 import org.openhab.core.thing.binding.ThingHandler;
66 import org.openhab.core.thing.binding.ThingHandlerService;
67 import org.openhab.core.types.Command;
68 import org.openhab.core.types.RefreshType;
69 import org.openhab.core.types.State;
70 import org.openhab.core.types.UnDefType;
71 import org.slf4j.Logger;
72 import org.slf4j.LoggerFactory;
73
74 /**
75  * The {@link VehicleHandler} is responsible for handling commands, which are sent
76  * to one of the channels.
77  *
78  * @author GaĆ«l L'hopital - Initial contribution
79  */
80 @NonNullByDefault
81 public class VehicleHandler extends BaseThingHandler {
82     private final Logger logger = LoggerFactory.getLogger(VehicleHandler.class);
83     private final Map<String, String> activeOptions = new HashMap<>();
84     private @Nullable ScheduledFuture<?> refreshJob;
85     private final List<ScheduledFuture<?>> pendingActions = new Stack<>();
86
87     private Vehicles vehicle = new Vehicles();
88     private VehiclePositionWrapper vehiclePosition = new VehiclePositionWrapper(new Position());
89     private Status vehicleStatus = new Status();
90     private @NonNullByDefault({}) VehicleConfiguration configuration;
91     private @NonNullByDefault({}) VolvoOnCallBridgeHandler bridgeHandler;
92     private long lastTripId;
93
94     public VehicleHandler(Thing thing, VehicleStateDescriptionProvider stateDescriptionProvider) {
95         super(thing);
96     }
97
98     @Override
99     public void initialize() {
100         logger.trace("Initializing the Volvo On Call handler for {}", getThing().getUID());
101
102         Bridge bridge = getBridge();
103         initializeBridge(bridge == null ? null : bridge.getHandler(), bridge == null ? null : bridge.getStatus());
104     }
105
106     @Override
107     public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
108         logger.debug("bridgeStatusChanged {} for thing {}", bridgeStatusInfo, getThing().getUID());
109
110         Bridge bridge = getBridge();
111         initializeBridge(bridge == null ? null : bridge.getHandler(), bridgeStatusInfo.getStatus());
112     }
113
114     private void initializeBridge(@Nullable ThingHandler thingHandler, @Nullable ThingStatus bridgeStatus) {
115         logger.debug("initializeBridge {} for thing {}", bridgeStatus, getThing().getUID());
116
117         if (thingHandler != null && bridgeStatus != null) {
118             bridgeHandler = (VolvoOnCallBridgeHandler) thingHandler;
119             if (bridgeStatus == ThingStatus.ONLINE) {
120                 configuration = getConfigAs(VehicleConfiguration.class);
121                 VocHttpApi api = bridgeHandler.getApi();
122                 if (api != null) {
123                     try {
124                         vehicle = api.getURL("vehicles/" + configuration.vin, Vehicles.class);
125                         if (thing.getProperties().isEmpty()) {
126                             Map<String, String> properties = discoverAttributes(api);
127                             updateProperties(properties);
128                         }
129
130                         activeOptions.putAll(
131                                 thing.getProperties().entrySet().stream().filter(p -> "true".equals(p.getValue()))
132                                         .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)));
133
134                         if (thing.getProperties().containsKey(LAST_TRIP_ID)) {
135                             lastTripId = Long.parseLong(thing.getProperties().get(LAST_TRIP_ID));
136                         }
137
138                         updateStatus(ThingStatus.ONLINE);
139                         startAutomaticRefresh(configuration.refresh, api);
140                     } catch (VolvoOnCallException e) {
141                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, e.getMessage());
142                     }
143
144                 }
145             } else {
146                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
147             }
148         } else {
149             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
150         }
151     }
152
153     private Map<String, String> discoverAttributes(VocHttpApi service) throws VolvoOnCallException {
154         Attributes attributes = service.getURL(vehicle.attributesURL, Attributes.class);
155
156         Map<String, String> properties = new HashMap<>();
157         properties.put(CAR_LOCATOR, attributes.carLocatorSupported.toString());
158         properties.put(HONK_AND_OR_BLINK, Boolean.toString(attributes.honkAndBlinkSupported
159                 && attributes.honkAndBlinkVersionsSupported.contains(HONK_AND_OR_BLINK)));
160         properties.put(HONK_BLINK, Boolean.toString(
161                 attributes.honkAndBlinkSupported && attributes.honkAndBlinkVersionsSupported.contains(HONK_BLINK)));
162         properties.put(REMOTE_HEATER, attributes.remoteHeaterSupported.toString());
163         properties.put(UNLOCK, attributes.unlockSupported.toString());
164         properties.put(LOCK, attributes.lockSupported.toString());
165         properties.put(JOURNAL_LOG, Boolean.toString(attributes.journalLogSupported && attributes.journalLogEnabled));
166         properties.put(PRECLIMATIZATION, attributes.preclimatizationSupported.toString());
167         properties.put(ENGINE_START, attributes.engineStartSupported.toString());
168         properties.put(UNLOCK_TIME, attributes.unlockTimeFrame.toString());
169
170         return properties;
171     }
172
173     /**
174      * Start the job refreshing the vehicle data
175      *
176      * @param refresh : refresh frequency in minutes
177      * @param service
178      */
179     private void startAutomaticRefresh(int refresh, VocHttpApi service) {
180         ScheduledFuture<?> refreshJob = this.refreshJob;
181         if (refreshJob == null || refreshJob.isCancelled()) {
182             this.refreshJob = scheduler.scheduleWithFixedDelay(() -> queryApiAndUpdateChannels(service), 1, refresh,
183                     TimeUnit.MINUTES);
184         }
185     }
186
187     private void queryApiAndUpdateChannels(VocHttpApi service) {
188         try {
189             Status newVehicleStatus = service.getURL(vehicle.statusURL, Status.class);
190             vehiclePosition = new VehiclePositionWrapper(service.getURL(Position.class, configuration.vin));
191             // Update all channels from the updated data
192             getThing().getChannels().stream().map(Channel::getUID)
193                     .filter(channelUID -> isLinked(channelUID) && !LAST_TRIP_GROUP.equals(channelUID.getGroupId()))
194                     .forEach(channelUID -> {
195                         String groupID = channelUID.getGroupId();
196                         if (groupID != null) {
197                             State state = getValue(groupID, channelUID.getIdWithoutGroup(), newVehicleStatus,
198                                     vehiclePosition);
199                             updateState(channelUID, state);
200                         }
201                     });
202             if (newVehicleStatus.odometer != vehicleStatus.odometer) {
203                 triggerChannel(GROUP_OTHER + "#" + CAR_EVENT, EVENT_CAR_MOVED);
204                 // We will update trips only if car position has changed to save server queries
205                 updateTrips(service);
206             }
207             if (!vehicleStatus.getEngineRunning().equals(newVehicleStatus.getEngineRunning())
208                     && newVehicleStatus.getEngineRunning().get() == OnOffType.ON) {
209                 triggerChannel(GROUP_OTHER + "#" + CAR_EVENT, EVENT_CAR_STARTED);
210             }
211             vehicleStatus = newVehicleStatus;
212         } catch (VolvoOnCallException e) {
213             logger.warn("Exception occurred during execution: {}", e.getMessage(), e);
214             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
215             freeRefreshJob();
216             startAutomaticRefresh(configuration.refresh, service);
217         }
218     }
219
220     private void freeRefreshJob() {
221         ScheduledFuture<?> refreshJob = this.refreshJob;
222         if (refreshJob != null) {
223             refreshJob.cancel(true);
224             this.refreshJob = null;
225         }
226         pendingActions.stream().filter(f -> !f.isCancelled()).forEach(f -> f.cancel(true));
227     }
228
229     @Override
230     public void dispose() {
231         freeRefreshJob();
232         super.dispose();
233     }
234
235     private void updateTrips(VocHttpApi service) throws VolvoOnCallException {
236         // This seems to rewind 100 days by default, did not find any way to filter it
237         Trips carTrips = service.getURL(Trips.class, configuration.vin);
238         List<Trip> newTrips = carTrips.trips.stream().filter(trip -> trip.id >= lastTripId)
239                 .collect(Collectors.toList());
240         Collections.reverse(newTrips);
241
242         logger.debug("Trips discovered : {}", newTrips.size());
243
244         if (!newTrips.isEmpty()) {
245             Long newTripId = newTrips.get(newTrips.size() - 1).id;
246             if (newTripId > lastTripId) {
247                 updateProperty(LAST_TRIP_ID, newTripId.toString());
248                 triggerChannel(GROUP_OTHER + "#" + CAR_EVENT, EVENT_CAR_STOPPED);
249                 lastTripId = newTripId;
250             }
251
252             newTrips.stream().map(t -> t.tripDetails.get(0)).forEach(catchUpTrip -> {
253                 logger.debug("Trip found {}", catchUpTrip.getStartTime());
254                 getThing().getChannels().stream().map(Channel::getUID)
255                         .filter(channelUID -> isLinked(channelUID) && LAST_TRIP_GROUP.equals(channelUID.getGroupId()))
256                         .forEach(channelUID -> {
257                             State state = getTripValue(channelUID.getIdWithoutGroup(), catchUpTrip);
258                             updateState(channelUID, state);
259                         });
260             });
261         }
262     }
263
264     @Override
265     public void handleCommand(ChannelUID channelUID, Command command) {
266         String channelID = channelUID.getIdWithoutGroup();
267         if (command instanceof RefreshType) {
268             VocHttpApi api = bridgeHandler.getApi();
269             if (api != null) {
270                 queryApiAndUpdateChannels(api);
271             }
272         } else if (command instanceof OnOffType) {
273             OnOffType onOffCommand = (OnOffType) command;
274             if (ENGINE_START.equals(channelID) && onOffCommand == OnOffType.ON) {
275                 actionStart(5);
276             } else if (REMOTE_HEATER.equals(channelID) || PRECLIMATIZATION.equals(channelID)) {
277                 actionHeater(channelID, onOffCommand == OnOffType.ON);
278             } else if (CAR_LOCKED.equals(channelID)) {
279                 actionOpenClose((onOffCommand == OnOffType.ON) ? LOCK : UNLOCK, onOffCommand);
280             }
281         }
282     }
283
284     private State getTripValue(String channelId, TripDetail tripDetails) {
285         switch (channelId) {
286             case TRIP_CONSUMPTION:
287                 return tripDetails.getFuelConsumption()
288                         .map(value -> (State) new QuantityType<>(value.floatValue() / 100, LITRE))
289                         .orElse(UnDefType.UNDEF);
290             case TRIP_DISTANCE:
291                 return new QuantityType<>((double) tripDetails.distance / 1000, KILO(METRE));
292             case TRIP_START_TIME:
293                 return tripDetails.getStartTime();
294             case TRIP_END_TIME:
295                 return tripDetails.getEndTime();
296             case TRIP_DURATION:
297                 return new QuantityType<>(tripDetails.getDurationInMinutes(), MINUTE);
298             case TRIP_START_ODOMETER:
299                 return new QuantityType<>((double) tripDetails.startOdometer / 1000, KILO(METRE));
300             case TRIP_STOP_ODOMETER:
301                 return new QuantityType<>((double) tripDetails.endOdometer / 1000, KILO(METRE));
302             case TRIP_START_POSITION:
303                 return tripDetails.getStartPosition();
304             case TRIP_END_POSITION:
305                 return tripDetails.getEndPosition();
306         }
307         return UnDefType.NULL;
308     }
309
310     private State getDoorsValue(String channelId, DoorsStatus doors) {
311         switch (channelId) {
312             case TAILGATE:
313                 return doors.tailgateOpen;
314             case REAR_RIGHT:
315                 return doors.rearRightDoorOpen;
316             case REAR_LEFT:
317                 return doors.rearLeftDoorOpen;
318             case FRONT_RIGHT:
319                 return doors.frontRightDoorOpen;
320             case FRONT_LEFT:
321                 return doors.frontLeftDoorOpen;
322             case HOOD:
323                 return doors.hoodOpen;
324         }
325         return UnDefType.NULL;
326     }
327
328     private State getWindowsValue(String channelId, WindowsStatus windows) {
329         switch (channelId) {
330             case REAR_RIGHT_WND:
331                 return windows.rearRightWindowOpen;
332             case REAR_LEFT_WND:
333                 return windows.rearLeftWindowOpen;
334             case FRONT_RIGHT_WND:
335                 return windows.frontRightWindowOpen;
336             case FRONT_LEFT_WND:
337                 return windows.frontLeftWindowOpen;
338         }
339         return UnDefType.NULL;
340     }
341
342     private State getTyresValue(String channelId, TyrePressure tyrePressure) {
343         switch (channelId) {
344             case REAR_RIGHT_TYRE:
345                 return tyrePressure.rearRightTyrePressure;
346             case REAR_LEFT_TYRE:
347                 return tyrePressure.rearLeftTyrePressure;
348             case FRONT_RIGHT_TYRE:
349                 return tyrePressure.frontRightTyrePressure;
350             case FRONT_LEFT_TYRE:
351                 return tyrePressure.frontLeftTyrePressure;
352         }
353         return UnDefType.NULL;
354     }
355
356     private State getHeaterValue(String channelId, Heater heater) {
357         switch (channelId) {
358             case REMOTE_HEATER:
359             case PRECLIMATIZATION:
360                 return heater.getStatus();
361         }
362         return UnDefType.NULL;
363     }
364
365     private State getBatteryValue(String channelId, HvBattery hvBattery) {
366         switch (channelId) {
367             case BATTERY_LEVEL:
368                 return hvBattery.hvBatteryLevel != UNDEFINED ? new QuantityType<>(hvBattery.hvBatteryLevel, PERCENT)
369                         : UnDefType.UNDEF;
370             case BATTERY_DISTANCE_TO_EMPTY:
371                 return hvBattery.distanceToHVBatteryEmpty != UNDEFINED
372                         ? new QuantityType<>(hvBattery.distanceToHVBatteryEmpty, KILO(METRE))
373                         : UnDefType.UNDEF;
374             case CHARGE_STATUS:
375                 return hvBattery.hvBatteryChargeStatusDerived != null ? hvBattery.hvBatteryChargeStatusDerived
376                         : UnDefType.UNDEF;
377             case TIME_TO_BATTERY_FULLY_CHARGED:
378                 return hvBattery.timeToHVBatteryFullyCharged != UNDEFINED
379                         ? new QuantityType<>(hvBattery.timeToHVBatteryFullyCharged, MINUTE)
380                         : UnDefType.UNDEF;
381             case CHARGING_END:
382                 return hvBattery.timeToHVBatteryFullyCharged != UNDEFINED && hvBattery.timeToHVBatteryFullyCharged > 0
383                         ? new DateTimeType(ZonedDateTime.now().plusMinutes(hvBattery.timeToHVBatteryFullyCharged))
384                         : UnDefType.UNDEF;
385         }
386         return UnDefType.NULL;
387     }
388
389     private State getValue(String groupId, String channelId, Status status, VehiclePositionWrapper position) {
390         switch (channelId) {
391             case CAR_LOCKED:
392                 // Warning : carLocked is in the Doors group but is part of general status informations.
393                 // Did not change it to avoid breaking change for users
394                 return status.getCarLocked().map(State.class::cast).orElse(UnDefType.UNDEF);
395             case ENGINE_RUNNING:
396                 return status.getEngineRunning().map(State.class::cast).orElse(UnDefType.UNDEF);
397             case BRAKE_FLUID_LEVEL:
398                 return new StringType(status.brakeFluid);
399             case WASHER_FLUID_LEVEL:
400                 return new StringType(status.washerFluidLevel);
401             case AVERAGE_SPEED:
402                 return status.averageSpeed != UNDEFINED ? new QuantityType<>(status.averageSpeed, KILOMETRE_PER_HOUR)
403                         : UnDefType.UNDEF;
404             case SERVICE_WARNING:
405                 return new StringType(status.serviceWarningStatus);
406             case BULB_FAILURE:
407                 return status.aFailedBulb() ? OnOffType.ON : OnOffType.OFF;
408             case REMOTE_HEATER:
409             case PRECLIMATIZATION:
410                 return status.getHeater().map(heater -> getHeaterValue(channelId, heater)).orElse(UnDefType.NULL);
411         }
412         switch (groupId) {
413             case GROUP_TANK:
414                 return getTankValue(channelId, status);
415             case GROUP_ODOMETER:
416                 return getOdometerValue(channelId, status);
417             case GROUP_POSITION:
418                 return getPositionValue(channelId, position);
419             case GROUP_DOORS:
420                 return status.getDoors().map(doors -> getDoorsValue(channelId, doors)).orElse(UnDefType.NULL);
421             case GROUP_WINDOWS:
422                 return status.getWindows().map(windows -> getWindowsValue(channelId, windows)).orElse(UnDefType.NULL);
423             case GROUP_TYRES:
424                 return status.getTyrePressure().map(tyres -> getTyresValue(channelId, tyres)).orElse(UnDefType.NULL);
425             case GROUP_BATTERY:
426                 return status.getHvBattery().map(batteries -> getBatteryValue(channelId, batteries))
427                         .orElse(UnDefType.NULL);
428         }
429         return UnDefType.NULL;
430     }
431
432     private State getTankValue(String channelId, Status status) {
433         switch (channelId) {
434             case DISTANCE_TO_EMPTY:
435                 return status.distanceToEmpty != UNDEFINED ? new QuantityType<>(status.distanceToEmpty, KILO(METRE))
436                         : UnDefType.UNDEF;
437             case FUEL_AMOUNT:
438                 return status.fuelAmount != UNDEFINED ? new QuantityType<>(status.fuelAmount, LITRE) : UnDefType.UNDEF;
439             case FUEL_LEVEL:
440                 return status.fuelAmountLevel != UNDEFINED ? new QuantityType<>(status.fuelAmountLevel, PERCENT)
441                         : UnDefType.UNDEF;
442             case FUEL_CONSUMPTION:
443                 return status.averageFuelConsumption != UNDEFINED ? new DecimalType(status.averageFuelConsumption / 10)
444                         : UnDefType.UNDEF;
445             case FUEL_ALERT:
446                 return status.distanceToEmpty < 100 ? OnOffType.ON : OnOffType.OFF;
447         }
448         return UnDefType.UNDEF;
449     }
450
451     private State getOdometerValue(String channelId, Status status) {
452         switch (channelId) {
453             case ODOMETER:
454                 return status.odometer != UNDEFINED ? new QuantityType<>((double) status.odometer / 1000, KILO(METRE))
455                         : UnDefType.UNDEF;
456             case TRIPMETER1:
457                 return status.tripMeter1 != UNDEFINED
458                         ? new QuantityType<>((double) status.tripMeter1 / 1000, KILO(METRE))
459                         : UnDefType.UNDEF;
460             case TRIPMETER2:
461                 return status.tripMeter2 != UNDEFINED
462                         ? new QuantityType<>((double) status.tripMeter2 / 1000, KILO(METRE))
463                         : UnDefType.UNDEF;
464         }
465         return UnDefType.UNDEF;
466     }
467
468     private State getPositionValue(String channelId, VehiclePositionWrapper position) {
469         switch (channelId) {
470             case ACTUAL_LOCATION:
471                 return position.getPosition();
472             case CALCULATED_LOCATION:
473                 return position.isCalculated();
474             case HEADING:
475                 return position.isHeading();
476             case LOCATION_TIMESTAMP:
477                 return position.getTimestamp();
478         }
479         return UnDefType.UNDEF;
480     }
481
482     public void actionHonkBlink(Boolean honk, Boolean blink) {
483         StringBuilder url = new StringBuilder("vehicles/" + vehicle.vehicleId + "/honk_blink/");
484
485         if (honk && blink && activeOptions.containsKey(HONK_BLINK)) {
486             url.append("both");
487         } else if (honk && activeOptions.containsKey(HONK_AND_OR_BLINK)) {
488             url.append("horn");
489         } else if (blink && activeOptions.containsKey(HONK_AND_OR_BLINK)) {
490             url.append("lights");
491         } else {
492             logger.warn("The vehicle is not capable of this action");
493             return;
494         }
495
496         post(url.toString(), vehiclePosition.getPositionAsJSon());
497     }
498
499     private void post(String url, @Nullable String param) {
500         VocHttpApi api = bridgeHandler.getApi();
501         if (api != null) {
502             try {
503                 PostResponse postResponse = api.postURL(url.toString(), param);
504                 if (postResponse != null) {
505                     pendingActions
506                             .add(scheduler.schedule(new ActionResultController(api, postResponse, scheduler, this),
507                                     1000, TimeUnit.MILLISECONDS));
508                 }
509             } catch (VolvoOnCallException e) {
510                 logger.warn("Exception occurred during execution: {}", e.getMessage(), e);
511                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
512             }
513         }
514         ;
515         pendingActions.removeIf(ScheduledFuture::isDone);
516     }
517
518     public void actionOpenClose(String action, OnOffType controlState) {
519         if (activeOptions.containsKey(action)) {
520             if (!vehicleStatus.getCarLocked().isPresent() || vehicleStatus.getCarLocked().get() != controlState) {
521                 post(String.format("vehicles/%s/%s", configuration.vin, action), "{}");
522             } else {
523                 logger.info("The car {} is already {}ed", configuration.vin, action);
524             }
525         } else {
526             logger.warn("The car {} does not support remote {}ing", configuration.vin, action);
527         }
528     }
529
530     public void actionHeater(String action, Boolean start) {
531         if (activeOptions.containsKey(action)) {
532             String address = String.format("vehicles/%s/%s/%s", configuration.vin,
533                     action.contains(REMOTE_HEATER) ? "heater" : "preclimatization", start ? "start" : "stop");
534             post(address, start ? "{}" : null);
535         } else {
536             logger.warn("The car {} does not support {}", configuration.vin, action);
537         }
538     }
539
540     public void actionStart(Integer runtime) {
541         if (activeOptions.containsKey(ENGINE_START)) {
542             String address = String.format("vehicles/%s/engine/start", vehicle.vehicleId);
543             String json = "{\"runtime\":" + runtime.toString() + "}";
544
545             post(address, json);
546         } else {
547             logger.warn("The car {} does not support remote engine starting", vehicle.vehicleId);
548         }
549     }
550
551     @Override
552     public Collection<Class<? extends ThingHandlerService>> getServices() {
553         return Collections.singletonList(VolvoOnCallActions.class);
554     }
555 }