diff --git a/bundles/org.openhab.binding.renault/README.md b/bundles/org.openhab.binding.renault/README.md index 4175ece63..0f4eb18ad 100644 --- a/bundles/org.openhab.binding.renault/README.md +++ b/bundles/org.openhab.binding.renault/README.md @@ -19,15 +19,15 @@ No discovery You require your MyRenault credential, locale and VIN for your MyRenault registered car. -| Parameter | Description | Required | -|-------------------|----------------------------------------------------------------------------|----------| -| myRenaultUsername | MyRenault Username. | yes | -| myRenaultPassword | MyRenault Password. | yes | -| locale | MyRenault Location (language_country). | yes | -| vin | Vehicle Identification Number. | yes | -| refreshInterval | Interval the car is polled in minutes. | no | -| updateDelay | How long to wait for commands to reach car and update to server in seconds.| no | -| kamereonApiKey | Kamereon API Key. | no | +| Parameter | Description | Default | +|-------------------|----------------------------------------------------------------------------|----------------------------------| +| myRenaultUsername | MyRenault Username. | | +| myRenaultPassword | MyRenault Password. | | +| locale | MyRenault Location (language_country). | | +| vin | Vehicle Identification Number. | | +| refreshInterval | Interval the car is polled in minutes. | 10 | +| updateDelay | How long to wait for commands to reach car and update to server in seconds.| 30 | +| kamereonApiKey | Kamereon API Key. | VAX7XYKGfa92yMvXculCkEFyfZbuM7Ss | ## Channels @@ -35,6 +35,7 @@ You require your MyRenault credential, locale and VIN for your MyRenault registe |------------------------|--------------------|-------------------------------------------------|-----------| | batteryavailableEnergy | Number:Energy | Battery Energy Available | Yes | | batterylevel | Number | State of the battery in % | Yes | +| batterystatusupdated | DateTime | Timestamp of the last battery status update | Yes | | chargingmode | String | Charging mode. always_charging or schedule_mode | No | | chargingstatus | String | Charging status | Yes | | chargingremainingtime | Number:Time | Charging time remaining | Yes | @@ -47,6 +48,7 @@ You require your MyRenault credential, locale and VIN for your MyRenault registe | image | String | Image URL of MyRenault | Yes | | location | Location | The GPS position of the vehicle | Yes | | locationupdated | DateTime | Timestamp of the last location update | Yes | +| locked | Switch | Locked status of the car | Yes | ## Limitations @@ -69,24 +71,24 @@ renaultcar.sitemap: sitemap renaultcar label="Renault Car" { Frame { Image item=RenaultCar_ImageURL - Default item=RenaultCar_BatteryLevel icon="batterylevel" - Default item=RenaultCar_BatteryEnergyAvailable icon="energy" - Default item=RenaultCar_PlugStatus icon="poweroutlet" - Default item=RenaultCar_ChargingStatus icon="switch" - Selection item=RenaultCar_ChargingMode mappings=[SCHEDULE_MODE="Schedule mode",ALWAYS_CHARGING="Instant charge"] icon="switch" - Default item=RenaultCar_ChargingTimeRemaining icon="time" - Default item=RenaultCar_EstimatedRange - Default item=RenaultCar_Odometer - Selection item=RenaultCar_HVACStatus mappings=[ON="ON"] icon="switch" - Setpoint item=RenaultCar_HVACTargetTemperature minValue=19 maxValue=21 step=1 icon="temperature" - Default item=RenaultCar_LocationUpdate icon="time" - Default item=RenaultCar_Location + Default icon="batterylevel" item=RenaultCar_BatteryLevel + Default item=RenaultCar_BatteryEnergyAvailable + Default item=RenaultCar_BatteryStatusUpdated + Default icon="poweroutlet" item=RenaultCar_PlugStatus + Default icon="switch" item=RenaultCar_ChargingStatus + Selection icon="switch" item=RenaultCar_ChargingMode mappings=[SCHEDULE_MODE="Schedule mode",ALWAYS_CHARGING="Instant charge"] + Default item=RenaultCar_ChargingTimeRemaining + Default icon="pressure" item=RenaultCar_EstimatedRange + Default icon="pressure" item=RenaultCar_Odometer + Selection icon="switch" item=RenaultCar_HVACStatus mappings=[ON="ON"] + Setpoint icon="temperature" item=RenaultCar_HVACTargetTemperature maxValue=21 minValue=19 step=1 + Default icon="lock" item=RenaultCar_Locked + Default item=RenaultCar_LocationUpdate + Default icon="zoom" item=RenaultCar_Location } } ``` -![Sitemap](doc/sitemap.png) - If you want to limit the charge of the car battery to less than 100%, this can be done as follows. - Set up an active dummy charge schedule in the MyRenault App. diff --git a/bundles/org.openhab.binding.renault/doc/sitemap.png b/bundles/org.openhab.binding.renault/doc/sitemap.png deleted file mode 100644 index 44425d092..000000000 Binary files a/bundles/org.openhab.binding.renault/doc/sitemap.png and /dev/null differ diff --git a/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/RenaultBindingConstants.java b/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/RenaultBindingConstants.java index 877d00bc0..4323727eb 100644 --- a/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/RenaultBindingConstants.java +++ b/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/RenaultBindingConstants.java @@ -32,6 +32,7 @@ public class RenaultBindingConstants { // List of all Channel ids public static final String CHANNEL_BATTERY_AVAILABLE_ENERGY = "batteryavailableenergy"; public static final String CHANNEL_BATTERY_LEVEL = "batterylevel"; + public static final String CHANNEL_BATTERY_STATUS_UPDATED = "batterystatusupdated"; public static final String CHANNEL_CHARGING_MODE = "chargingmode"; public static final String CHANNEL_CHARGING_STATUS = "chargingstatus"; public static final String CHANNEL_CHARGING_REMAINING_TIME = "chargingremainingtime"; @@ -42,6 +43,7 @@ public class RenaultBindingConstants { public static final String CHANNEL_IMAGE = "image"; public static final String CHANNEL_LOCATION = "location"; public static final String CHANNEL_LOCATION_UPDATED = "locationupdated"; + public static final String CHANNEL_LOCKED = "locked"; public static final String CHANNEL_ODOMETER = "odometer"; public static final String CHANNEL_PLUG_STATUS = "plugstatus"; } diff --git a/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/api/Car.java b/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/api/Car.java index bec8189ac..cbcc7caea 100644 --- a/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/api/Car.java +++ b/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/api/Car.java @@ -12,6 +12,9 @@ */ package org.openhab.binding.renault.internal.api; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.slf4j.Logger; @@ -40,6 +43,7 @@ public class Car { private boolean disableBattery = false; private boolean disableCockpit = false; private boolean disableHvac = false; + private boolean disableLockStatus = false; private ChargingStatus chargingStatus = ChargingStatus.UNKNOWN; private ChargingMode chargingMode = ChargingMode.UNKNOWN; @@ -47,12 +51,14 @@ public class Car { private double hvacTargetTemperature = 20.0; private @Nullable Double batteryLevel; private @Nullable Double batteryAvailableEnergy; + private @Nullable ZonedDateTime batteryStatusUpdated; private @Nullable Integer chargingRemainingTime; private @Nullable Boolean hvacstatus; private @Nullable Double odometer; private @Nullable Double estimatedRange; private @Nullable String imageURL; - private @Nullable String locationUpdated; + private @Nullable ZonedDateTime locationUpdated; + private LockStatus lockStatus = LockStatus.UNKNOWN; private @Nullable Double gpsLatitude; private @Nullable Double gpsLongitude; private @Nullable Double externalTemperature; @@ -63,14 +69,6 @@ public class Car { ALWAYS_CHARGING } - public enum PlugStatus { - UNPLUGGED, - PLUGGED, - PLUG_ERROR, - PLUG_UNKNOWN, - UNKNOWN - } - public enum ChargingStatus { NOT_IN_CHARGE, WAITING_FOR_A_PLANNED_CHARGE, @@ -83,6 +81,20 @@ public class Car { UNKNOWN } + public enum LockStatus { + LOCKED, + UNLOCKED, + UNKNOWN + } + + public enum PlugStatus { + UNPLUGGED, + PLUGGED, + PLUG_ERROR, + PLUG_UNKNOWN, + UNKNOWN + } + public void setBatteryStatus(JsonObject responseJson) { try { JsonObject attributes = getAttributes(responseJson); @@ -105,6 +117,14 @@ public class Car { if (attributes.get("chargingRemainingTime") != null) { chargingRemainingTime = attributes.get("chargingRemainingTime").getAsInt(); } + if (attributes.get("timestamp") != null) { + try { + batteryStatusUpdated = ZonedDateTime.parse(attributes.get("timestamp").getAsString()); + } catch (DateTimeParseException e) { + batteryStatusUpdated = null; + logger.debug("Error updating battery status updated timestamp. {}", e.getMessage()); + } + } } } catch (IllegalStateException | ClassCastException e) { logger.warn("Error {} parsing Battery Status: {}", e.getMessage(), responseJson); @@ -153,7 +173,25 @@ public class Car { gpsLongitude = attributes.get("gpsLongitude").getAsDouble(); } if (attributes.get("lastUpdateTime") != null) { - locationUpdated = attributes.get("lastUpdateTime").getAsString(); + try { + locationUpdated = ZonedDateTime.parse(attributes.get("lastUpdateTime").getAsString()); + } catch (DateTimeParseException e) { + locationUpdated = null; + logger.debug("Error updating location updated timestamp. {}", e.getMessage()); + } + } + } + } catch (IllegalStateException | ClassCastException e) { + logger.warn("Error {} parsing Location: {}", e.getMessage(), responseJson); + } + } + + public void setLockStatus(JsonObject responseJson) { + try { + JsonObject attributes = getAttributes(responseJson); + if (attributes != null) { + if (attributes.get("lockStatus") != null) { + lockStatus = mapLockStatus(attributes.get("lockStatus").getAsString()); } } } catch (IllegalStateException | ClassCastException e) { @@ -212,6 +250,10 @@ public class Car { return batteryLevel; } + public @Nullable ZonedDateTime getBatteryStatusUpdated() { + return batteryStatusUpdated; + } + public @Nullable Boolean getHvacstatus() { return hvacstatus; } @@ -232,7 +274,7 @@ public class Car { return gpsLongitude; } - public @Nullable String getLocationUpdated() { + public @Nullable ZonedDateTime getLocationUpdated() { return locationUpdated; } @@ -312,6 +354,17 @@ public class Car { return null; } + private LockStatus mapLockStatus(final String apiLockStatus) { + switch (apiLockStatus) { + case "locked": + return LockStatus.LOCKED; + case "unlocked": + return LockStatus.UNLOCKED; + default: + return LockStatus.UNKNOWN; + } + } + private PlugStatus mapPlugStatus(final String apiPlugState) { // https://github.com/hacf-fr/renault-api/blob/main/src/renault_api/kamereon/enums.py switch (apiPlugState) { @@ -351,4 +404,16 @@ public class Car { return ChargingStatus.UNKNOWN; } } + + public LockStatus getLockStatus() { + return lockStatus; + } + + public boolean isDisableLockStatus() { + return disableLockStatus; + } + + public void setDisableLockStatus(boolean disableLockStatus) { + this.disableLockStatus = disableLockStatus; + } } diff --git a/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/api/MyRenaultHttpSession.java b/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/api/MyRenaultHttpSession.java index fa8f0c069..3cb36b491 100644 --- a/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/api/MyRenaultHttpSession.java +++ b/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/api/MyRenaultHttpSession.java @@ -26,6 +26,7 @@ import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.util.Fields; import org.openhab.binding.renault.internal.RenaultConfiguration; import org.openhab.binding.renault.internal.api.Car.ChargingMode; +import org.openhab.binding.renault.internal.api.exceptions.RenaultActionException; import org.openhab.binding.renault.internal.api.exceptions.RenaultException; import org.openhab.binding.renault.internal.api.exceptions.RenaultForbiddenException; import org.openhab.binding.renault.internal.api.exceptions.RenaultNotImplementedException; @@ -87,9 +88,13 @@ public class MyRenaultHttpSession { fields.add("ApiKey", this.constants.getGigyaApiKey()); fields.add("loginID", config.myRenaultUsername); fields.add("password", config.myRenaultPassword); - logger.debug("URL: {}/accounts.login", this.constants.getGigyaRootUrl()); - ContentResponse response = httpClient.FORM(this.constants.getGigyaRootUrl() + "/accounts.login", fields); + final String url = this.constants.getGigyaRootUrl() + "/accounts.login"; + ContentResponse response = httpClient.FORM(url, fields); if (HttpStatus.OK_200 == response.getStatus()) { + if (logger.isTraceEnabled()) { + logger.trace("GigyaApi Request: {} Response: [{}] {}\n{}", url, response.getStatus(), + response.getReason(), response.getContentAsString()); + } try { JsonObject responseJson = JsonParser.parseString(response.getContentAsString()).getAsJsonObject(); JsonObject sessionInfoJson = responseJson.getAsJsonObject("sessionInfo"); @@ -104,11 +109,11 @@ public class MyRenaultHttpSession { throw new RenaultException("Login Error: cookie value not found in JSON response"); } if (cookieValue == null) { - logger.warn("Login Error: cookie value not found! Response: [{}] {}\n{}", response.getStatus(), - response.getReason(), response.getContentAsString()); + logger.warn("Login Error: cookie value not found! Response: {}", response.getContentAsString()); + throw new RenaultException("Login Error: cookie value not found in JSON response"); } } else { - logger.warn("Response: [{}] {}\n{}", response.getStatus(), response.getReason(), + logger.warn("GigyaApi Request: {} Response: [{}] {}\n{}", url, response.getStatus(), response.getReason(), response.getContentAsString()); throw new RenaultException("Login Error: " + response.getReason()); } @@ -118,9 +123,13 @@ public class MyRenaultHttpSession { Fields fields = new Fields(); fields.add("ApiKey", this.constants.getGigyaApiKey()); fields.add("login_token", cookieValue); - ContentResponse response = httpClient.FORM(this.constants.getGigyaRootUrl() + "/accounts.getAccountInfo", - fields); + final String url = this.constants.getGigyaRootUrl() + "/accounts.getAccountInfo"; + ContentResponse response = httpClient.FORM(url, fields); if (HttpStatus.OK_200 == response.getStatus()) { + if (logger.isTraceEnabled()) { + logger.trace("GigyaApi Request: {} Response: [{}] {}\n{}", url, response.getStatus(), + response.getReason(), response.getContentAsString()); + } try { JsonObject responseJson = JsonParser.parseString(response.getContentAsString()).getAsJsonObject(); JsonObject dataJson = responseJson.getAsJsonObject("data"); @@ -138,7 +147,7 @@ public class MyRenaultHttpSession { "Get Account Info Error: personId or gigyaDataCenter value not found in JSON response"); } } else { - logger.warn("Response: [{}] {}\n{}", response.getStatus(), response.getReason(), + logger.warn("GigyaApi Request: {} Response: [{}] {}\n{}", url, response.getStatus(), response.getReason(), response.getContentAsString()); throw new RenaultException("Get Account Info Error: " + response.getReason()); } @@ -151,20 +160,25 @@ public class MyRenaultHttpSession { fields.add("fields", "data.personId,data.gigyaDataCenter"); fields.add("personId", personId); fields.add("gigyaDataCenter", gigyaDataCenter); - ContentResponse response = this.httpClient.FORM(this.constants.getGigyaRootUrl() + "/accounts.getJWT", fields); + final String url = this.constants.getGigyaRootUrl() + "/accounts.getJWT"; + ContentResponse response = this.httpClient.FORM(url, fields); if (HttpStatus.OK_200 == response.getStatus()) { + if (logger.isTraceEnabled()) { + logger.trace("GigyaApi Request: {} Response: [{}] {}\n{}", url, response.getStatus(), + response.getReason(), response.getContentAsString()); + } try { JsonObject responseJson = JsonParser.parseString(response.getContentAsString()).getAsJsonObject(); JsonElement element = responseJson.get("id_token"); if (element != null) { jwt = element.getAsString(); - logger.debug("jwt: {} ", jwt); + logger.debug("GigyaApi jwt: {} ", jwt); } } catch (JsonParseException | ClassCastException | IllegalStateException e) { throw new RenaultException("Get JWT Error: jwt value not found in JSON response"); } } else { - logger.warn("Response: [{}] {}\n{}", response.getStatus(), response.getReason(), + logger.warn("GigyaApi Request: {} Response: [{}] {}\n{}", url, response.getStatus(), response.getReason(), response.getContentAsString()); throw new RenaultException("Get JWT Error: " + response.getReason()); } @@ -233,108 +247,101 @@ public class MyRenaultHttpSession { } } - public void actionHvacOn(double hvacTargetTemperature) - throws RenaultForbiddenException, RenaultNotImplementedException { - Request request = httpClient - .newRequest(this.constants.getKamereonRootUrl() + "/commerce/v1/accounts/" + kamereonaccountId - + "/kamereon/kca/car-adapter/v1/cars/" + config.vin + "/actions/hvac-start?country=" - + getCountry(config)) - .method(HttpMethod.POST).header("Content-type", "application/vnd.api+json") - .header("apikey", this.config.kamereonApiKey) - .header("x-kamereon-authorization", "Bearer " + kamereonToken).header("x-gigya-id_token", jwt); - request.content(new StringContentProvider( - "{\"data\":{\"type\":\"HvacStart\",\"attributes\":{\"action\":\"start\",\"targetTemperature\":\"" - + hvacTargetTemperature + "\"}}}", - "utf-8")); - try { - ContentResponse response = request.send(); - logger.debug("Kamereon Response HVAC ON: {}", response.getContentAsString()); - if (HttpStatus.OK_200 != response.getStatus()) { - logger.warn("Kamereon Response: [{}] {} {}", response.getStatus(), response.getReason(), - response.getContentAsString()); - if (HttpStatus.FORBIDDEN_403 == response.getStatus()) { - throw new RenaultForbiddenException( - "Kamereon Response Forbidden! Ensure the car is paired in your MyRenault App."); - } else if (HttpStatus.NOT_IMPLEMENTED_501 == response.getStatus()) { - throw new RenaultNotImplementedException( - "Kamereon Service Not Implemented: [" + response.getStatus() + "] " + response.getReason()); - } - } - } catch (InterruptedException e) { - logger.warn("Kamereon Request: {} threw exception: {} ", request.getURI().toString(), e.getMessage()); - Thread.currentThread().interrupt(); - } catch (JsonParseException | TimeoutException | ExecutionException e) { - logger.warn("Kamereon Request: {} threw exception: {} ", request.getURI().toString(), e.getMessage()); + public void getLockStatus(Car car) + throws RenaultForbiddenException, RenaultUpdateException, RenaultNotImplementedException { + JsonObject responseJson = getKamereonResponse("/commerce/v1/accounts/" + kamereonaccountId + + "/kamereon/kca/car-adapter/v1/cars/" + config.vin + "/lock-status?country=" + getCountry(config)); + if (responseJson != null) { + car.setLockStatus(responseJson); } } - public void actionChargeMode(ChargingMode mode) throws RenaultForbiddenException, RenaultNotImplementedException { - Request request = httpClient - .newRequest(this.constants.getKamereonRootUrl() + "/commerce/v1/accounts/" + kamereonaccountId - + "/kamereon/kca/car-adapter/v1/cars/" + config.vin + "/actions/charge-mode?country=" - + getCountry(config)) - .method(HttpMethod.POST).header("Content-type", "application/vnd.api+json") - .header("apikey", this.config.kamereonApiKey) - .header("x-kamereon-authorization", "Bearer " + kamereonToken).header("x-gigya-id_token", jwt); + public void actionHvacOn(double hvacTargetTemperature) + throws RenaultForbiddenException, RenaultNotImplementedException, RenaultActionException { + final String path = "/commerce/v1/accounts/" + kamereonaccountId + "/kamereon/kca/car-adapter/v1/cars/" + + config.vin + "/actions/hvac-start?country=" + getCountry(config); + postKamereonRequest(path, + "{\"data\":{\"type\":\"HvacStart\",\"attributes\":{\"action\":\"start\",\"targetTemperature\":\"" + + hvacTargetTemperature + "\"}}}"); + } + public void actionChargeMode(ChargingMode mode) + throws RenaultForbiddenException, RenaultNotImplementedException, RenaultActionException { final String apiMode = ChargingMode.SCHEDULE_MODE.equals(mode) ? CHARGING_MODE_SCHEDULE : CHARGING_MODE_ALWAYS; - request.content(new StringContentProvider( - "{\"data\":{\"type\":\"ChargeMode\",\"attributes\":{\"action\":\"" + apiMode + "\"}}}", "utf-8")); + final String path = "/commerce/v1/accounts/" + kamereonaccountId + "/kamereon/kca/car-adapter/v1/cars/" + + config.vin + "/actions/charge-mode?country=" + getCountry(config); + postKamereonRequest(path, + "{\"data\":{\"type\":\"ChargeMode\",\"attributes\":{\"action\":\"" + apiMode + "\"}}}"); + } + + private void postKamereonRequest(final String path, final String content) + throws RenaultForbiddenException, RenaultNotImplementedException, RenaultActionException { + Request request = httpClient.newRequest(this.constants.getKamereonRootUrl() + path).method(HttpMethod.POST) + .header("Content-type", "application/vnd.api+json").header("apikey", this.config.kamereonApiKey) + .header("x-kamereon-authorization", "Bearer " + kamereonToken).header("x-gigya-id_token", jwt) + .content(new StringContentProvider(content, "utf-8")); try { ContentResponse response = request.send(); - logger.debug("Kamereon Response set ChargeMode: {}", response.getContentAsString()); - if (HttpStatus.OK_200 != response.getStatus()) { - logger.warn("Kamereon Response: [{}] {} {}", response.getStatus(), response.getReason(), - response.getContentAsString()); - if (HttpStatus.FORBIDDEN_403 == response.getStatus()) { - throw new RenaultForbiddenException( - "Kamereon Response Forbidden! Ensure the car is paired in your MyRenault App."); - } else if (HttpStatus.NOT_IMPLEMENTED_501 == response.getStatus()) { - throw new RenaultNotImplementedException( - "Kamereon Service Not Implemented: [" + response.getStatus() + "] " + response.getReason()); - } - } + logKamereonCall(request, response); + checkResponse(response); } catch (InterruptedException e) { logger.warn("Kamereon Request: {} threw exception: {} ", request.getURI().toString(), e.getMessage()); Thread.currentThread().interrupt(); } catch (JsonParseException | TimeoutException | ExecutionException e) { - logger.warn("Kamereon Request: {} threw exception: {} ", request.getURI().toString(), e.getMessage()); + throw new RenaultActionException(e.toString()); } } private @Nullable JsonObject getKamereonResponse(String path) - throws RenaultForbiddenException, RenaultUpdateException, RenaultNotImplementedException { + throws RenaultForbiddenException, RenaultNotImplementedException, RenaultUpdateException { Request request = httpClient.newRequest(this.constants.getKamereonRootUrl() + path).method(HttpMethod.GET) .header("Content-type", "application/vnd.api+json").header("apikey", this.config.kamereonApiKey) .header("x-kamereon-authorization", "Bearer " + kamereonToken).header("x-gigya-id_token", jwt); try { ContentResponse response = request.send(); + logKamereonCall(request, response); if (HttpStatus.OK_200 == response.getStatus()) { - logger.debug("Kamereon Response: {}", response.getContentAsString()); return JsonParser.parseString(response.getContentAsString()).getAsJsonObject(); - } else { - logger.warn("Kamereon Response: [{}] {} {}", response.getStatus(), response.getReason(), - response.getContentAsString()); - if (HttpStatus.FORBIDDEN_403 == response.getStatus()) { - throw new RenaultForbiddenException( - "Kamereon Response Forbidden! Ensure the car is paired in your MyRenault App."); - } else if (HttpStatus.NOT_IMPLEMENTED_501 == response.getStatus()) { - throw new RenaultNotImplementedException( - "Kamereon Service Not Implemented: [" + response.getStatus() + "] " + response.getReason()); - } else { - throw new RenaultUpdateException( - "Kamereon Response Failed! Error: [" + response.getStatus() + "] " + response.getReason()); - } } + checkResponse(response); } catch (InterruptedException e) { logger.warn("Kamereon Request: {} threw exception: {} ", request.getURI().toString(), e.getMessage()); Thread.currentThread().interrupt(); } catch (JsonParseException | TimeoutException | ExecutionException e) { - logger.warn("Kamereon Request: {} threw exception: {} ", request.getURI().toString(), e.getMessage()); + throw new RenaultUpdateException(e.toString()); } return null; } + private void logKamereonCall(Request request, ContentResponse response) { + if (HttpStatus.OK_200 == response.getStatus()) { + if (logger.isTraceEnabled()) { + logger.trace("Kamereon Request: {} Response: [{}] {}\n{}", request.getURI().toString(), + response.getStatus(), response.getReason(), response.getContentAsString()); + } + } else { + logger.warn("Kamereon Request: {} Response: [{}] {}\n{}", request.getURI().toString(), response.getStatus(), + response.getReason(), response.getContentAsString()); + } + } + + private void checkResponse(ContentResponse response) + throws RenaultForbiddenException, RenaultNotImplementedException { + switch (response.getStatus()) { + case HttpStatus.FORBIDDEN_403: + throw new RenaultForbiddenException( + "Kamereon request forbidden! Ensure the car is paired in your MyRenault App."); + case HttpStatus.NOT_FOUND_404: + throw new RenaultNotImplementedException("Kamereon service not found"); + case HttpStatus.NOT_IMPLEMENTED_501: + throw new RenaultNotImplementedException("Kamereon request not implemented"); + case HttpStatus.BAD_GATEWAY_502: + throw new RenaultNotImplementedException("Kamereon request failed"); + default: + break; + } + } + private String getCountry(RenaultConfiguration config) { String country = "XX"; if (config.locale.length() == 5) { diff --git a/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/api/exceptions/RenaultActionException.java b/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/api/exceptions/RenaultActionException.java new file mode 100644 index 000000000..aa153850f --- /dev/null +++ b/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/api/exceptions/RenaultActionException.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.renault.internal.api.exceptions; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception thrown while trying to perform action on the My Renault car. + * + * @author Doug Culnane - Initial contribution + */ +@NonNullByDefault +public class RenaultActionException extends Exception { + + private static final long serialVersionUID = 1L; + + public RenaultActionException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/handler/RenaultHandler.java b/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/handler/RenaultHandler.java index fae1d60e9..2c793b26d 100644 --- a/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/handler/RenaultHandler.java +++ b/bundles/org.openhab.binding.renault/src/main/java/org/openhab/binding/renault/internal/handler/RenaultHandler.java @@ -18,6 +18,7 @@ import static org.openhab.core.library.unit.SIUnits.METRE; import static org.openhab.core.library.unit.Units.KILOWATT_HOUR; import static org.openhab.core.library.unit.Units.MINUTE; +import java.time.ZonedDateTime; import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -36,12 +37,14 @@ import org.openhab.binding.renault.internal.RenaultConfiguration; import org.openhab.binding.renault.internal.api.Car; import org.openhab.binding.renault.internal.api.Car.ChargingMode; import org.openhab.binding.renault.internal.api.MyRenaultHttpSession; +import org.openhab.binding.renault.internal.api.exceptions.RenaultActionException; import org.openhab.binding.renault.internal.api.exceptions.RenaultException; import org.openhab.binding.renault.internal.api.exceptions.RenaultForbiddenException; import org.openhab.binding.renault.internal.api.exceptions.RenaultNotImplementedException; import org.openhab.binding.renault.internal.api.exceptions.RenaultUpdateException; import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.PointType; import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.StringType; @@ -53,6 +56,7 @@ import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.thing.binding.BaseThingHandler; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; +import org.openhab.core.types.UnDefType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -110,19 +114,15 @@ public class RenaultHandler extends BaseThingHandler { return; } updateStatus(ThingStatus.UNKNOWN); - updateState(CHANNEL_HVAC_TARGET_TEMPERATURE, new QuantityType(car.getHvacTargetTemperature(), SIUnits.CELSIUS)); - // Background initialization: - ScheduledFuture job = pollingJob; - if (job == null || job.isCancelled()) { - pollingJob = scheduler.scheduleWithFixedDelay(this::getStatus, 0, config.refreshInterval, TimeUnit.MINUTES); - } + reschedulePollingJob(); } @Override public void handleCommand(ChannelUID channelUID, Command command) { + switch (channelUID.getId()) { case RenaultBindingConstants.CHANNEL_HVAC_TARGET_TEMPERATURE: if (!car.isDisableHvac()) { @@ -146,32 +146,39 @@ public class RenaultHandler extends BaseThingHandler { } break; case RenaultBindingConstants.CHANNEL_HVAC_STATUS: - // We can only trigger pre-conditioning of the car. - if (command instanceof StringType && command.toString().equals(Car.HVAC_STATUS_ON) - && !car.isDisableHvac()) { - final MyRenaultHttpSession httpSession = new MyRenaultHttpSession(this.config, httpClient); - try { - updateState(CHANNEL_HVAC_STATUS, new StringType(Car.HVAC_STATUS_PENDING)); - car.resetHVACStatus(); - httpSession.initSesssion(car); - httpSession.actionHvacOn(car.getHvacTargetTemperature()); - if (pollingJob != null) { - pollingJob.cancel(true); + if (!car.isDisableHvac()) { + if (command instanceof RefreshType) { + reschedulePollingJob(); + } else if (command instanceof StringType && command.toString().equals(Car.HVAC_STATUS_ON)) { + // We can only trigger pre-conditioning of the car. + final MyRenaultHttpSession httpSession = new MyRenaultHttpSession(this.config, httpClient); + try { + updateState(CHANNEL_HVAC_STATUS, new StringType(Car.HVAC_STATUS_PENDING)); + car.resetHVACStatus(); + httpSession.initSesssion(car); + httpSession.actionHvacOn(car.getHvacTargetTemperature()); + ScheduledFuture job = pollingJob; + if (job != null) { + job.cancel(true); + } + pollingJob = scheduler.scheduleWithFixedDelay(this::getStatus, config.updateDelay, + config.refreshInterval * 60, TimeUnit.SECONDS); + } catch (InterruptedException e) { + logger.warn("Error My Renault Http Session.", e); + Thread.currentThread().interrupt(); + } catch (RenaultException | RenaultForbiddenException | RenaultUpdateException + | RenaultActionException | RenaultNotImplementedException | ExecutionException + | TimeoutException e) { + logger.warn("Error during action HVAC on.", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); } - pollingJob = scheduler.scheduleWithFixedDelay(this::getStatus, config.updateDelay, - config.refreshInterval * 60, TimeUnit.SECONDS); - } catch (InterruptedException e) { - logger.warn("Error My Renault Http Session.", e); - Thread.currentThread().interrupt(); - } catch (RenaultException | RenaultForbiddenException | RenaultUpdateException - | RenaultNotImplementedException | ExecutionException | TimeoutException e) { - logger.warn("Error My Renault Http Session.", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); } } break; case RenaultBindingConstants.CHANNEL_CHARGING_MODE: - if (command instanceof StringType) { + if (command instanceof RefreshType) { + reschedulePollingJob(); + } else if (command instanceof StringType) { try { ChargingMode newMode = ChargingMode.valueOf(command.toString()); if (!ChargingMode.UNKNOWN.equals(newMode)) { @@ -185,8 +192,9 @@ public class RenaultHandler extends BaseThingHandler { logger.warn("Error My Renault Http Session.", e); Thread.currentThread().interrupt(); } catch (RenaultException | RenaultForbiddenException | RenaultUpdateException - | RenaultNotImplementedException | ExecutionException | TimeoutException e) { - logger.warn("Error My Renault Http Session.", e); + | RenaultActionException | RenaultNotImplementedException | ExecutionException + | TimeoutException e) { + logger.warn("Error during action set charge mode.", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); } @@ -196,7 +204,11 @@ public class RenaultHandler extends BaseThingHandler { return; } } + break; default: + if (command instanceof RefreshType) { + reschedulePollingJob(); + } break; } } @@ -223,16 +235,15 @@ public class RenaultHandler extends BaseThingHandler { logger.warn("Error My Renault Http Session.", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); } - if (httpSession != null) { - String imageURL = car.getImageURL(); - if (imageURL != null && !imageURL.isEmpty()) { - updateState(CHANNEL_IMAGE, new StringType(imageURL)); - } - updateHvacStatus(httpSession); - updateCockpit(httpSession); - updateLocation(httpSession); - updateBattery(httpSession); + String imageURL = car.getImageURL(); + if (imageURL != null && !imageURL.isEmpty()) { + updateState(CHANNEL_IMAGE, new StringType(imageURL)); } + updateHvacStatus(httpSession); + updateCockpit(httpSession); + updateLocation(httpSession); + updateBattery(httpSession); + updateLockStatus(httpSession); } private void updateHvacStatus(MyRenaultHttpSession httpSession) { @@ -253,8 +264,10 @@ public class RenaultHandler extends BaseThingHandler { new QuantityType(externalTemperature.doubleValue(), SIUnits.CELSIUS)); } } catch (RenaultNotImplementedException e) { + logger.warn("Disabling unsupported HVAC status update."); car.setDisableHvac(true); } catch (RenaultForbiddenException | RenaultUpdateException e) { + logger.warn("Error updating HVAC status.", e); } } } @@ -269,13 +282,15 @@ public class RenaultHandler extends BaseThingHandler { updateState(CHANNEL_LOCATION, new PointType(new DecimalType(latitude.doubleValue()), new DecimalType(longitude.doubleValue()))); } - String locationUpdated = car.getLocationUpdated(); + ZonedDateTime locationUpdated = car.getLocationUpdated(); if (locationUpdated != null) { updateState(CHANNEL_LOCATION_UPDATED, new DateTimeType(locationUpdated)); } } catch (RenaultNotImplementedException e) { + logger.warn("Disabling unsupported location update."); car.setDisableLocation(true); } catch (IllegalArgumentException | RenaultForbiddenException | RenaultUpdateException e) { + logger.warn("Error updating location.", e); } } } @@ -289,8 +304,10 @@ public class RenaultHandler extends BaseThingHandler { updateState(CHANNEL_ODOMETER, new QuantityType(odometer.doubleValue(), KILO(METRE))); } } catch (RenaultNotImplementedException e) { + logger.warn("Disabling unsupported cockpit status update."); car.setDisableCockpit(true); } catch (RenaultForbiddenException | RenaultUpdateException e) { + logger.warn("Error updating cockpit status.", e); } } } @@ -320,10 +337,49 @@ public class RenaultHandler extends BaseThingHandler { updateState(CHANNEL_CHARGING_REMAINING_TIME, new QuantityType