[renault] Several improvements related to updating channels/documentation (#20773)

* [renault] Several improvements related to updating channels/documentation

Signed-off-by: Hilbrand Bouwkamp <hilbrand@h72.nl>
This commit is contained in:
Hilbrand Bouwkamp
2026-05-25 20:54:55 +02:00
committed by GitHub
parent a8f5d8ff5c
commit 589e0b3aae
13 changed files with 628 additions and 472 deletions
+73 -19
View File
@@ -17,6 +17,7 @@ No discovery
## Thing Configuration
There is a single thing `car`.
You require your MyRenault credential, locale and VIN for your MyRenault registered car.
| Parameter | Description | Default |
@@ -29,28 +30,29 @@ You require your MyRenault credential, locale and VIN for your MyRenault registe
| 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 |
| gigyaApiKey | Gigya API Key. Configure to override the hard coded value for your region. | Hard coded region value |
## Channels
| Channel ID | Type | Description | Read Only |
|------------------------|--------------------|-------------------------------------------------|-----------|
| 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 |
| pause | Switch | Pause the charge. | No |
| chargingstatus | String | Charging status | Yes |
| chargingremainingtime | Number:Time | Charging time remaining | Yes |
| plugstatus | String | Status of charging plug | Yes |
| estimatedrange | Number:Length | Estimated range of the car | Yes |
| odometer | Number:Length | Total distance travelled | Yes |
| hvacstatus | String | HVAC status HVAC Status (ON, OFF, PENDING) | No |
| hvactargettemperature | Number:Temperature | HVAC target temperature (19 to 21) | No |
| externaltemperature | Number:Temperature | Temperature outside of the car | Yes |
| 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 |
| Channel ID | Type | Description | Read Only |
|------------------------|--------------------|-----------------------------------------------------|-----------|
| 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 |
| pause | Switch | Pause the charge | No |
| chargingstatus | String | Charging status | Yes |
| chargingremainingtime | Number:Time | Charging time remaining | Yes |
| plugstatus | String | Status of charging plug | Yes |
| estimatedrange | Number:Length | Estimated range of the car | Yes |
| odometer | Number:Length | Total distance travelled | Yes |
| hvacstatus | String | HVAC status HVAC Status (ON, OFF, PENDING) | No |
| hvactargettemperature | Number:Temperature | HVAC target temperature (19 to 21) | No |
| externaltemperature | Number:Temperature | Temperature outside of the car | Yes |
| 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
@@ -64,12 +66,64 @@ This seams to only allow values 19, 20 and 21 or else the pre-conditioning comma
The 'pause' and 'chargingmode' may not work on some cars.
As an example, 'chargingmode' does not work on Dacia Spring cars.
The `odometer` may not work on some cars.
The Kamereon API Key changes periodically, which causes a communication error.
To fix this error update the API Key in the bindings configuration.
The new key value can hopefully be found in the renault-api project: [KAMEREON_APIKEY value](https://github.com/hacf-fr/renault-api/blob/main/src/renault_api/const.py) or in the openHAB forums.
## Example
renaultcar.things:
```java
renault:car:renault "Renault" [
accountType="MYRENAULT",
locale="<your locale>",
myRenaultUsername="<my renault username>",
myRenaultPassword="<my renault password>",
kamereonApiKey="YjkKtHmGfaceeuExUDKGxrLZGGvtVS0J",
vin="<your vin>",
gigyaApiKey="",
updateDelay=60,
refreshInterval=10
]
```
renaultcar.items:
```java
Number:Energy RenaultCar_BatteryEnergyAvailable "Battery Energy Available [%.1f %unit%]" <batterylevel> { channel="renault:car:renault:batteryavailableenergy", unit="kWh" }
Number RenaultCar_BatteryLevel "Battery Level [%d %%]" <batterylevel> { channel="renault:car:renault:batterylevel" }
DateTime RenaultCar_BatteryStatusUpdated "Battery Status Updated [%1$tH:%1$tM]" <time> { channel="renault:car:renault:batterystatusupdated" }
String RenaultCar_ChargingMode "Charging Mode [%s]" <switch> { channel="renault:car:renault:chargingmode" }
String RenaultCar_Pause "Pause/Resume Charge [%s]" <switch> { channel="renault:car:renault:pause" }
String RenaultCar_ChargingStatus "Charging Status [%s]" <switch> { channel="renault:car:renault:chargingstatus" }
Number:Time RenaultCar_ChargingTimeRemaining "Charging Time Remaining [%d %unit%]" <time> { channel="renault:car:renault:chargingremainingtime", unit="min" }
String RenaultCar_PlugStatus "Plug Status [%s]" <poweroutlet> { channel="renault:car:renault:plugstatus" }
Number:Length RenaultCar_EstimatedRange "Estimated Range [%d %unit%]" { channel="renault:car:renault:estimatedrange", unit="km" }
Number:Length RenaultCar_Odometer "Odometer [%d %unit%]" { channel="renault:car:renault:odometer", unit="km" }
String RenaultCar_HVACStatus "HVAC Status [%s]" <heating> { channel="renault:car:renault:hvacstatus" }
Number:Temperature RenaultCar_HVACTargetTemperature "HVAC Target Temperature [%.0f %unit%]" <temperature> { channel="renault:car:renault:hvactargettemperature" }
Number:Temperature RenaultCar_ExternalTemperature "External Temperature [%.1f %unit%]" <temperature> { channel="renault:car:renault:externaltemperature" }
String RenaultCar_ImageURL "Car Image URL [%s]" <image> { channel="renault:car:renault:image" }
Location RenaultCar_Location "Location Car" <zoom> { channel="renault:car:renault:location" }
DateTime RenaultCar_LocationUpdate "Location Updated [%1$tH:%1$tM]" <time> { channel="renault:car:renault:locationupdated" }
Switch RenaultCar_Locked "Locked [%s]" <lock> { channel="renault:car:renault:locked" }
// ------------------------------------------------------------
// Additional Charge limit via Dimmer-item
// Use together with the ChargeRenaultCarLimit rule
// (see binding-documentation for rule code)
// ------------------------------------------------------------
// Maximum load percentage (e.g. 80 is 80%)
Dimmer RenaultCar_ChargeLimit "Charge Limit [%d %%]" <batterylevel>
```
renaultcar.sitemap:
```perl
@@ -12,6 +12,8 @@
*/
package org.openhab.binding.renault.internal;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
@@ -47,4 +49,14 @@ public class RenaultBindingConstants {
public static final String CHANNEL_LOCKED = "locked";
public static final String CHANNEL_ODOMETER = "odometer";
public static final String CHANNEL_PLUG_STATUS = "plugstatus";
public static final List<String> HVAC_CHANNELS = List.of(CHANNEL_HVAC_STATUS, CHANNEL_HVAC_TARGET_TEMPERATURE);
public static final List<String> BATTERY_CHANNELS = List.of(CHANNEL_BATTERY_AVAILABLE_ENERGY, CHANNEL_BATTERY_LEVEL,
CHANNEL_BATTERY_STATUS_UPDATED, CHANNEL_CHARGING_MODE, CHANNEL_CHARGING_STATUS,
CHANNEL_CHARGING_REMAINING_TIME);
public static final List<String> ALL_CHANNELS = List.of(CHANNEL_IMAGE, CHANNEL_LOCATION, CHANNEL_LOCATION_UPDATED,
CHANNEL_ODOMETER, CHANNEL_LOCKED, CHANNEL_HVAC_STATUS, CHANNEL_HVAC_TARGET_TEMPERATURE,
CHANNEL_EXTERNAL_TEMPERATURE, CHANNEL_PLUG_STATUS, CHANNEL_CHARGING_STATUS, CHANNEL_BATTERY_LEVEL,
CHANNEL_ESTIMATED_RANGE, CHANNEL_BATTERY_AVAILABLE_ENERGY, CHANNEL_CHARGING_REMAINING_TIME,
CHANNEL_BATTERY_STATUS_UPDATED, CHANNEL_CHARGING_MODE);
}
@@ -30,4 +30,5 @@ public class RenaultConfiguration {
public int refreshInterval = 10;
public int updateDelay = 30;
public String kamereonApiKey = "YjkKtHmGfaceeuExUDKGxrLZGGvtVS0J";
public String gigyaApiKey = "";
}
@@ -39,15 +39,16 @@ public class Car {
private final Logger logger = LoggerFactory.getLogger(Car.class);
private boolean disableVehicle = false;
private boolean disableLocation = false;
private boolean disableBattery = false;
private boolean disableChargeMode = false;
private boolean disableCockpit = false;
private boolean disableHvac = false;
private boolean disableLockStatus = false;
private boolean pausemode = false;
private ChargingStatus chargingStatus = ChargingStatus.UNKNOWN;
private ChargingMode chargingMode = ChargingMode.UNKNOWN;
private @Nullable ChargingMode chargingMode;
private PlugStatus plugStatus = PlugStatus.UNKNOWN;
private double hvacTargetTemperature = 20.0;
private @Nullable Double batteryLevel;
@@ -65,7 +66,6 @@ public class Car {
private @Nullable Double externalTemperature;
public enum ChargingMode {
UNKNOWN,
SCHEDULE_MODE,
ALWAYS_CHARGING
}
@@ -132,6 +132,10 @@ public class Car {
}
}
public void setChargeMode(ChargingMode chargeMode) {
this.chargingMode = chargeMode;
}
public void resetHVACStatus() {
this.hvacstatus = null;
}
@@ -230,6 +234,10 @@ public class Car {
}
}
public boolean isDisableVehicle() {
return disableVehicle;
}
public boolean isDisableLocation() {
return disableLocation;
}
@@ -294,14 +302,10 @@ public class Car {
return chargingStatus;
}
public ChargingMode getChargingMode() {
public @Nullable ChargingMode getChargingMode() {
return chargingMode;
}
public boolean getPauseMode() {
return pausemode;
}
public @Nullable Integer getChargingRemainingTime() {
return chargingRemainingTime;
}
@@ -318,6 +322,10 @@ public class Car {
this.hvacTargetTemperature = hvacTargetTemperature;
}
public void setDisableVehicle(boolean disableVehicle) {
this.disableVehicle = disableVehicle;
}
public void setDisableLocation(boolean disableLocation) {
this.disableLocation = disableLocation;
}
@@ -334,26 +342,6 @@ public class Car {
this.disableHvac = disableHvac;
}
/**
* Set the charging mode to a known mode.
*
* @param mode
*/
public void setChargeMode(ChargingMode mode) {
switch (mode) {
case SCHEDULE_MODE:
case ALWAYS_CHARGING:
chargingMode = mode;
break;
default:
break;
}
}
public void setPauseMode(boolean pausemode) {
this.pausemode = pausemode;
}
private @Nullable JsonObject getAttributes(JsonObject responseJson)
throws IllegalStateException, ClassCastException {
if (responseJson.get("data") != null && responseJson.get("data").getAsJsonObject().get("attributes") != null) {
@@ -363,54 +351,37 @@ public class Car {
}
private LockStatus mapLockStatus(final String apiLockStatus) {
switch (apiLockStatus) {
case "locked":
return LockStatus.LOCKED;
case "unlocked":
return LockStatus.UNLOCKED;
default:
return LockStatus.UNKNOWN;
}
return switch (apiLockStatus) {
case "locked" -> LockStatus.LOCKED;
case "unlocked" -> LockStatus.UNLOCKED;
default -> 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) {
case "0":
return PlugStatus.UNPLUGGED;
case "1":
return PlugStatus.PLUGGED;
case "-1":
return PlugStatus.PLUG_ERROR;
case "-2147483648":
return PlugStatus.PLUG_UNKNOWN;
default:
return PlugStatus.UNKNOWN;
}
return switch (apiPlugState) {
case "0" -> PlugStatus.UNPLUGGED;
case "1" -> PlugStatus.PLUGGED;
case "-1" -> PlugStatus.PLUG_ERROR;
case "-2147483648" -> PlugStatus.PLUG_UNKNOWN;
default -> PlugStatus.UNKNOWN;
};
}
private ChargingStatus mapChargingStatus(final String apiChargeState) {
// https://github.com/hacf-fr/renault-api/blob/main/src/renault_api/kamereon/enums.py
switch (apiChargeState) {
case "0.0":
return ChargingStatus.NOT_IN_CHARGE;
case "0.1":
return ChargingStatus.WAITING_FOR_A_PLANNED_CHARGE;
case "0.2":
return ChargingStatus.CHARGE_ENDED;
case "0.3":
return ChargingStatus.WAITING_FOR_CURRENT_CHARGE;
case "0.4":
return ChargingStatus.ENERGY_FLAP_OPENED;
case "1.0":
return ChargingStatus.CHARGE_IN_PROGRESS;
case "-1.0":
return ChargingStatus.CHARGE_ERROR;
case "-1.1":
return ChargingStatus.UNAVAILABLE;
default:
return ChargingStatus.UNKNOWN;
}
return switch (apiChargeState) {
case "0.0" -> ChargingStatus.NOT_IN_CHARGE;
case "0.1" -> ChargingStatus.WAITING_FOR_A_PLANNED_CHARGE;
case "0.2" -> ChargingStatus.CHARGE_ENDED;
case "0.3" -> ChargingStatus.WAITING_FOR_CURRENT_CHARGE;
case "0.4" -> ChargingStatus.ENERGY_FLAP_OPENED;
case "1.0" -> ChargingStatus.CHARGE_IN_PROGRESS;
case "-1.0" -> ChargingStatus.CHARGE_ERROR;
case "-1.1" -> ChargingStatus.UNAVAILABLE;
default -> ChargingStatus.UNKNOWN;
};
}
public LockStatus getLockStatus() {
@@ -424,4 +395,12 @@ public class Car {
public void setDisableLockStatus(boolean disableLockStatus) {
this.disableLockStatus = disableLockStatus;
}
public boolean isDisableChargeMode() {
return disableChargeMode;
}
public void setDisableChargeMode(boolean disableChargeMode) {
this.disableChargeMode = disableChargeMode;
}
}
@@ -58,7 +58,7 @@ public class Constants {
break;
case "de_AT":
gigyaRootUrl = GIGYA_URL_EU;
gigyaApiKey = "3__B4KghyeUb0GlpU62ZXKrjSfb7CPzwBS368wioftJUL5qXE0Z_sSy0rX69klXuHy";
gigyaApiKey = GIGYA_KEY_EU;
kamereonRootUrl = KAMEREON_URL_EU;
break;
case "de_CH":
@@ -12,6 +12,9 @@
*/
package org.openhab.binding.renault.internal.api;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -28,11 +31,11 @@ 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.RenaultAPIGatewayException;
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.cache.ExpiringCache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -51,80 +54,84 @@ import com.google.gson.JsonParser;
@NonNullByDefault
public class MyRenaultHttpSession {
private static final String CHARGING_MODE_SCHEDULE = "schedule_mode";
private static final String CHARGING_MODE_ALWAYS = "always_charging";
private static final int REQUEST_TIMEOUT_MS = 10_000;
private static final String NOT_BE_THERE = "you should not be there but well done for the effort";
private final Logger logger = LoggerFactory.getLogger(MyRenaultHttpSession.class);
// Use a expiring cache to not login again if initSession is called within 3
// seconds of the previous call.
private final ExpiringCache<Boolean> initCache = new ExpiringCache<>(Duration.ofSeconds(3), () -> true);
private final Object lock = new Object();
private RenaultConfiguration config;
private HttpClient httpClient;
private Constants constants;
private @Nullable String kamereonToken;
private @Nullable String kamereonaccountId;
private @Nullable String cookieValue;
private @Nullable String personId;
private @Nullable String gigyaDataCenter;
private @Nullable String jwt;
private final Logger logger = LoggerFactory.getLogger(MyRenaultHttpSession.class);
public MyRenaultHttpSession(RenaultConfiguration config, HttpClient httpClient) {
this.config = config;
this.httpClient = httpClient;
this.constants = new Constants(config.locale);
}
public void initSesssion(Car car) throws RenaultException, RenaultForbiddenException, RenaultUpdateException,
RenaultNotImplementedException, InterruptedException, ExecutionException, TimeoutException {
login();
getAccountInfo();
getJWT();
getAccountID();
final String imageURL = car.getImageURL();
if (imageURL == null) {
getVehicle(car);
public void initSesssion() throws RenaultException, InterruptedException, ExecutionException, TimeoutException {
synchronized (lock) {
if (initCache.isExpired()) {
login();
getAccountInfo();
getJWT();
getAccountID();
initCache.refreshValue();
}
}
}
private void login() throws RenaultException, InterruptedException, ExecutionException, TimeoutException {
Fields fields = new Fields();
fields.add("ApiKey", this.constants.getGigyaApiKey());
final Fields fields = new Fields();
fields.add("ApiKey", getGigyaApiKey());
fields.add("loginID", config.myRenaultUsername);
fields.add("password", config.myRenaultPassword);
final String url = this.constants.getGigyaRootUrl() + "/accounts.login";
ContentResponse response = httpClient.FORM(url, fields);
final 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");
if (sessionInfoJson != null) {
JsonElement element = sessionInfoJson.get("cookieValue");
if (element != null) {
cookieValue = element.getAsString();
logger.debug("Cookie: {}", cookieValue);
}
final JsonObject responseJson = JsonParser.parseString(response.getContentAsString()).getAsJsonObject();
final JsonObject sessionInfoJson = responseJson.getAsJsonObject("sessionInfo");
if (sessionInfoJson == null) {
throw new RenaultException("@text/error.renault.session.login.no_session_info");
}
JsonElement element = sessionInfoJson.get("cookieValue");
if (element == null) {
throw new RenaultException("@text/error.renault.session.login.no_cookie");
}
cookieValue = element.getAsString();
logger.debug("Cookie: {}", cookieValue);
} catch (JsonParseException | ClassCastException | IllegalStateException e) {
throw new RenaultException("Login Error: cookie value not found in JSON response");
throw new RenaultException("@text/error.renault.session.login.no_cookie");
}
if (cookieValue == null) {
logger.warn("Login Error: cookie value not found! Response: {}", response.getContentAsString());
throw new RenaultException("Login Error: cookie value not found in JSON response");
throw new RenaultException("@text/error.renault.session.login.no_cookie");
}
} else {
logger.warn("GigyaApi Request: {} Response: [{}] {}\n{}", url, response.getStatus(), response.getReason(),
response.getContentAsString());
throw new RenaultException("Login Error: " + response.getReason());
throw new RenaultException(
String.format("@text/error.renault.session.login.error[\"%s\"]", response.getReason()));
}
}
private void getAccountInfo() throws RenaultException, InterruptedException, ExecutionException, TimeoutException {
Fields fields = new Fields();
fields.add("ApiKey", this.constants.getGigyaApiKey());
fields.add("ApiKey", getGigyaApiKey());
fields.add("login_token", cookieValue);
final String url = this.constants.getGigyaRootUrl() + "/accounts.getAccountInfo";
ContentResponse response = httpClient.FORM(url, fields);
@@ -146,50 +153,64 @@ public class MyRenaultHttpSession {
}
}
} catch (JsonParseException | ClassCastException | IllegalStateException e) {
throw new RenaultException(
"Get Account Info Error: personId or gigyaDataCenter value not found in JSON response");
throw new RenaultException("@text/error.renault.session.gigya.no_data");
}
} else {
logger.warn("GigyaApi Request: {} Response: [{}] {}\n{}", url, response.getStatus(), response.getReason(),
response.getContentAsString());
throw new RenaultException("Get Account Info Error: " + response.getReason());
throw new RenaultException(
String.format("@text/error.renault.session.gigya.error[\"%s\"]", response.getReason()));
}
}
/**
* Return the gigyaApiKey from configuration if it is used to override the
* default hard-coded constant.
*
* @return
*/
private String getGigyaApiKey() {
if (!this.config.gigyaApiKey.isBlank()) {
return this.config.gigyaApiKey;
}
return this.constants.getGigyaApiKey();
}
private void getJWT() throws RenaultException, InterruptedException, ExecutionException, TimeoutException {
Fields fields = new Fields();
fields.add("ApiKey", this.constants.getGigyaApiKey());
final Fields fields = new Fields();
fields.add("ApiKey", getGigyaApiKey());
fields.add("login_token", cookieValue);
fields.add("fields", "data.personId,data.gigyaDataCenter");
fields.add("personId", personId);
fields.add("gigyaDataCenter", gigyaDataCenter);
final String url = this.constants.getGigyaRootUrl() + "/accounts.getJWT";
ContentResponse response = this.httpClient.FORM(url, fields);
final 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");
final JsonObject responseJson = JsonParser.parseString(response.getContentAsString()).getAsJsonObject();
logger.trace("GigyaApi response: {}", responseJson);
final JsonElement element = responseJson.get("id_token");
if (element != null) {
jwt = element.getAsString();
logger.debug("GigyaApi jwt: {} ", jwt);
}
} catch (JsonParseException | ClassCastException | IllegalStateException e) {
throw new RenaultException("Get JWT Error: jwt value not found in JSON response");
throw new RenaultException("@text/error.renault.session.jwt.no_jwt");
}
} else {
logger.warn("GigyaApi Request: {} Response: [{}] {}\n{}", url, response.getStatus(), response.getReason(),
response.getContentAsString());
throw new RenaultException("Get JWT Error: " + response.getReason());
throw new RenaultException(
String.format("@text/error.renault.session.jwt.error[\"%s\"]", response.getReason()));
}
}
private void getAccountID()
throws RenaultException, RenaultForbiddenException, RenaultUpdateException, RenaultNotImplementedException {
JsonObject responseJson = getKamereonResponse(
private void getAccountID() throws RenaultException {
final JsonObject responseJson = getKamereonResponse(
"/commerce/v1/persons/" + personId + "?country=" + getCountry(config));
if (responseJson != null) {
JsonArray accounts = responseJson.getAsJsonArray("accounts");
@@ -201,84 +222,69 @@ public class MyRenaultHttpSession {
}
}
if (kamereonaccountId == null) {
throw new RenaultException("Can not get Kamereon " + config.accountType + " Account ID!");
throw new RenaultException(String.format("@text/error.renault.session.kamereon_cant_get_account_id[\"%s\"]",
config.accountType));
}
}
public void getVehicle(Car car) throws RenaultForbiddenException, RenaultUpdateException,
RenaultNotImplementedException, RenaultAPIGatewayException {
JsonObject responseJson = getKamereonResponse("/commerce/v1/accounts/" + kamereonaccountId + "/vehicles/"
public void getVehicle(Car car) throws RenaultException {
final JsonObject responseJson = getKamereonResponse("/commerce/v1/accounts/" + kamereonaccountId + "/vehicles/"
+ config.vin + "/details?country=" + getCountry(config));
if (responseJson != null) {
car.setDetails(responseJson);
}
}
public void getBatteryStatus(Car car) throws RenaultForbiddenException, RenaultUpdateException,
RenaultNotImplementedException, RenaultAPIGatewayException {
JsonObject responseJson = getKamereonResponse("/commerce/v1/accounts/" + kamereonaccountId
+ "/kamereon/kca/car-adapter/v2/cars/" + config.vin + "/battery-status?country=" + getCountry(config));
public void getBatteryStatus(Car car) throws RenaultException {
final JsonObject responseJson = getKamereonResponse(kcav2() + "battery-status?country=" + getCountry(config));
if (responseJson != null) {
car.setBatteryStatus(responseJson);
}
}
public void getHvacStatus(Car car) throws RenaultForbiddenException, RenaultUpdateException,
RenaultNotImplementedException, RenaultAPIGatewayException {
JsonObject responseJson = getKamereonResponse("/commerce/v1/accounts/" + kamereonaccountId
+ "/kamereon/kca/car-adapter/v1/cars/" + config.vin + "/hvac-status?country=" + getCountry(config));
public void getHvacStatus(Car car) throws RenaultException {
final JsonObject responseJson = getKamereonResponse(kcav1() + "hvac-status?country=" + getCountry(config));
if (responseJson != null) {
car.setHVACStatus(responseJson);
}
}
public void getCockpit(Car car) throws RenaultForbiddenException, RenaultUpdateException,
RenaultNotImplementedException, RenaultAPIGatewayException {
JsonObject responseJson = getKamereonResponse("/commerce/v1/accounts/" + kamereonaccountId
+ "/kamereon/kca/car-adapter/v1/cars/" + config.vin + "/cockpit?country=" + getCountry(config));
public void getCockpit(Car car) throws RenaultException {
final JsonObject responseJson = getKamereonResponse(kcav1() + "cockpit?country=" + getCountry(config));
if (responseJson != null) {
car.setCockpit(responseJson);
}
}
public void getLocation(Car car) throws RenaultForbiddenException, RenaultUpdateException,
RenaultNotImplementedException, RenaultAPIGatewayException {
JsonObject responseJson = getKamereonResponse("/commerce/v1/accounts/" + kamereonaccountId
+ "/kamereon/kca/car-adapter/v1/cars/" + config.vin + "/location?country=" + getCountry(config));
public void getLocation(Car car) throws RenaultException {
final JsonObject responseJson = getKamereonResponse(kcav1() + "location?country=" + getCountry(config));
if (responseJson != null) {
car.setLocation(responseJson);
}
}
public void getLockStatus(Car car) throws RenaultForbiddenException, RenaultUpdateException,
RenaultNotImplementedException, RenaultAPIGatewayException {
JsonObject responseJson = getKamereonResponse("/commerce/v1/accounts/" + kamereonaccountId
+ "/kamereon/kca/car-adapter/v1/cars/" + config.vin + "/lock-status?country=" + getCountry(config));
public void getLockStatus(Car car) throws RenaultException {
final JsonObject responseJson = getKamereonResponse(kcav1() + "lock-status?country=" + getCountry(config));
if (responseJson != null) {
car.setLockStatus(responseJson);
}
}
public void actionHvacOn(double hvacTargetTemperature) throws RenaultForbiddenException,
RenaultNotImplementedException, RenaultActionException, RenaultAPIGatewayException {
final String path = "/commerce/v1/accounts/" + kamereonaccountId + "/kamereon/kca/car-adapter/v1/cars/"
+ config.vin + "/actions/hvac-start?country=" + getCountry(config);
public void actionHvacOn(double hvacTargetTemperature) throws RenaultException {
final String path = kcav1() + "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, RenaultAPIGatewayException {
final String apiMode = ChargingMode.SCHEDULE_MODE.equals(mode) ? CHARGING_MODE_SCHEDULE : CHARGING_MODE_ALWAYS;
final String path = "/commerce/v1/accounts/" + kamereonaccountId + "/kamereon/kca/car-adapter/v1/cars/"
+ config.vin + "/actions/charge-mode?country=" + getCountry(config);
public void actionChargeMode(ChargingMode mode) throws RenaultException {
final String apiMode = mode.name().toLowerCase(Locale.ROOT);
final String path = kcav1() + "actions/charge-mode?country=" + getCountry(config);
postKamereonRequest(path,
"{\"data\":{\"type\":\"ChargeMode\",\"attributes\":{\"action\":\"" + apiMode + "\"}}}");
}
public void actionPause(boolean mode) throws RenaultForbiddenException, RenaultNotImplementedException,
RenaultActionException, RenaultAPIGatewayException {
public void actionPause(boolean mode) throws RenaultException {
final String apiMode = mode ? "pause" : "resume";
final String path = "/commerce/v1/accounts/" + kamereonaccountId + "/kamereon/kcm/v1/vehicles/" + config.vin
+ "/charge/pause-resume?country=" + getCountry(config);
@@ -286,36 +292,28 @@ public class MyRenaultHttpSession {
"{\"data\":{\"type\":\"ChargePauseResume\",\"attributes\":{\"action\":\"" + apiMode + "\"}}}");
}
private void postKamereonRequest(final String path, final String content) throws RenaultForbiddenException,
RenaultNotImplementedException, RenaultActionException, RenaultAPIGatewayException {
Request request = httpClient.newRequest(this.constants.getKamereonRootUrl() + path).method(HttpMethod.POST)
.timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).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();
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) {
throw new RenaultActionException(e.toString());
}
private void postKamereonRequest(final String path, final String content) throws RenaultException {
requestKamereonResponse(HttpMethod.POST, path, new StringContentProvider(content, "utf-8"));
}
private @Nullable JsonObject getKamereonResponse(String path) throws RenaultForbiddenException,
RenaultNotImplementedException, RenaultUpdateException, RenaultAPIGatewayException {
Request request = httpClient.newRequest(this.constants.getKamereonRootUrl() + path).method(HttpMethod.GET)
private @Nullable JsonObject getKamereonResponse(String path) throws RenaultException {
return requestKamereonResponse(HttpMethod.GET, path, null);
}
private @Nullable JsonObject requestKamereonResponse(HttpMethod httpMethod, String path,
@Nullable StringContentProvider content) throws RenaultException {
Request request = httpClient.newRequest(this.constants.getKamereonRootUrl() + path).method(httpMethod)
.timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).header("Content-type", "application/vnd.api+json")
.header("apikey", this.config.kamereonApiKey)
.header("x-kamereon-authorization", "Bearer " + kamereonToken).header("x-gigya-id_token", jwt);
.header("apikey", this.config.kamereonApiKey).header("x-gigya-id_token", jwt).content(content);
try {
ContentResponse response = request.send();
logKamereonCall(request, response);
if (HttpStatus.OK_200 == response.getStatus()) {
return JsonParser.parseString(response.getContentAsString()).getAsJsonObject();
if (httpMethod == HttpMethod.GET) {
if (HttpStatus.OK_200 == response.getStatus()) {
final JsonObject json = JsonParser.parseString(response.getContentAsString()).getAsJsonObject();
checkNotSupported(json);
return json;
}
}
checkResponse(response);
} catch (InterruptedException e) {
@@ -327,6 +325,24 @@ public class MyRenaultHttpSession {
return null;
}
private String kcav1() {
return String.format("/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v1/cars/%s/", kamereonaccountId,
config.vin);
}
private String kcav2() {
return String.format("/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v2/cars/%s/", kamereonaccountId,
config.vin);
}
private void checkNotSupported(JsonObject json) throws RenaultNotImplementedException {
if (Optional.ofNullable(json.get("message")).map(JsonElement::getAsString).filter(NOT_BE_THERE::equals)
.isPresent()) {
logger.debug("Kamereon response indicates unsupported operation: {}", NOT_BE_THERE);
throw new RenaultNotImplementedException("@text/error.renault.session.kamereon_request_not_implemented");
}
}
private void logKamereonCall(Request request, ContentResponse response) {
if (HttpStatus.OK_200 == response.getStatus()) {
if (logger.isTraceEnabled()) {
@@ -343,14 +359,22 @@ public class MyRenaultHttpSession {
throws RenaultForbiddenException, RenaultNotImplementedException, RenaultAPIGatewayException {
switch (response.getStatus()) {
case HttpStatus.FORBIDDEN_403:
throw new RenaultForbiddenException(
"Kamereon request forbidden! Ensure the car is paired in your MyRenault App.");
try {
final JsonObject json = JsonParser.parseString(response.getContentAsString()).getAsJsonObject();
if ("err.func.privacy.on".equals(getErrorCode(json))) {
throw new RenaultForbiddenException("@text/error.renault.session.kamereon_privacy_on");
}
} catch (JsonParseException e) {
logger.debug("Could not parse 403 message: {}", response.getContentAsString());
}
throw new RenaultForbiddenException("@text/error.renault.session.kamereon_request_forbidden");
case HttpStatus.NOT_FOUND_404:
throw new RenaultNotImplementedException("Kamereon service not found");
throw new RenaultNotImplementedException("@text/error.renault.session.kamereon_service_not_found");
case HttpStatus.NOT_IMPLEMENTED_501:
throw new RenaultNotImplementedException("Kamereon request not implemented");
throw new RenaultNotImplementedException(
"@text/error.renault.session.kamereon_request_not_implemented");
case HttpStatus.BAD_GATEWAY_502:
throw new RenaultAPIGatewayException("Kamereon request failed");
throw new RenaultAPIGatewayException("@text/error.renault.session.kamereon_request_failed");
default:
break;
}
@@ -363,4 +387,18 @@ public class MyRenaultHttpSession {
}
return country;
}
private static String getErrorCode(JsonObject responseJson) {
// @formatter:off
final @Nullable String errorCode = Optional.ofNullable(responseJson.get("messages"))
.map(m -> m.getAsJsonArray())
.map(m -> m.asList())
.map(m -> m.get(0))
.map(m -> m.getAsJsonObject())
.map(m -> m.get("code"))
.map(m -> m.getAsString())
.orElse("");
// @formatter:on
return errorCode == null ? "" : errorCode;
}
}
@@ -20,7 +20,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Doug Culnane - Initial contribution
*/
@NonNullByDefault
public class RenaultActionException extends Exception {
public class RenaultActionException extends RenaultException {
private static final long serialVersionUID = 1L;
@@ -21,7 +21,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Doug Culnane - Initial contribution
*/
@NonNullByDefault
public class RenaultForbiddenException extends Exception {
public class RenaultForbiddenException extends RenaultException {
private static final long serialVersionUID = 1L;
@@ -21,7 +21,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Doug Culnane - Initial contribution
*/
@NonNullByDefault
public class RenaultNotImplementedException extends Exception {
public class RenaultNotImplementedException extends RenaultException {
private static final long serialVersionUID = 1L;
@@ -20,7 +20,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Doug Culnane - Initial contribution
*/
@NonNullByDefault
public class RenaultUpdateException extends Exception {
public class RenaultUpdateException extends RenaultException {
private static final long serialVersionUID = 1L;
@@ -15,15 +15,20 @@ package org.openhab.binding.renault.internal.handler;
import static org.openhab.binding.renault.internal.RenaultBindingConstants.*;
import static org.openhab.core.library.unit.MetricPrefix.KILO;
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 static org.openhab.core.library.unit.Units.*;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Function;
import java.util.function.Supplier;
import javax.measure.Quantity;
import javax.measure.Unit;
import javax.measure.quantity.Temperature;
import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -33,13 +38,11 @@ import org.openhab.binding.renault.internal.RenaultBindingConstants;
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.Car.LockStatus;
import org.openhab.binding.renault.internal.api.MyRenaultHttpSession;
import org.openhab.binding.renault.internal.api.exceptions.RenaultAPIGatewayException;
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.cache.ExpiringCache;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
@@ -54,6 +57,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.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -67,178 +71,160 @@ import org.slf4j.LoggerFactory;
@NonNullByDefault
public class RenaultHandler extends BaseThingHandler {
private interface RenaultRunner {
void run() throws RenaultException, InterruptedException, ExecutionException, TimeoutException;
}
private interface RenaultConsumer<T> {
void accept(T value) throws RenaultException, InterruptedException, ExecutionException, TimeoutException;
}
private static final Duration CACHE_INVALIDATION_TIMEOUT_SECONDS = Duration.ofSeconds(10);
private final Logger logger = LoggerFactory.getLogger(RenaultHandler.class);
private final ExpireCarCache carCache = new ExpireCarCache(this::refreshCar);
private final Object lockObject = new Object();
private final HttpClient httpClient;
private RenaultConfiguration config = new RenaultConfiguration();
private @Nullable ScheduledFuture<?> pollingJob;
private HttpClient httpClient;
private Car car;
private @Nullable MyRenaultHttpSession httpSession;
public RenaultHandler(Thing thing, HttpClient httpClient) {
super(thing);
this.car = new Car();
this.httpClient = httpClient;
}
@Override
public void initialize() {
// reset the car on initialize
this.car = new Car();
this.config = getConfigAs(RenaultConfiguration.class);
// Validate configuration
if (this.config.myRenaultUsername.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "MyRenault Username is empty!");
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/thing-type.config.renault.car.error.username_empty");
return;
}
if (this.config.myRenaultPassword.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "MyRenault Password is empty!");
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/thing-type.config.renault.car.error.password_empty");
return;
}
if (this.config.locale.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Location is empty!");
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/thing-type.config.renault.car.error.location_empty");
return;
}
if (this.config.vin.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "VIN is empty!");
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/thing-type.config.renault.car.error.vin_empty");
return;
}
if (this.config.refreshInterval < 1) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"The refresh interval mush to be larger than 1");
"@text/thing-type.config.renault.car.error.refresh_interval_to_low");
return;
}
updateStatus(ThingStatus.UNKNOWN);
updateState(CHANNEL_HVAC_TARGET_TEMPERATURE,
new QuantityType<>(car.getHvacTargetTemperature(), SIUnits.CELSIUS));
this.httpSession = new MyRenaultHttpSession(this.config, httpClient);
reschedulePollingJob();
}
private void reschedulePollingJob() {
synchronized (lockObject) {
final ScheduledFuture<?> job = pollingJob;
if (job != null) {
job.cancel(true);
}
carCache.reset();
pollingJob = scheduler.scheduleWithFixedDelay(carCache::getCar, 0, config.refreshInterval,
TimeUnit.MINUTES);
}
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
switch (channelUID.getId()) {
case RenaultBindingConstants.CHANNEL_HVAC_TARGET_TEMPERATURE:
if (!car.isDisableHvac()) {
if (command instanceof RefreshType) {
updateState(CHANNEL_HVAC_TARGET_TEMPERATURE,
new QuantityType<>(car.getHvacTargetTemperature(), SIUnits.CELSIUS));
} else if (command instanceof DecimalType decimalCommand) {
car.setHvacTargetTemperature(decimalCommand.doubleValue());
updateState(CHANNEL_HVAC_TARGET_TEMPERATURE,
new QuantityType<>(car.getHvacTargetTemperature(), SIUnits.CELSIUS));
} else if (command instanceof QuantityType) {
@Nullable
QuantityType<Temperature> celsius = ((QuantityType<Temperature>) command)
.toUnit(SIUnits.CELSIUS);
if (celsius != null) {
car.setHvacTargetTemperature(celsius.doubleValue());
}
updateState(CHANNEL_HVAC_TARGET_TEMPERATURE,
new QuantityType<>(car.getHvacTargetTemperature(), SIUnits.CELSIUS));
}
}
break;
case RenaultBindingConstants.CHANNEL_HVAC_STATUS:
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());
final MyRenaultHttpSession httpSession = this.httpSession;
if (httpSession == null) {
return;
}
synchronized (lockObject) {
final Car car = carCache.getCar();
if (command instanceof RefreshType) {
updateChannel(channelUID.getId(), car);
return;
}
switch (channelUID.getId()) {
case RenaultBindingConstants.CHANNEL_HVAC_TARGET_TEMPERATURE:
if (!car.isDisableHvac()) {
if (command instanceof DecimalType decimalCommand) {
car.setHvacTargetTemperature(decimalCommand.doubleValue());
} else if (command instanceof QuantityType) {
Optional.ofNullable(((QuantityType<Temperature>) command).toUnit(SIUnits.CELSIUS))
.ifPresent(celsius -> car.setHvacTargetTemperature(celsius.doubleValue()));
}
}
}
break;
case RenaultBindingConstants.CHANNEL_CHARGING_MODE:
if (command instanceof RefreshType) {
reschedulePollingJob();
} else if (command instanceof StringType) {
try {
ChargingMode newMode = ChargingMode.valueOf(command.toString());
if (!ChargingMode.UNKNOWN.equals(newMode)) {
MyRenaultHttpSession httpSession = new MyRenaultHttpSession(this.config, httpClient);
try {
httpSession.initSesssion(car);
httpSession.actionChargeMode(newMode);
car.setChargeMode(newMode);
updateState(CHANNEL_CHARGING_MODE, new StringType(newMode.toString()));
} 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 set charge mode.", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
e.getMessage());
break;
case RenaultBindingConstants.CHANNEL_HVAC_STATUS:
if (!car.isDisableHvac()) {
if (command instanceof StringType && command.toString().equals(Car.HVAC_STATUS_ON)) {
// We can only trigger pre-conditioning of the car.
if (run(() -> {
updateState(CHANNEL_HVAC_STATUS, new StringType(Car.HVAC_STATUS_PENDING));
car.resetHVACStatus();
httpSession.initSesssion();
httpSession.actionHvacOn(car.getHvacTargetTemperature());
}, "Error during action HVAC on.", true)) {
refreshHvac(car);
}
}
} catch (IllegalArgumentException e) {
logger.warn("Invalid ChargingMode {}.", command.toString());
return;
}
}
break;
case RenaultBindingConstants.CHANNEL_PAUSE:
if (command instanceof RefreshType) {
reschedulePollingJob();
} else if (command instanceof OnOffType) {
try {
MyRenaultHttpSession httpSession = new MyRenaultHttpSession(this.config, httpClient);
break;
case RenaultBindingConstants.CHANNEL_CHARGING_MODE:
if (command instanceof StringType) {
try {
boolean pause = OnOffType.ON == command;
httpSession.initSesssion(car);
httpSession.actionPause(pause);
car.setPauseMode(pause);
updateState(CHANNEL_PAUSE, OnOffType.from(command.toString()));
} catch (InterruptedException e) {
logger.warn("Error My Renault Http Session.", e);
Thread.currentThread().interrupt();
} catch (RenaultForbiddenException | RenaultNotImplementedException | RenaultActionException
| RenaultException | RenaultUpdateException | ExecutionException | TimeoutException e) {
logger.warn("Error during action set pause.", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
final ChargingMode newMode = ChargingMode.valueOf(command.toString());
if ((newMode == ChargingMode.ALWAYS_CHARGING || newMode == ChargingMode.SCHEDULE_MODE)
&& run(() -> {
httpSession.initSesssion();
httpSession.actionChargeMode(newMode);
car.setChargeMode(newMode);
}, "Error during action set charge mode.", true)) {
refreshBattery(car);
}
} catch (IllegalArgumentException e) {
logger.warn("Invalid ChargingMode {}.", command.toString());
return;
}
} catch (IllegalArgumentException e) {
logger.warn("Invalid Pause Mode {}.", command.toString());
return;
}
}
break;
default:
if (command instanceof RefreshType) {
reschedulePollingJob();
}
break;
break;
case RenaultBindingConstants.CHANNEL_PAUSE:
if (command instanceof OnOffType) {
try {
if (run(() -> {
httpSession.initSesssion();
httpSession.actionPause(OnOffType.ON == command);
}, "Error during action set pause.", true)) {
refreshBattery(car);
}
} catch (IllegalArgumentException e) {
logger.warn("Invalid Pause Mode {}.", command.toString());
return;
}
}
break;
}
}
}
@Override
public void dispose() {
ScheduledFuture<?> job = pollingJob;
final ScheduledFuture<?> job = pollingJob;
if (job != null) {
job.cancel(true);
pollingJob = null;
@@ -246,164 +232,220 @@ public class RenaultHandler extends BaseThingHandler {
super.dispose();
}
private void getStatus() {
MyRenaultHttpSession httpSession = new MyRenaultHttpSession(this.config, httpClient);
try {
httpSession.initSesssion(car);
private void refreshBattery(Car car) {
runWithHttpSession(httpSession -> {
updateBattery(httpSession, car);
BATTERY_CHANNELS.forEach(c -> updateChannel(c, car));
}, "Battery");
}
private void refreshHvac(Car car) {
runWithHttpSession(httpSession -> {
updateHvac(httpSession, car);
HVAC_CHANNELS.forEach(c -> updateChannel(c, car));
}, "HVAC.");
}
private Car refreshCar(Car car) {
synchronized (lockObject) {
runWithHttpSession(httpSession -> {
httpSession.initSesssion();
// Only get vehicle image once. Not other data from Vehicle is used.
runIfNotDisabled(car.isDisableVehicle() || car.getImageURL() != null, () -> httpSession.getVehicle(car),
() -> car.setDisableVehicle(true), "imageURL");
updateHvac(httpSession, car);
runIfNotDisabled(car.isDisableLocation(), () -> httpSession.getLocation(car),
() -> car.setDisableLocation(true), "location");
runIfNotDisabled(car.isDisableCockpit(), () -> httpSession.getCockpit(car),
() -> car.setDisableCockpit(true), "cockpit");
updateBattery(httpSession, car);
runIfNotDisabled(car.isDisableLockStatus(), () -> httpSession.getLockStatus(car),
() -> car.setDisableLockStatus(true), "lock");
ALL_CHANNELS.forEach(c -> updateChannel(c, car));
updateStatus(car);
}, "all");
return car;
}
}
private void runWithHttpSession(RenaultConsumer<MyRenaultHttpSession> supplier, String type) {
final MyRenaultHttpSession httpSession = this.httpSession;
if (httpSession == null) {
return;
}
logger.debug("Get Car {} data from cloud service and update channels.", type);
run(() -> supplier.accept(httpSession), "Error My Renault Http Session.", true);
}
private void updateHvac(final MyRenaultHttpSession httpSession, Car car) {
runIfNotDisabled(car.isDisableHvac(), () -> httpSession.getHvacStatus(car), () -> car.setDisableHvac(true),
"HVAC");
}
private void updateBattery(final MyRenaultHttpSession httpSession, Car car) {
runIfNotDisabled(car.isDisableBattery(), () -> httpSession.getBatteryStatus(car),
() -> car.setDisableBattery(true), "battery");
}
private void updateStatus(Car car) {
if (car.getImageURL() == null && car.isDisableHvac() && car.isDisableLocation() && car.isDisableCockpit()
&& car.isDisableBattery() && car.isDisableLockStatus()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/thing-type.renault.car.error.no_data");
} else {
updateStatus(ThingStatus.ONLINE);
}
}
private void updateChannel(String channelId, Car car) {
if (CHANNEL_PAUSE.equals(channelId)) {
return; // channel pause is command channel therefore no state set.
}
final @Nullable State state = switch (channelId) {
case CHANNEL_IMAGE -> stringNotBlank(car.getImageURL());
// ── Location ─────────────────────────────────────────────────────────
case CHANNEL_LOCATION ->
getIfNotDisabled(car.isDisableLocation(), () -> point(car.getGpsLatitude(), car.getGpsLongitude()));
case CHANNEL_LOCATION_UPDATED ->
getIfNotDisabled(car.isDisableLocation(), () -> dateTime(car.getLocationUpdated()));
// ── Cockpit ──────────────────────────────────────────────────────────
case CHANNEL_ODOMETER ->
getIfNotDisabled(car.isDisableCockpit(), () -> quantity(car.getOdometer(), KILO(METRE)));
// ── Lock ──────────────────────────────────────────────────────────
case CHANNEL_LOCKED -> getIfNotDisabled(car.isDisableLockStatus(), () -> lock(car.getLockStatus()));
// ── HVAC ─────────────────────────────────────────────────────────────
case CHANNEL_HVAC_STATUS -> getIfNotDisabled(car.isDisableHvac(), () -> hvacStatus(car.getHvacstatus()));
case CHANNEL_HVAC_TARGET_TEMPERATURE ->
getIfNotDisabled(car.isDisableHvac(), () -> quantity(car.getHvacTargetTemperature(), SIUnits.CELSIUS));
case CHANNEL_EXTERNAL_TEMPERATURE ->
getIfNotDisabled(car.isDisableHvac(), () -> quantity(car.getExternalTemperature(), SIUnits.CELSIUS));
// ── Battery ──────────────────────────────────────────────────────────
case CHANNEL_PLUG_STATUS ->
getIfNotDisabled(car.isDisableBattery(), () -> new StringType(car.getPlugStatus().name()));
case CHANNEL_CHARGING_STATUS ->
getIfNotDisabled(car.isDisableBattery(), () -> new StringType(car.getChargingStatus().name()));
case CHANNEL_BATTERY_LEVEL ->
getIfNotDisabled(car.isDisableBattery(), () -> decimal(car.getBatteryLevel()));
case CHANNEL_ESTIMATED_RANGE ->
getIfNotDisabled(car.isDisableBattery(), () -> quantity(car.getEstimatedRange(), KILO(METRE)));
case CHANNEL_BATTERY_AVAILABLE_ENERGY -> getIfNotDisabled(car.isDisableBattery(),
() -> quantity(car.getBatteryAvailableEnergy(), KILOWATT_HOUR));
case CHANNEL_CHARGING_REMAINING_TIME ->
getIfNotDisabled(car.isDisableBattery(), () -> quantity(car.getChargingRemainingTime(), MINUTE));
case CHANNEL_BATTERY_STATUS_UPDATED ->
getIfNotDisabled(car.isDisableBattery(), () -> dateTime(car.getBatteryStatusUpdated()));
case CHANNEL_CHARGING_MODE ->
getIfNotDisabled(car.isDisableChargeMode(), () -> stringEnum(car.getChargingMode()));
default -> null;
};
if (state == null) {
logger.debug("updateChannel: unhandled channel '{}'", channelId);
}
updateState(channelId, state == null ? UnDefType.NULL : state);
}
private static State getIfNotDisabled(boolean disabled, Supplier<State> supplier) {
return disabled ? UnDefType.UNDEF : supplier.get();
}
private State hvacStatus(@Nullable Boolean hvacStatus) {
final String hvacString;
if (hvacStatus == null) {
hvacString = Car.HVAC_STATUS_PENDING;
} else if (hvacStatus.booleanValue()) {
hvacString = Car.HVAC_STATUS_ON;
} else {
hvacString = Car.HVAC_STATUS_OFF;
}
return new StringType(hvacString);
}
private static State lock(LockStatus lockStatus) {
return switch (lockStatus) {
case LOCKED -> OnOffType.ON;
case UNLOCKED -> OnOffType.OFF;
default -> UnDefType.UNDEF;
};
}
private static State stringNotBlank(@Nullable String value) {
return value == null || value.isBlank() ? UnDefType.UNDEF : new StringType(value);
}
private static State stringEnum(@Nullable Enum<?> value) {
return value == null ? UnDefType.UNDEF : new StringType(value.name());
}
private static State point(@Nullable Double latitude, @Nullable Double longitude) {
return latitude == null || longitude == null ? UnDefType.UNDEF
: new PointType(new DecimalType(latitude.doubleValue()), new DecimalType(longitude.doubleValue()));
}
private static State dateTime(@Nullable ZonedDateTime value) {
return value == null ? UnDefType.UNDEF : new DateTimeType(value);
}
private static <Q extends Quantity<Q>> State quantity(@Nullable Number value, Unit<Q> unit) {
return value == null ? UnDefType.UNDEF : new QuantityType<>(value, unit);
}
private static State decimal(@Nullable Number value) {
return value == null ? UnDefType.UNDEF : new DecimalType(value);
}
private void runIfNotDisabled(boolean disabled, RenaultRunner handler, Runnable disabler, String type) {
if (disabled) {
return;
}
run(() -> {
try {
handler.run();
} catch (RenaultNotImplementedException e) {
logger.debug("Disabling unsupported {} status update", type, e);
disabler.run();
}
}, String.format("Error updating %s status.", type), false);
}
private boolean run(RenaultRunner handler, String logWarnMessage, boolean setOffline) {
try {
handler.run();
return true;
} catch (InterruptedException e) {
logger.warn("Error My Renault Http Session.", e);
Thread.currentThread().interrupt();
} catch (Exception e) {
logger.warn("Error My Renault Http Session.", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
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) {
if (!car.isDisableHvac()) {
try {
httpSession.getHvacStatus(car);
Boolean hvacstatus = car.getHvacstatus();
if (hvacstatus == null) {
updateState(CHANNEL_HVAC_STATUS, new StringType(Car.HVAC_STATUS_PENDING));
} else if (hvacstatus.booleanValue()) {
updateState(CHANNEL_HVAC_STATUS, new StringType(Car.HVAC_STATUS_ON));
} else {
updateState(CHANNEL_HVAC_STATUS, new StringType(Car.HVAC_STATUS_OFF));
}
Double externalTemperature = car.getExternalTemperature();
if (externalTemperature != null) {
updateState(CHANNEL_EXTERNAL_TEMPERATURE,
new QuantityType<>(externalTemperature.doubleValue(), SIUnits.CELSIUS));
}
} catch (RenaultNotImplementedException e) {
logger.warn("Disabling unsupported HVAC status update.");
car.setDisableHvac(true);
} catch (RenaultForbiddenException | RenaultUpdateException | RenaultAPIGatewayException e) {
logger.warn("Error updating HVAC status.", e);
} catch (RenaultException | ExecutionException | TimeoutException e) {
logger.warn("{}", logWarnMessage, e);
if (setOffline) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
}
return false;
}
private void updateLocation(MyRenaultHttpSession httpSession) {
if (!car.isDisableLocation()) {
try {
httpSession.getLocation(car);
Double latitude = car.getGpsLatitude();
Double longitude = car.getGpsLongitude();
if (latitude != null && longitude != null) {
updateState(CHANNEL_LOCATION, new PointType(new DecimalType(latitude.doubleValue()),
new DecimalType(longitude.doubleValue())));
}
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
| RenaultAPIGatewayException e) {
logger.warn("Error updating location.", e);
}
}
}
private static class ExpireCarCache {
private final ExpiringCache<Car> carCache;
private Car car = new Car();
private void updateCockpit(MyRenaultHttpSession httpSession) {
if (!car.isDisableCockpit()) {
try {
httpSession.getCockpit(car);
Double odometer = car.getOdometer();
if (odometer != null) {
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 | RenaultAPIGatewayException e) {
logger.warn("Error updating cockpit status.", e);
}
ExpireCarCache(Function<Car, Car> updateCar) {
carCache = new ExpiringCache<>(CACHE_INVALIDATION_TIMEOUT_SECONDS, () -> updateCar.apply(car));
}
}
private void updateBattery(MyRenaultHttpSession httpSession) {
if (!car.isDisableBattery()) {
try {
httpSession.getBatteryStatus(car);
updateState(CHANNEL_PLUG_STATUS, new StringType(car.getPlugStatus().name()));
updateState(CHANNEL_CHARGING_STATUS, new StringType(car.getChargingStatus().name()));
Double batteryLevel = car.getBatteryLevel();
if (batteryLevel != null) {
updateState(CHANNEL_BATTERY_LEVEL, new DecimalType(batteryLevel.doubleValue()));
}
Double estimatedRange = car.getEstimatedRange();
if (estimatedRange != null) {
updateState(CHANNEL_ESTIMATED_RANGE, new QuantityType<>(estimatedRange.doubleValue(), KILO(METRE)));
}
Double batteryAvailableEnergy = car.getBatteryAvailableEnergy();
if (batteryAvailableEnergy != null) {
updateState(CHANNEL_BATTERY_AVAILABLE_ENERGY,
new QuantityType<>(batteryAvailableEnergy.doubleValue(), KILOWATT_HOUR));
}
Integer chargingRemainingTime = car.getChargingRemainingTime();
if (chargingRemainingTime != null) {
updateState(CHANNEL_CHARGING_REMAINING_TIME,
new QuantityType<>(chargingRemainingTime.doubleValue(), MINUTE));
}
ZonedDateTime batteryStatusUpdated = car.getBatteryStatusUpdated();
if (batteryStatusUpdated != null) {
updateState(CHANNEL_BATTERY_STATUS_UPDATED, new DateTimeType(batteryStatusUpdated));
}
} catch (RenaultNotImplementedException e) {
logger.warn("Disabling unsupported battery update.");
car.setDisableBattery(true);
} catch (RenaultForbiddenException | RenaultUpdateException | RenaultAPIGatewayException e) {
logger.warn("Error updating battery status.", e);
}
/**
* Resets the Car object. Only call when binding (re)started.
*/
public void reset() {
car = new Car();
carCache.invalidateValue();
}
}
private void updateLockStatus(MyRenaultHttpSession httpSession) {
if (!car.isDisableLockStatus()) {
try {
httpSession.getLockStatus(car);
switch (car.getLockStatus()) {
case LOCKED:
updateState(CHANNEL_LOCKED, OnOffType.ON);
break;
case UNLOCKED:
updateState(CHANNEL_LOCKED, OnOffType.OFF);
break;
default:
updateState(CHANNEL_LOCKED, UnDefType.UNDEF);
break;
}
} catch (RenaultNotImplementedException | RenaultAPIGatewayException e) {
// If not supported API returns a Bad Gateway for this call.
updateState(CHANNEL_LOCKED, UnDefType.UNDEF);
logger.warn("Disabling unsupported lock status update.");
car.setDisableLockStatus(true);
} catch (RenaultForbiddenException | RenaultUpdateException e) {
logger.warn("Error updating lock status.", e);
}
/**
* @return Returns an updated Car object when the expire timeout has been
* triggered.
*/
public Car getCar() {
return Optional.ofNullable(carCache.getValue()).orElseGet(() -> car);
}
}
private void reschedulePollingJob() {
ScheduledFuture<?> job = pollingJob;
if (job != null) {
job.cancel(true);
}
pollingJob = scheduler.scheduleWithFixedDelay(this::getStatus, 0, config.refreshInterval, TimeUnit.MINUTES);
}
}
@@ -15,6 +15,8 @@ thing-type.config.renault.car.accountType.option.MYDACIA = MyDacia
thing-type.config.renault.car.accountType.option.MYRENAULT = MyRenault
thing-type.config.renault.car.kamereonApiKey.label = Kamereon API Key
thing-type.config.renault.car.kamereonApiKey.description = Access code for MyRenault Services.
thing-type.config.renault.car.gigyaApiKey.label = Gigya API Key
thing-type.config.renault.car.gigyaApiKey.description = Configure a Gigya API Key to override the hard coded value for your region.
thing-type.config.renault.car.locale.label = MyRenault Location
thing-type.config.renault.car.locale.description = The country (and language combination) that best fits with your MyRenault registered car.
thing-type.config.renault.car.locale.option.de_AT = Austria
@@ -62,7 +64,6 @@ channel-type.renault.batterystatusupdated.label = Battery Status Updated
channel-type.renault.batterystatusupdated.description = Timestamp of the last battery status update
channel-type.renault.batterystatusupdated.state.pattern = %1$tH:%1$tM %1$td.%1$tm.%1$tY
channel-type.renault.chargingmode.label = Charging Mode
channel-type.renault.chargingmode.state.option.UNKNOWN = Unknown
channel-type.renault.chargingmode.state.option.SCHEDULE_MODE = Schedule mode
channel-type.renault.chargingmode.state.option.ALWAYS_CHARGING = Instant charge
channel-type.renault.chargingremainingtime.label = Charging Time Remaining
@@ -104,3 +105,28 @@ channel-type.renault.plugstatus.state.option.PLUGGED = Plugged
channel-type.renault.plugstatus.state.option.PLUG_ERROR = Plug error
channel-type.renault.plugstatus.state.option.PLUG_UNKNOWN = Plug unknown
channel-type.renault.plugstatus.state.option.UNKNOWN = Unknown
# thing type error messages
thing-type.config.renault.car.error.username_empty = MyRenault Username is empty!
thing-type.config.renault.car.error.password_empty = MyRenault Password is empty!
thing-type.config.renault.car.error.location_empty = Location is empty!
thing-type.config.renault.car.error.vin_empty = VIN is empty!
thing-type.config.renault.car.error.refresh_interval_to_low = The refresh interval must be at least 1
thing-type.renault.car.error.no_data = No data at all. Is your VIN correct?
# error messages
error.renault.session.gigya.no_data = Get Account Info Error: personId or gigyaDataCenter value not found in JSON response.
error.renault.session.gigya.error = Get Account Info Error: {0}.
error.renault.session.jwt.error = Get JWT Error: {0}.
error.renault.session.jwt.no_jwt = Get JWT Error: jwt value not found in JSON response.
error.renault.session.kamereon_cant_get_account_id = Can not get Kamereon {0} Account ID.
error.renault.session.kamereon_request_forbidden = Kamereon request forbidden! Ensure the car is paired in your MyRenault App.
error.renault.session.kamereon_privacy_on = Privacy mode currently ON. Change your privacy settings in your Car.
error.renault.session.kamereon_request_not_implemented = Kamereon request not implemented.
error.renault.session.kamereon_request_failed = Kamereon request failed.
error.renault.session.kamereon_service_not_found = Kamereon service not found.
error.renault.session.login.error = Login Error: {0}.
error.renault.session.login.no_cookie = Login Error: cookie value not found in JSON response.
error.renault.session.login.no_session_info = Login Error: Could not find session info.
@@ -104,6 +104,11 @@
<description>Access code for MyRenault Services.</description>
<default>YjkKtHmGfaceeuExUDKGxrLZGGvtVS0J</default>
</parameter>
<parameter name="gigyaApiKey" type="text">
<label>Gigya API Key</label>
<description>Configure a Gigya API Key to override the hard coded value for your region.</description>
<default></default>
</parameter>
</config-description>
</thing-type>
@@ -172,7 +177,6 @@
</tags>
<state readOnly="false">
<options>
<option value="UNKNOWN">Unknown</option>
<option value="SCHEDULE_MODE">Schedule mode</option>
<option value="ALWAYS_CHARGING">Instant charge</option>
</options>