2 * Copyright (c) 2010-2020 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.SmartHomeUnits.*;
20 import java.io.IOException;
21 import java.time.ZonedDateTime;
22 import java.util.Collection;
23 import java.util.Collections;
24 import java.util.HashMap;
25 import java.util.List;
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.config.VehicleConfiguration;
36 import org.openhab.binding.volvooncall.internal.dto.Attributes;
37 import org.openhab.binding.volvooncall.internal.dto.DoorsStatus;
38 import org.openhab.binding.volvooncall.internal.dto.Heater;
39 import org.openhab.binding.volvooncall.internal.dto.HvBattery;
40 import org.openhab.binding.volvooncall.internal.dto.Position;
41 import org.openhab.binding.volvooncall.internal.dto.Status;
42 import org.openhab.binding.volvooncall.internal.dto.Trip;
43 import org.openhab.binding.volvooncall.internal.dto.TripDetail;
44 import org.openhab.binding.volvooncall.internal.dto.Trips;
45 import org.openhab.binding.volvooncall.internal.dto.TyrePressure;
46 import org.openhab.binding.volvooncall.internal.dto.Vehicles;
47 import org.openhab.binding.volvooncall.internal.dto.WindowsStatus;
48 import org.openhab.binding.volvooncall.internal.wrapper.VehiclePositionWrapper;
49 import org.openhab.core.library.types.DateTimeType;
50 import org.openhab.core.library.types.DecimalType;
51 import org.openhab.core.library.types.OnOffType;
52 import org.openhab.core.library.types.QuantityType;
53 import org.openhab.core.library.types.StringType;
54 import org.openhab.core.thing.Bridge;
55 import org.openhab.core.thing.Channel;
56 import org.openhab.core.thing.ChannelUID;
57 import org.openhab.core.thing.Thing;
58 import org.openhab.core.thing.ThingStatus;
59 import org.openhab.core.thing.ThingStatusDetail;
60 import org.openhab.core.thing.binding.BaseThingHandler;
61 import org.openhab.core.thing.binding.BridgeHandler;
62 import org.openhab.core.thing.binding.ThingHandlerService;
63 import org.openhab.core.types.Command;
64 import org.openhab.core.types.RefreshType;
65 import org.openhab.core.types.State;
66 import org.openhab.core.types.UnDefType;
67 import org.slf4j.Logger;
68 import org.slf4j.LoggerFactory;
70 import com.google.gson.JsonSyntaxException;
73 * The {@link VehicleHandler} is responsible for handling commands, which are sent
74 * to one of the channels.
76 * @author Gaƫl L'hopital - Initial contribution
79 public class VehicleHandler extends BaseThingHandler {
80 private final Logger logger = LoggerFactory.getLogger(VehicleHandler.class);
81 private final Map<String, String> activeOptions = new HashMap<>();
82 private @Nullable ScheduledFuture<?> refreshJob;
84 private Vehicles vehicle = new Vehicles();
85 private VehiclePositionWrapper vehiclePosition = new VehiclePositionWrapper(new Position());
86 private Status vehicleStatus = new Status();
87 private @NonNullByDefault({}) VehicleConfiguration configuration;
88 private Integer lastTripId = 0;
90 public VehicleHandler(Thing thing, VehicleStateDescriptionProvider stateDescriptionProvider) {
94 private Map<String, String> discoverAttributes(VolvoOnCallBridgeHandler bridgeHandler)
95 throws JsonSyntaxException, IOException, VolvoOnCallException {
96 Attributes attributes = bridgeHandler.getURL(vehicle.attributesURL, Attributes.class);
98 Map<String, String> properties = new HashMap<>();
99 properties.put(CAR_LOCATOR, attributes.carLocatorSupported.toString());
100 properties.put(HONK_AND_OR_BLINK, Boolean.toString(attributes.honkAndBlinkSupported
101 && attributes.honkAndBlinkVersionsSupported.contains(HONK_AND_OR_BLINK)));
102 properties.put(HONK_BLINK, Boolean.toString(
103 attributes.honkAndBlinkSupported && attributes.honkAndBlinkVersionsSupported.contains(HONK_BLINK)));
104 properties.put(REMOTE_HEATER, attributes.remoteHeaterSupported.toString());
105 properties.put(UNLOCK, attributes.unlockSupported.toString());
106 properties.put(LOCK, attributes.lockSupported.toString());
107 properties.put(JOURNAL_LOG, Boolean.toString(attributes.journalLogSupported && attributes.journalLogEnabled));
108 properties.put(PRECLIMATIZATION, attributes.preclimatizationSupported.toString());
109 properties.put(ENGINE_START, attributes.engineStartSupported.toString());
110 properties.put(UNLOCK_TIME, attributes.unlockTimeFrame.toString());
116 public void initialize() {
117 logger.trace("Initializing the Volvo On Call handler for {}", getThing().getUID());
119 VolvoOnCallBridgeHandler bridgeHandler = getBridgeHandler();
120 if (bridgeHandler != null) {
121 configuration = getConfigAs(VehicleConfiguration.class);
123 vehicle = bridgeHandler.getURL(SERVICE_URL + "vehicles/" + configuration.vin, Vehicles.class);
125 if (thing.getProperties().isEmpty()) {
126 Map<String, String> properties = discoverAttributes(bridgeHandler);
127 updateProperties(properties);
130 activeOptions.putAll(thing.getProperties().entrySet().stream().filter(p -> "true".equals(p.getValue()))
131 .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)));
133 if (thing.getProperties().containsKey(LAST_TRIP_ID)) {
134 lastTripId = Integer.parseInt(thing.getProperties().get(LAST_TRIP_ID));
137 updateStatus(ThingStatus.ONLINE);
138 startAutomaticRefresh(configuration.refresh);
139 } catch (JsonSyntaxException | IOException | VolvoOnCallException e) {
140 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, e.getMessage());
146 * Start the job refreshing the vehicle data
148 * @param refresh : refresh frequency in minutes
150 private void startAutomaticRefresh(int refresh) {
151 ScheduledFuture<?> refreshJob = this.refreshJob;
152 if (refreshJob == null || refreshJob.isCancelled()) {
153 refreshJob = scheduler.scheduleWithFixedDelay(this::queryApiAndUpdateChannels, 10, refresh,
158 private void queryApiAndUpdateChannels() {
159 VolvoOnCallBridgeHandler bridgeHandler = getBridgeHandler();
160 if (bridgeHandler != null) {
162 vehicleStatus = bridgeHandler.getURL(vehicle.statusURL, Status.class);
163 vehiclePosition = new VehiclePositionWrapper(bridgeHandler.getURL(Position.class, configuration.vin));
164 // Update all channels from the updated data
165 getThing().getChannels().stream().map(Channel::getUID)
166 .filter(channelUID -> isLinked(channelUID) && !LAST_TRIP_GROUP.equals(channelUID.getGroupId()))
167 .forEach(channelUID -> {
168 State state = getValue(channelUID.getGroupId(), channelUID.getIdWithoutGroup(),
169 vehicleStatus, vehiclePosition);
171 updateState(channelUID, state);
173 updateTrips(bridgeHandler);
174 } catch (VolvoOnCallException e) {
175 logger.warn("Exception occurred during execution: {}", e.getMessage(), e);
176 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
178 startAutomaticRefresh(configuration.refresh);
183 private void freeRefreshJob() {
184 ScheduledFuture<?> refreshJob = this.refreshJob;
185 if (refreshJob != null) {
186 refreshJob.cancel(true);
187 this.refreshJob = null;
192 public void dispose() {
197 private void updateTrips(VolvoOnCallBridgeHandler bridgeHandler) throws VolvoOnCallException {
198 // This seems to rewind 100 days by default, did not find any way to filter it
199 Trips carTrips = bridgeHandler.getURL(Trips.class, configuration.vin);
200 List<Trip> tripList = carTrips.trips;
202 if (tripList != null) {
203 List<Trip> newTrips = tripList.stream().filter(trip -> trip.id >= lastTripId).collect(Collectors.toList());
204 Collections.reverse(newTrips);
206 logger.debug("Trips discovered : {}", newTrips.size());
208 if (!newTrips.isEmpty()) {
209 Integer newTripId = newTrips.get(newTrips.size() - 1).id;
210 if (newTripId > lastTripId) {
211 updateProperty(LAST_TRIP_ID, newTripId.toString());
212 lastTripId = newTripId;
215 newTrips.stream().map(t -> t.tripDetails.get(0)).forEach(catchUpTrip -> {
216 logger.debug("Trip found {}", catchUpTrip.getStartTime());
217 getThing().getChannels().stream().map(Channel::getUID).filter(
218 channelUID -> isLinked(channelUID) && LAST_TRIP_GROUP.equals(channelUID.getGroupId()))
219 .forEach(channelUID -> {
220 State state = getTripValue(channelUID.getIdWithoutGroup(), catchUpTrip);
221 updateState(channelUID, state);
229 public void handleCommand(ChannelUID channelUID, Command command) {
230 String channelID = channelUID.getIdWithoutGroup();
231 if (command instanceof RefreshType) {
232 queryApiAndUpdateChannels();
233 } else if (command instanceof OnOffType) {
234 OnOffType onOffCommand = (OnOffType) command;
235 if (ENGINE_START.equals(channelID) && onOffCommand == OnOffType.ON) {
237 } else if (REMOTE_HEATER.equals(channelID)) {
238 actionHeater(onOffCommand == OnOffType.ON);
239 } else if (PRECLIMATIZATION.equals(channelID)) {
240 actionPreclimatization(onOffCommand == OnOffType.ON);
241 } else if (CAR_LOCKED.equals(channelID)) {
242 if (onOffCommand == OnOffType.ON) {
251 private State getTripValue(String channelId, TripDetail tripDetails) {
253 case TRIP_CONSUMPTION:
254 return tripDetails.getFuelConsumption()
255 .map(value -> (State) new QuantityType<>(value.floatValue() / 100, LITRE))
256 .orElse(UnDefType.UNDEF);
258 return new QuantityType<>((double) tripDetails.distance / 1000, KILO(METRE));
259 case TRIP_START_TIME:
260 return tripDetails.getStartTime();
262 return tripDetails.getEndTime();
264 return new QuantityType<>(tripDetails.getDurationInMinutes(), MINUTE);
265 case TRIP_START_ODOMETER:
266 return new QuantityType<>((double) tripDetails.startOdometer / 1000, KILO(METRE));
267 case TRIP_STOP_ODOMETER:
268 return new QuantityType<>((double) tripDetails.endOdometer / 1000, KILO(METRE));
269 case TRIP_START_POSITION:
270 return tripDetails.getStartPosition();
271 case TRIP_END_POSITION:
272 return tripDetails.getEndPosition();
275 return UnDefType.NULL;
278 private State getDoorsValue(String channelId, DoorsStatus doors) {
281 return doors.tailgateOpen;
283 return doors.rearRightDoorOpen;
285 return doors.rearLeftDoorOpen;
287 return doors.frontRightDoorOpen;
289 return doors.frontLeftDoorOpen;
291 return doors.hoodOpen;
293 return UnDefType.NULL;
296 private State getWindowsValue(String channelId, WindowsStatus windows) {
299 return windows.rearRightWindowOpen;
301 return windows.rearLeftWindowOpen;
302 case FRONT_RIGHT_WND:
303 return windows.frontRightWindowOpen;
305 return windows.frontLeftWindowOpen;
307 return UnDefType.NULL;
310 private State getTyresValue(String channelId, TyrePressure tyrePressure) {
312 case REAR_RIGHT_TYRE:
313 return new StringType(tyrePressure.rearRightTyrePressure);
315 return new StringType(tyrePressure.rearLeftTyrePressure);
316 case FRONT_RIGHT_TYRE:
317 return new StringType(tyrePressure.frontRightTyrePressure);
318 case FRONT_LEFT_TYRE:
319 return new StringType(tyrePressure.frontLeftTyrePressure);
321 return UnDefType.NULL;
324 private State getHeaterValue(String channelId, Heater heater) {
327 case PRECLIMATIZATION:
328 return heater.getStatus();
330 return UnDefType.NULL;
333 private State getBatteryValue(String channelId, HvBattery hvBattery) {
336 return hvBattery.hvBatteryLevel != UNDEFINED ? new QuantityType<>(hvBattery.hvBatteryLevel, PERCENT)
338 case BATTERY_DISTANCE_TO_EMPTY:
339 return hvBattery.distanceToHVBatteryEmpty != UNDEFINED
340 ? new QuantityType<>(hvBattery.distanceToHVBatteryEmpty, KILO(METRE))
343 return hvBattery.hvBatteryChargeStatusDerived != null
344 ? new StringType(hvBattery.hvBatteryChargeStatusDerived)
346 case TIME_TO_BATTERY_FULLY_CHARGED:
347 return hvBattery.timeToHVBatteryFullyCharged != UNDEFINED
348 ? new QuantityType<>(hvBattery.timeToHVBatteryFullyCharged, MINUTE)
351 return hvBattery.timeToHVBatteryFullyCharged != UNDEFINED && hvBattery.timeToHVBatteryFullyCharged > 0
352 ? new DateTimeType(ZonedDateTime.now().plusMinutes(hvBattery.timeToHVBatteryFullyCharged))
356 return UnDefType.NULL;
359 private State getValue(@Nullable String groupId, String channelId, Status status, VehiclePositionWrapper position) {
362 return status.odometer != UNDEFINED ? new QuantityType<>((double) status.odometer / 1000, KILO(METRE))
365 return status.tripMeter1 != UNDEFINED
366 ? new QuantityType<>((double) status.tripMeter1 / 1000, KILO(METRE))
369 return status.tripMeter2 != UNDEFINED
370 ? new QuantityType<>((double) status.tripMeter2 / 1000, KILO(METRE))
372 case DISTANCE_TO_EMPTY:
373 return status.distanceToEmpty != UNDEFINED ? new QuantityType<>(status.distanceToEmpty, KILO(METRE))
376 return status.fuelAmount != UNDEFINED ? new QuantityType<>(status.fuelAmount, LITRE) : UnDefType.UNDEF;
378 return status.fuelAmountLevel != UNDEFINED ? new QuantityType<>(status.fuelAmountLevel, PERCENT)
380 case FUEL_CONSUMPTION:
381 return status.averageFuelConsumption != UNDEFINED ? new DecimalType(status.averageFuelConsumption / 10)
383 case ACTUAL_LOCATION:
384 return position.getPosition();
385 case CALCULATED_LOCATION:
386 return position.isCalculated();
388 return position.isHeading();
389 case LOCATION_TIMESTAMP:
390 return position.getTimestamp();
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);
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);
402 return status.averageSpeed != UNDEFINED ? new QuantityType<>(status.averageSpeed, KILOMETRE_PER_HOUR)
404 case SERVICE_WARNING:
405 return new StringType(status.serviceWarningStatus);
407 return status.distanceToEmpty < 100 ? OnOffType.ON : OnOffType.OFF;
409 return status.aFailedBulb() ? OnOffType.ON : OnOffType.OFF;
411 case PRECLIMATIZATION:
412 return status.getHeater().map(heater -> getHeaterValue(channelId, heater)).orElse(UnDefType.NULL);
414 if (groupId != null) {
417 return status.getDoors().map(doors -> getDoorsValue(channelId, doors)).orElse(UnDefType.NULL);
419 return status.getWindows().map(windows -> getWindowsValue(channelId, windows))
420 .orElse(UnDefType.NULL);
422 return status.getTyrePressure().map(tyres -> getTyresValue(channelId, tyres))
423 .orElse(UnDefType.NULL);
425 return status.getHvBattery().map(batteries -> getBatteryValue(channelId, batteries))
426 .orElse(UnDefType.NULL);
429 return UnDefType.NULL;
432 public void actionHonkBlink(Boolean honk, Boolean blink) {
433 VolvoOnCallBridgeHandler bridgeHandler = getBridgeHandler();
434 if (bridgeHandler != null) {
435 StringBuilder url = new StringBuilder(SERVICE_URL + "vehicles/" + vehicle.vehicleId + "/honk_blink/");
437 if (honk && blink && activeOptions.containsKey(HONK_BLINK)) {
439 } else if (honk && activeOptions.containsKey(HONK_AND_OR_BLINK)) {
441 } else if (blink && activeOptions.containsKey(HONK_AND_OR_BLINK)) {
442 url.append("lights");
444 logger.warn("The vehicle is not capable of this action");
449 bridgeHandler.postURL(url.toString(), vehiclePosition.getPositionAsJSon());
450 } catch (VolvoOnCallException e) {
451 logger.warn("Exception occurred during execution: {}", e.getMessage(), e);
452 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
457 private void actionOpenClose(String action, OnOffType controlState) {
458 VolvoOnCallBridgeHandler bridgeHandler = getBridgeHandler();
459 if (bridgeHandler != null) {
460 if (activeOptions.containsKey(action)) {
461 if (!vehicleStatus.getCarLocked().isPresent() || vehicleStatus.getCarLocked().get() != controlState) {
463 StringBuilder address = new StringBuilder(SERVICE_URL);
464 address.append("vehicles/");
465 address.append(configuration.vin);
467 address.append(action);
468 bridgeHandler.postURL(address.toString(), "{}");
469 } catch (VolvoOnCallException e) {
470 logger.warn("Exception occurred during execution: {}", e.getMessage(), e);
471 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
474 logger.info("The car {} is already {}ed", configuration.vin, action);
477 logger.warn("The car {} does not support remote {}ing", configuration.vin, action);
482 private void actionHeater(String action, Boolean start) {
483 VolvoOnCallBridgeHandler bridgeHandler = getBridgeHandler();
484 if (bridgeHandler != null) {
485 if (activeOptions.containsKey(action)) {
487 if (action.contains(REMOTE_HEATER)) {
488 String command = start ? "start" : "stop";
489 String address = SERVICE_URL + "vehicles/" + configuration.vin + "/heater/" + command;
490 bridgeHandler.postURL(address, start ? "{}" : null);
491 } else if (action.contains(PRECLIMATIZATION)) {
492 String command = start ? "start" : "stop";
493 String address = SERVICE_URL + "vehicles/" + configuration.vin + "/preclimatization/" + command;
494 bridgeHandler.postURL(address, start ? "{}" : null);
496 } catch (VolvoOnCallException e) {
497 logger.warn("Exception occurred during execution: {}", e.getMessage(), e);
498 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
501 logger.warn("The car {} does not support {}", configuration.vin, action);
506 public void actionHeater(Boolean start) {
507 actionHeater(REMOTE_HEATER, start);
510 public void actionPreclimatization(Boolean start) {
511 actionHeater(PRECLIMATIZATION, start);
514 public void actionOpen() {
515 actionOpenClose(UNLOCK, OnOffType.OFF);
518 public void actionClose() {
519 actionOpenClose(LOCK, OnOffType.ON);
522 public void actionStart(Integer runtime) {
523 VolvoOnCallBridgeHandler bridgeHandler = getBridgeHandler();
524 if (bridgeHandler != null) {
525 if (activeOptions.containsKey(ENGINE_START)) {
526 String url = SERVICE_URL + "vehicles/" + vehicle.vehicleId + "/engine/start";
527 String json = "{\"runtime\":" + runtime.toString() + "}";
530 bridgeHandler.postURL(url, json);
531 } catch (VolvoOnCallException e) {
532 logger.warn("Exception occurred during execution: {}", e.getMessage(), e);
533 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
536 logger.warn("The car {} does not support remote engine starting", vehicle.vehicleId);
542 * Called by Bridge when it has to notify this of a potential state
546 void updateIfMatches(String vin) {
547 if (vin.equalsIgnoreCase(configuration.vin)) {
548 queryApiAndUpdateChannels();
552 private @Nullable VolvoOnCallBridgeHandler getBridgeHandler() {
553 Bridge bridge = getBridge();
554 if (bridge != null) {
555 BridgeHandler handler = bridge.getHandler();
556 if (handler != null) {
557 return (VolvoOnCallBridgeHandler) handler;
560 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
565 public Collection<Class<? extends ThingHandlerService>> getServices() {
566 return Collections.singletonList(VolvoOnCallActions.class);