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.volvooncall.internal.handler;
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.Units.*;
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;
26 import java.util.Stack;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29 import java.util.stream.Collectors;
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.Status.FluidLevel;
46 import org.openhab.binding.volvooncall.internal.dto.Trip;
47 import org.openhab.binding.volvooncall.internal.dto.TripDetail;
48 import org.openhab.binding.volvooncall.internal.dto.Trips;
49 import org.openhab.binding.volvooncall.internal.dto.TyrePressure;
50 import org.openhab.binding.volvooncall.internal.dto.TyrePressure.PressureLevel;
51 import org.openhab.binding.volvooncall.internal.dto.Vehicles;
52 import org.openhab.binding.volvooncall.internal.dto.WindowsStatus;
53 import org.openhab.binding.volvooncall.internal.wrapper.VehiclePositionWrapper;
54 import org.openhab.core.library.types.DateTimeType;
55 import org.openhab.core.library.types.DecimalType;
56 import org.openhab.core.library.types.OnOffType;
57 import org.openhab.core.library.types.QuantityType;
58 import org.openhab.core.library.types.StringType;
59 import org.openhab.core.thing.Bridge;
60 import org.openhab.core.thing.Channel;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.Thing;
63 import org.openhab.core.thing.ThingStatus;
64 import org.openhab.core.thing.ThingStatusDetail;
65 import org.openhab.core.thing.ThingStatusInfo;
66 import org.openhab.core.thing.binding.BaseThingHandler;
67 import org.openhab.core.thing.binding.ThingHandler;
68 import org.openhab.core.thing.binding.ThingHandlerService;
69 import org.openhab.core.types.Command;
70 import org.openhab.core.types.RefreshType;
71 import org.openhab.core.types.State;
72 import org.openhab.core.types.UnDefType;
73 import org.slf4j.Logger;
74 import org.slf4j.LoggerFactory;
77 * The {@link VehicleHandler} is responsible for handling commands, which are sent
78 * to one of the channels.
80 * @author Gaƫl L'hopital - Initial contribution
83 public class VehicleHandler extends BaseThingHandler {
84 private final Logger logger = LoggerFactory.getLogger(VehicleHandler.class);
85 private final Map<String, String> activeOptions = new HashMap<>();
86 private @Nullable ScheduledFuture<?> refreshJob;
87 private final List<ScheduledFuture<?>> pendingActions = new Stack<>();
89 private Vehicles vehicle = new Vehicles();
90 private VehiclePositionWrapper vehiclePosition = new VehiclePositionWrapper(new Position());
91 private Status vehicleStatus = new Status();
92 private @NonNullByDefault({}) VehicleConfiguration configuration;
93 private @NonNullByDefault({}) VolvoOnCallBridgeHandler bridgeHandler;
94 private long lastTripId;
96 public VehicleHandler(Thing thing, VehicleStateDescriptionProvider stateDescriptionProvider) {
101 public void initialize() {
102 logger.trace("Initializing the Volvo On Call handler for {}", getThing().getUID());
104 Bridge bridge = getBridge();
105 initializeBridge(bridge == null ? null : bridge.getHandler(), bridge == null ? null : bridge.getStatus());
109 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
110 logger.debug("bridgeStatusChanged {} for thing {}", bridgeStatusInfo, getThing().getUID());
112 Bridge bridge = getBridge();
113 initializeBridge(bridge == null ? null : bridge.getHandler(), bridgeStatusInfo.getStatus());
116 private void initializeBridge(@Nullable ThingHandler thingHandler, @Nullable ThingStatus bridgeStatus) {
117 logger.debug("initializeBridge {} for thing {}", bridgeStatus, getThing().getUID());
119 if (thingHandler != null && bridgeStatus != null) {
120 bridgeHandler = (VolvoOnCallBridgeHandler) thingHandler;
121 if (bridgeStatus == ThingStatus.ONLINE) {
122 configuration = getConfigAs(VehicleConfiguration.class);
123 VocHttpApi api = bridgeHandler.getApi();
126 vehicle = api.getURL("vehicles/" + configuration.vin, Vehicles.class);
127 if (thing.getProperties().isEmpty()) {
128 Map<String, String> properties = discoverAttributes(api);
129 updateProperties(properties);
132 activeOptions.putAll(
133 thing.getProperties().entrySet().stream().filter(p -> "true".equals(p.getValue()))
134 .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)));
136 String lastTripIdString = thing.getProperties().get(LAST_TRIP_ID);
137 if (lastTripIdString != null) {
138 lastTripId = Long.parseLong(lastTripIdString);
141 updateStatus(ThingStatus.ONLINE);
142 startAutomaticRefresh(configuration.refresh, api);
143 } catch (VolvoOnCallException e) {
144 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, e.getMessage());
149 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
152 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
156 private Map<String, String> discoverAttributes(VocHttpApi service) throws VolvoOnCallException {
157 Attributes attributes = service.getURL(vehicle.attributesURL, Attributes.class);
159 Map<String, String> properties = new HashMap<>();
160 properties.put(CAR_LOCATOR, attributes.carLocatorSupported.toString());
161 properties.put(HONK_AND_OR_BLINK, Boolean.toString(attributes.honkAndBlinkSupported
162 && attributes.honkAndBlinkVersionsSupported.contains(HONK_AND_OR_BLINK)));
163 properties.put(HONK_BLINK, Boolean.toString(
164 attributes.honkAndBlinkSupported && attributes.honkAndBlinkVersionsSupported.contains(HONK_BLINK)));
165 properties.put(REMOTE_HEATER, attributes.remoteHeaterSupported.toString());
166 properties.put(UNLOCK, attributes.unlockSupported.toString());
167 properties.put(LOCK, attributes.lockSupported.toString());
168 properties.put(JOURNAL_LOG, Boolean.toString(attributes.journalLogSupported && attributes.journalLogEnabled));
169 properties.put(PRECLIMATIZATION, attributes.preclimatizationSupported.toString());
170 properties.put(ENGINE_START, attributes.engineStartSupported.toString());
171 properties.put(UNLOCK_TIME, attributes.unlockTimeFrame.toString());
177 * Start the job refreshing the vehicle data
179 * @param refresh : refresh frequency in minutes
182 private void startAutomaticRefresh(int refresh, VocHttpApi service) {
183 ScheduledFuture<?> refreshJob = this.refreshJob;
184 if (refreshJob == null || refreshJob.isCancelled()) {
185 this.refreshJob = scheduler.scheduleWithFixedDelay(() -> queryApiAndUpdateChannels(service), 1, refresh,
190 private void queryApiAndUpdateChannels(VocHttpApi service) {
192 Status newVehicleStatus = service.getURL(vehicle.statusURL, Status.class);
193 vehiclePosition = new VehiclePositionWrapper(service.getURL(Position.class, configuration.vin));
194 // Update all channels from the updated data
195 getThing().getChannels().stream().map(Channel::getUID)
196 .filter(channelUID -> isLinked(channelUID) && !LAST_TRIP_GROUP.equals(channelUID.getGroupId()))
197 .forEach(channelUID -> {
198 String groupID = channelUID.getGroupId();
199 if (groupID != null) {
200 State state = getValue(groupID, channelUID.getIdWithoutGroup(), newVehicleStatus,
202 updateState(channelUID, state);
205 if (newVehicleStatus.odometer != vehicleStatus.odometer) {
206 triggerChannel(GROUP_OTHER + "#" + CAR_EVENT, EVENT_CAR_MOVED);
207 // We will update trips only if car position has changed to save server queries
208 updateTrips(service);
210 if (!vehicleStatus.getEngineRunning().equals(newVehicleStatus.getEngineRunning())
211 && newVehicleStatus.getEngineRunning().get() == OnOffType.ON) {
212 triggerChannel(GROUP_OTHER + "#" + CAR_EVENT, EVENT_CAR_STARTED);
214 vehicleStatus = newVehicleStatus;
215 } catch (VolvoOnCallException e) {
216 logger.warn("Exception occurred during execution: {}", e.getMessage(), e);
217 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
219 startAutomaticRefresh(configuration.refresh, service);
223 private void freeRefreshJob() {
224 ScheduledFuture<?> refreshJob = this.refreshJob;
225 if (refreshJob != null) {
226 refreshJob.cancel(true);
227 this.refreshJob = null;
229 pendingActions.stream().filter(f -> !f.isCancelled()).forEach(f -> f.cancel(true));
233 public void dispose() {
238 private void updateTrips(VocHttpApi service) throws VolvoOnCallException {
239 // This seems to rewind 100 days by default, did not find any way to filter it
240 Trips carTrips = service.getURL(Trips.class, configuration.vin);
241 List<Trip> newTrips = carTrips.trips.stream().filter(trip -> trip.id >= lastTripId)
242 .collect(Collectors.toList());
243 Collections.reverse(newTrips);
245 logger.debug("Trips discovered : {}", newTrips.size());
247 if (!newTrips.isEmpty()) {
248 Long newTripId = newTrips.get(newTrips.size() - 1).id;
249 if (newTripId > lastTripId) {
250 updateProperty(LAST_TRIP_ID, newTripId.toString());
251 triggerChannel(GROUP_OTHER + "#" + CAR_EVENT, EVENT_CAR_STOPPED);
252 lastTripId = newTripId;
255 newTrips.stream().map(t -> t.tripDetails.get(0)).forEach(catchUpTrip -> {
256 logger.debug("Trip found {}", catchUpTrip.getStartTime());
257 getThing().getChannels().stream().map(Channel::getUID)
258 .filter(channelUID -> isLinked(channelUID) && LAST_TRIP_GROUP.equals(channelUID.getGroupId()))
259 .forEach(channelUID -> {
260 State state = getTripValue(channelUID.getIdWithoutGroup(), catchUpTrip);
261 updateState(channelUID, state);
268 public void handleCommand(ChannelUID channelUID, Command command) {
269 String channelID = channelUID.getIdWithoutGroup();
270 if (command instanceof RefreshType) {
271 VocHttpApi api = bridgeHandler.getApi();
273 queryApiAndUpdateChannels(api);
275 } else if (command instanceof OnOffType) {
276 OnOffType onOffCommand = (OnOffType) command;
277 if (ENGINE_START.equals(channelID) && onOffCommand == OnOffType.ON) {
279 } else if (REMOTE_HEATER.equals(channelID) || PRECLIMATIZATION.equals(channelID)) {
280 actionHeater(channelID, onOffCommand == OnOffType.ON);
281 } else if (CAR_LOCKED.equals(channelID)) {
282 actionOpenClose((onOffCommand == OnOffType.ON) ? LOCK : UNLOCK, onOffCommand);
287 private State getTripValue(String channelId, TripDetail tripDetails) {
289 case TRIP_CONSUMPTION:
290 return tripDetails.getFuelConsumption()
291 .map(value -> (State) new QuantityType<>(value.floatValue() / 100, LITRE))
292 .orElse(UnDefType.UNDEF);
294 return new QuantityType<>((double) tripDetails.distance / 1000, KILO(METRE));
295 case TRIP_START_TIME:
296 return tripDetails.getStartTime();
298 return tripDetails.getEndTime();
300 return tripDetails.getDurationInMinutes().map(value -> (State) new QuantityType<>(value, MINUTE))
301 .orElse(UnDefType.UNDEF);
302 case TRIP_START_ODOMETER:
303 return new QuantityType<>((double) tripDetails.startOdometer / 1000, KILO(METRE));
304 case TRIP_STOP_ODOMETER:
305 return new QuantityType<>((double) tripDetails.endOdometer / 1000, KILO(METRE));
306 case TRIP_START_POSITION:
307 return tripDetails.getStartPosition();
308 case TRIP_END_POSITION:
309 return tripDetails.getEndPosition();
311 return UnDefType.NULL;
314 private State getDoorsValue(String channelId, DoorsStatus doors) {
317 return doors.tailgateOpen;
319 return doors.rearRightDoorOpen;
321 return doors.rearLeftDoorOpen;
323 return doors.frontRightDoorOpen;
325 return doors.frontLeftDoorOpen;
327 return doors.hoodOpen;
329 return UnDefType.NULL;
332 private State getWindowsValue(String channelId, WindowsStatus windows) {
335 return windows.rearRightWindowOpen;
337 return windows.rearLeftWindowOpen;
338 case FRONT_RIGHT_WND:
339 return windows.frontRightWindowOpen;
341 return windows.frontLeftWindowOpen;
343 return UnDefType.NULL;
346 private State pressureLevelToState(PressureLevel level) {
347 return level != PressureLevel.UNKNOWN ? new DecimalType(level.ordinal()) : UnDefType.UNDEF;
350 private State getTyresValue(String channelId, TyrePressure tyrePressure) {
352 case REAR_RIGHT_TYRE:
353 return pressureLevelToState(tyrePressure.rearRightTyrePressure);
355 return pressureLevelToState(tyrePressure.rearLeftTyrePressure);
356 case FRONT_RIGHT_TYRE:
357 return pressureLevelToState(tyrePressure.frontRightTyrePressure);
358 case FRONT_LEFT_TYRE:
359 return pressureLevelToState(tyrePressure.frontLeftTyrePressure);
361 return UnDefType.NULL;
364 private State getHeaterValue(String channelId, Heater heater) {
367 case PRECLIMATIZATION:
368 return heater.getStatus();
370 return UnDefType.NULL;
373 private State getBatteryValue(String channelId, HvBattery hvBattery) {
377 * If the car is charging the battery level can be reported as 100% by the API regardless of actual
378 * charge level, but isn't always. So, if we see that the car is Charging, ChargingPaused, or
379 * ChargingInterrupted and the reported battery level is 100%, then instead produce UNDEF.
381 * If we see FullyCharged, then we can rely on the value being 100% anyway.
383 if (hvBattery.hvBatteryChargeStatusDerived != null
384 && hvBattery.hvBatteryChargeStatusDerived.toString().startsWith("CablePluggedInCar_Charging")
385 && hvBattery.hvBatteryLevel != UNDEFINED && hvBattery.hvBatteryLevel == 100) {
386 return UnDefType.UNDEF;
388 return hvBattery.hvBatteryLevel != UNDEFINED ? new QuantityType<>(hvBattery.hvBatteryLevel, PERCENT)
391 case BATTERY_LEVEL_RAW:
392 return hvBattery.hvBatteryLevel != UNDEFINED ? new QuantityType<>(hvBattery.hvBatteryLevel, PERCENT)
394 case BATTERY_DISTANCE_TO_EMPTY:
395 return hvBattery.distanceToHVBatteryEmpty != UNDEFINED
396 ? new QuantityType<>(hvBattery.distanceToHVBatteryEmpty, KILO(METRE))
399 return hvBattery.hvBatteryChargeStatusDerived != null ? hvBattery.hvBatteryChargeStatusDerived
401 case CHARGE_STATUS_CABLE:
402 return hvBattery.hvBatteryChargeStatusDerived != null
404 hvBattery.hvBatteryChargeStatusDerived.toString().startsWith("CablePluggedInCar_"))
406 case CHARGE_STATUS_CHARGING:
407 return hvBattery.hvBatteryChargeStatusDerived != null
408 ? OnOffType.from(hvBattery.hvBatteryChargeStatusDerived.toString().endsWith("_Charging"))
410 case CHARGE_STATUS_FULLY_CHARGED:
412 * If the car is charging the battery level can be reported incorrectly by the API, so use the charging
413 * status instead of checking the level when the car is plugged in.
415 if (hvBattery.hvBatteryChargeStatusDerived != null
416 && hvBattery.hvBatteryChargeStatusDerived.toString().startsWith("CablePluggedInCar_")) {
417 return OnOffType.from(hvBattery.hvBatteryChargeStatusDerived.toString().endsWith("_FullyCharged"));
419 return hvBattery.hvBatteryLevel != UNDEFINED ? OnOffType.from(hvBattery.hvBatteryLevel == 100)
422 case TIME_TO_BATTERY_FULLY_CHARGED:
423 return hvBattery.timeToHVBatteryFullyCharged != UNDEFINED
424 ? new QuantityType<>(hvBattery.timeToHVBatteryFullyCharged, MINUTE)
427 return hvBattery.timeToHVBatteryFullyCharged != UNDEFINED && hvBattery.timeToHVBatteryFullyCharged > 0
428 ? new DateTimeType(ZonedDateTime.now().plusMinutes(hvBattery.timeToHVBatteryFullyCharged))
431 return UnDefType.NULL;
434 private State getValue(String groupId, String channelId, Status status, VehiclePositionWrapper position) {
437 // Warning : carLocked is in the Doors group but is part of general status informations.
438 // Did not change it to avoid breaking change for users
439 return status.getCarLocked().map(State.class::cast).orElse(UnDefType.UNDEF);
441 return status.getEngineRunning().map(State.class::cast).orElse(UnDefType.UNDEF);
442 case BRAKE_FLUID_LEVEL:
443 return fluidLevelToState(status.brakeFluidLevel);
444 case WASHER_FLUID_LEVEL:
445 return fluidLevelToState(status.washerFluidLevel);
447 return status.averageSpeed != UNDEFINED ? new QuantityType<>(status.averageSpeed, KILOMETRE_PER_HOUR)
449 case SERVICE_WARNING:
450 return new StringType(status.serviceWarningStatus);
452 return status.aFailedBulb() ? OnOffType.ON : OnOffType.OFF;
454 case PRECLIMATIZATION:
455 return status.getHeater().map(heater -> getHeaterValue(channelId, heater)).orElse(UnDefType.NULL);
459 return getTankValue(channelId, status);
461 return getOdometerValue(channelId, status);
463 return getPositionValue(channelId, position);
465 return status.getDoors().map(doors -> getDoorsValue(channelId, doors)).orElse(UnDefType.NULL);
467 return status.getWindows().map(windows -> getWindowsValue(channelId, windows)).orElse(UnDefType.NULL);
469 return status.getTyrePressure().map(tyres -> getTyresValue(channelId, tyres)).orElse(UnDefType.NULL);
471 return status.getHvBattery().map(batteries -> getBatteryValue(channelId, batteries))
472 .orElse(UnDefType.NULL);
474 return UnDefType.NULL;
477 private State fluidLevelToState(FluidLevel level) {
478 return level != FluidLevel.UNKNOWN ? new DecimalType(level.ordinal()) : UnDefType.UNDEF;
481 private State getTankValue(String channelId, Status status) {
483 case DISTANCE_TO_EMPTY:
484 return status.distanceToEmpty != UNDEFINED ? new QuantityType<>(status.distanceToEmpty, KILO(METRE))
487 return status.fuelAmount != UNDEFINED ? new QuantityType<>(status.fuelAmount, LITRE) : UnDefType.UNDEF;
489 return status.fuelAmountLevel != UNDEFINED ? new QuantityType<>(status.fuelAmountLevel, PERCENT)
491 case FUEL_CONSUMPTION:
492 return status.averageFuelConsumption != UNDEFINED ? new DecimalType(status.averageFuelConsumption / 10)
495 return status.distanceToEmpty < 100 ? OnOffType.ON : OnOffType.OFF;
497 return UnDefType.UNDEF;
500 private State getOdometerValue(String channelId, Status status) {
503 return status.odometer != UNDEFINED ? new QuantityType<>((double) status.odometer / 1000, KILO(METRE))
506 return status.tripMeter1 != UNDEFINED
507 ? new QuantityType<>((double) status.tripMeter1 / 1000, KILO(METRE))
510 return status.tripMeter2 != UNDEFINED
511 ? new QuantityType<>((double) status.tripMeter2 / 1000, KILO(METRE))
514 return UnDefType.UNDEF;
517 private State getPositionValue(String channelId, VehiclePositionWrapper position) {
519 case ACTUAL_LOCATION:
520 return position.getPosition();
521 case CALCULATED_LOCATION:
522 return position.isCalculated();
524 return position.isHeading();
525 case LOCATION_TIMESTAMP:
526 return position.getTimestamp();
528 return UnDefType.UNDEF;
531 public void actionHonkBlink(Boolean honk, Boolean blink) {
532 StringBuilder url = new StringBuilder("vehicles/" + vehicle.vehicleId + "/honk_blink/");
534 if (honk && blink && activeOptions.containsKey(HONK_BLINK)) {
536 } else if (honk && activeOptions.containsKey(HONK_AND_OR_BLINK)) {
538 } else if (blink && activeOptions.containsKey(HONK_AND_OR_BLINK)) {
539 url.append("lights");
541 logger.warn("The vehicle is not capable of this action");
545 post(url.toString(), vehiclePosition.getPositionAsJSon());
548 private void post(String url, @Nullable String param) {
549 VocHttpApi api = bridgeHandler.getApi();
552 PostResponse postResponse = api.postURL(url.toString(), param);
553 if (postResponse != null) {
555 .add(scheduler.schedule(new ActionResultController(api, postResponse, scheduler, this),
556 1000, TimeUnit.MILLISECONDS));
558 } catch (VolvoOnCallException e) {
559 logger.warn("Exception occurred during execution: {}", e.getMessage(), e);
560 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
564 pendingActions.removeIf(ScheduledFuture::isDone);
567 public void actionOpenClose(String action, OnOffType controlState) {
568 if (activeOptions.containsKey(action)) {
569 if (!vehicleStatus.getCarLocked().isPresent() || vehicleStatus.getCarLocked().get() != controlState) {
570 post(String.format("vehicles/%s/%s", configuration.vin, action), "{}");
572 logger.info("The car {} is already {}ed", configuration.vin, action);
575 logger.warn("The car {} does not support remote {}ing", configuration.vin, action);
579 public void actionHeater(String action, Boolean start) {
580 if (activeOptions.containsKey(action)) {
581 String address = String.format("vehicles/%s/%s/%s", configuration.vin,
582 action.contains(REMOTE_HEATER) ? "heater" : "preclimatization", start ? "start" : "stop");
583 post(address, start ? "{}" : null);
585 logger.warn("The car {} does not support {}", configuration.vin, action);
589 public void actionStart(Integer runtime) {
590 if (activeOptions.containsKey(ENGINE_START)) {
591 String address = String.format("vehicles/%s/engine/start", vehicle.vehicleId);
592 String json = "{\"runtime\":" + runtime.toString() + "}";
596 logger.warn("The car {} does not support remote engine starting", vehicle.vehicleId);
601 public Collection<Class<? extends ThingHandlerService>> getServices() {
602 return Collections.singletonList(VolvoOnCallActions.class);