Make renault scan interval dynamic (#142964)

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
tmenguy 2025-04-19 00:51:41 -07:00 committed by GitHub
parent 27b7fb6f91
commit c422bcf1e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 416 additions and 6 deletions

View File

@ -7,7 +7,9 @@ DOMAIN = "renault"
CONF_LOCALE = "locale" CONF_LOCALE = "locale"
CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id"
DEFAULT_SCAN_INTERVAL = 420 # 7 minutes # normal number of allowed calls per hour to the API
# for a single car and the 7 coordinator, it is a scan every 7mn
MAX_CALLS_PER_HOURS = 60
# If throttled time to pause the updates, in seconds # If throttled time to pause the updates, in seconds
COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes

View File

@ -32,9 +32,9 @@ from time import time
from .const import ( from .const import (
CONF_KAMEREON_ACCOUNT_ID, CONF_KAMEREON_ACCOUNT_ID,
COOLING_UPDATES_SECONDS, COOLING_UPDATES_SECONDS,
DEFAULT_SCAN_INTERVAL, MAX_CALLS_PER_HOURS,
) )
from .renault_vehicle import RenaultVehicleProxy from .renault_vehicle import COORDINATORS, RenaultVehicleProxy
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -82,7 +82,6 @@ class RenaultHub:
async def async_initialise(self, config_entry: RenaultConfigEntry) -> None: async def async_initialise(self, config_entry: RenaultConfigEntry) -> None:
"""Set up proxy.""" """Set up proxy."""
account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID]
scan_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL)
self._account = await self._client.get_api_account(account_id) self._account = await self._client.get_api_account(account_id)
vehicles = await self._account.get_vehicles() vehicles = await self._account.get_vehicles()
@ -94,6 +93,12 @@ class RenaultHub:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
"Failed to retrieve vehicle details from Renault servers" "Failed to retrieve vehicle details from Renault servers"
) )
num_call_per_scan = len(COORDINATORS) * len(vehicles.vehicleLinks)
scan_interval = timedelta(
seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS
)
device_registry = dr.async_get(self._hass) device_registry = dr.async_get(self._hass)
await asyncio.gather( await asyncio.gather(
*( *(
@ -108,6 +113,21 @@ class RenaultHub:
) )
) )
# all vehicles have been initiated with the right number of active coordinators
num_call_per_scan = 0
for vehicle_link in vehicles.vehicleLinks:
vehicle = self._vehicles[str(vehicle_link.vin)]
num_call_per_scan += len(vehicle.coordinators)
new_scan_interval = timedelta(
seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS
)
if new_scan_interval != scan_interval:
# we need to change the vehicles with the right scan interval
for vehicle_link in vehicles.vehicleLinks:
vehicle = self._vehicles[str(vehicle_link.vin)]
vehicle.update_scan_interval(new_scan_interval)
async def async_initialise_vehicle( async def async_initialise_vehicle(
self, self,
vehicle_link: KamereonVehiclesLink, vehicle_link: KamereonVehiclesLink,

View File

@ -91,6 +91,13 @@ class RenaultVehicleProxy:
self._scan_interval = scan_interval self._scan_interval = scan_interval
self._hub = hub self._hub = hub
def update_scan_interval(self, scan_interval: timedelta) -> None:
"""Set the scan interval for the vehicle."""
if scan_interval != self._scan_interval:
self._scan_interval = scan_interval
for coordinator in self.coordinators.values():
coordinator.update_interval = scan_interval
@property @property
def details(self) -> models.KamereonVehicleDetails: def details(self) -> models.KamereonVehicleDetails:
"""Return the specs of the vehicle.""" """Return the specs of the vehicle."""

View File

@ -0,0 +1,291 @@
{
"accountId": "account-id-2",
"country": "IT",
"vehicleLinks": [
{
"brand": "RENAULT",
"vin": "VF1AAAAA555777999",
"status": "ACTIVE",
"linkType": "OWNER",
"garageBrand": "RENAULT",
"annualMileage": 16000,
"mileage": 26464,
"startDate": "2017-08-07",
"createdDate": "2019-05-23T21:38:16.409008Z",
"lastModifiedDate": "2020-11-17T08:41:40.497400Z",
"ownershipStartDate": "2017-08-01",
"cancellationReason": {},
"connectedDriver": {
"role": "MAIN_DRIVER",
"createdDate": "2019-06-17T09:49:06.880627Z",
"lastModifiedDate": "2019-06-17T09:49:06.880627Z"
},
"vehicleDetails": {
"vin": "VF1AAAAA555777999",
"registrationDate": "2017-08-01",
"firstRegistrationDate": "2017-08-01",
"engineType": "5AQ",
"engineRatio": "601",
"modelSCR": "ZOE",
"deliveryCountry": {
"code": "FR",
"label": "FRANCE"
},
"family": {
"code": "X10",
"label": "FAMILLE X10",
"group": "007"
},
"tcu": {
"code": "TCU0G2",
"label": "TCU VER 0 GEN 2",
"group": "E70"
},
"navigationAssistanceLevel": {
"code": "NAV3G5",
"label": "LEVEL 3 TYPE 5 NAVIGATION",
"group": "408"
},
"battery": {
"code": "BT4AR1",
"label": "BATTERIE BT4AR1",
"group": "968"
},
"radioType": {
"code": "RAD37A",
"label": "RADIO 37A",
"group": "425"
},
"registrationCountry": {
"code": "FR"
},
"brand": {
"label": "RENAULT"
},
"model": {
"code": "X101VE",
"label": "ZOE",
"group": "971"
},
"gearbox": {
"code": "BVEL",
"label": "BOITE A VARIATEUR ELECTRIQUE",
"group": "427"
},
"version": {
"code": "INT MB 10R"
},
"energy": {
"code": "ELEC",
"label": "ELECTRIQUE",
"group": "019"
},
"registrationNumber": "REG-NUMBER",
"vcd": "SYTINC/SKTPOU/SAND41/FDIU1/SSESM/MAPSUP/SSCALL/SAND88/SAND90/SQKDRO/SDIFPA/FACBA2/PRLEX1/SSRCAR/CABDO2/TCU0G2/SWALBO/EVTEC1/STANDA/X10/B10/EA2/MB/ELEC/DG/TEMP/TR4X2/RV/ABS/CAREG/LAC/VT003/CPE/RET03/SPROJA/RALU16/CEAVRH/AIRBA1/SERIE/DRA/DRAP08/HARM02/ATAR/TERQG/SFBANA/KM/DPRPN/AVREPL/SSDECA/ASRESP/RDAR02/ALEVA/CACBL2/SOP02C/CTHAB2/TRNOR/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/APL03/ALOUCC/CMAR3P/NAV3G5/RAD37A/BVEL/AUTAUG/RNORM/ISOFIX/EQPEUR/HRGM01/SDPCLV/TLFRAN/SPRODI/SAN613/SSAPEX/GENEV1/ELC1/SANCML/PE2012/PHAS1/SAN913/045KWH/BT4AR1/VEC153/X101VE/NBT017/5AQ",
"assets": [
{
"assetType": "PICTURE",
"renditions": [
{
"resolutionType": "ONE_MYRENAULT_LARGE",
"url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE"
},
{
"resolutionType": "ONE_MYRENAULT_SMALL",
"url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2"
}
]
},
{
"assetType": "PDF",
"assetRole": "GUIDE",
"title": "PDF Guide",
"description": "",
"renditions": [
{
"url": "https://cdn.group.renault.com/ren/gb/myr/assets/x101ve/manual.pdf.asset.pdf/1558704861676.pdf"
}
]
},
{
"assetType": "URL",
"assetRole": "GUIDE",
"title": "e-guide",
"description": "",
"renditions": [
{
"url": "http://gb.e-guide.renault.com/eng/Zoe"
}
]
},
{
"assetType": "VIDEO",
"assetRole": "CAR",
"title": "10 Fundamentals about getting the best out of your electric vehicle",
"description": "",
"renditions": [
{
"url": "39r6QEKcOM4"
}
]
},
{
"assetType": "VIDEO",
"assetRole": "CAR",
"title": "Automatic Climate Control",
"description": "",
"renditions": [
{
"url": "Va2FnZFo_GE"
}
]
},
{
"assetType": "URL",
"assetRole": "CAR",
"title": "More videos",
"description": "",
"renditions": [
{
"url": "https://www.youtube.com/watch?v=wfpCMkK1rKI"
}
]
},
{
"assetType": "VIDEO",
"assetRole": "CAR",
"title": "Charging the battery",
"description": "",
"renditions": [
{
"url": "RaEad8DjUJs"
}
]
},
{
"assetType": "VIDEO",
"assetRole": "CAR",
"title": "Charging the battery at a station with a flap",
"description": "",
"renditions": [
{
"url": "zJfd7fJWtr0"
}
]
}
],
"yearsOfMaintenance": 12,
"connectivityTechnology": "RLINK1",
"easyConnectStore": false,
"electrical": true,
"rlinkStore": false,
"deliveryDate": "2017-08-11",
"retrievedFromDhs": false,
"engineEnergyType": "ELEC",
"radioCode": "1234"
}
},
{
"brand": "RENAULT",
"vin": "VF1AAAAA555777123",
"status": "ACTIVE",
"linkType": "USER",
"garageBrand": "RENAULT",
"mileage": 346,
"startDate": "2020-06-12",
"createdDate": "2020-06-12T15:02:00.555432Z",
"lastModifiedDate": "2020-06-15T06:21:43.762467Z",
"cancellationReason": {},
"connectedDriver": {
"role": "MAIN_DRIVER",
"createdDate": "2020-06-15T06:20:39.107794Z",
"lastModifiedDate": "2020-06-15T06:20:39.107794Z"
},
"vehicleDetails": {
"vin": "VF1AAAAA555777123",
"engineType": "H5H",
"engineRatio": "470",
"modelSCR": "CP1",
"deliveryCountry": {
"code": "BE",
"label": "BELGIQUE"
},
"family": {
"code": "XJB",
"label": "FAMILLE B+X OVER",
"group": "007"
},
"tcu": {
"code": "AIVCT",
"label": "AVEC BOITIER CONNECT AIVC",
"group": "E70"
},
"navigationAssistanceLevel": {
"code": "",
"label": "",
"group": ""
},
"battery": {
"code": "SANBAT",
"label": "SANS BATTERIE",
"group": "968"
},
"radioType": {
"code": "NA406",
"label": "A-IVIMINDL, 2BO + 2BI + 2T, MICRO-DOUBLE, FM1/DAB+FM2",
"group": "425"
},
"registrationCountry": {
"code": "BE"
},
"brand": {
"label": "RENAULT"
},
"model": {
"code": "XJB1SU",
"label": "CAPTUR II",
"group": "971"
},
"gearbox": {
"code": "BVA7",
"label": "BOITE DE VITESSE AUTOMATIQUE 7 RAPPORTS",
"group": "427"
},
"version": {
"code": "ITAMFHA 6TH"
},
"energy": {
"code": "ESS",
"label": "ESSENCE",
"group": "019"
},
"registrationNumber": "REG-NUMBER",
"vcd": "ADR00/DLIGM2/PGPRT2/FEUAR3/CDVIT1/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET04/DANGMO/ECOMOD/SSRCAR/AIVCT/AVGSI/TPRPE/TSGNE/2TON/ITPK7/MLEXP1/SPERTA/SSPERG/SPERTP/VOLCHA/SREACT/AVOSP1/SWALBO/DWGE01/AVC1A/1234Y/AEBS07/PRAHL/AVCAM/STANDA/XJB/HJB/EA3/MF/ESS/DG/TEMP/TR4X2/AFURGE/RVDIST/ABS/SBARTO/CA02/TOPAN/PBNCH/LAC/VSTLAR/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV02/SGAR02/BIXPE/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/SCACBA/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETIN2/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGACHA/BEL01/APL03/FSTPO/ALOUC5/CMAR3P/FIPOU2/NA406/BVA7/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRGAC/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06T/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/SSTYAD/HYB01/SSCABA/SANBAT/VEC012/XJB1SU/SSNBT/H5H",
"assets": [
{
"assetType": "PICTURE",
"renditions": [
{
"resolutionType": "ONE_MYRENAULT_LARGE",
"url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE"
},
{
"resolutionType": "ONE_MYRENAULT_SMALL",
"url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2"
}
]
}
],
"yearsOfMaintenance": 12,
"connectivityTechnology": "NONE",
"easyConnectStore": false,
"electrical": false,
"rlinkStore": false,
"deliveryDate": "2020-06-17",
"retrievedFromDhs": false,
"engineEnergyType": "OTHER",
"radioCode": "1234"
}
}
]
}

View File

@ -2,11 +2,15 @@
from collections.abc import Generator from collections.abc import Generator
import datetime import datetime
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from renault_api.kamereon.exceptions import QuotaLimitException from renault_api.kamereon.exceptions import (
AccessDeniedException,
NotSupportedException,
QuotaLimitException,
)
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -241,3 +245,89 @@ async def test_sensor_throttling_after_init(
assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE)
assert "Renault API throttled" not in caplog.text assert "Renault API throttled" not in caplog.text
assert "Renault hub currently throttled: scan skipped" not in caplog.text assert "Renault hub currently throttled: scan skipped" not in caplog.text
# scan interval in seconds = (3600 * num_calls) / MAX_CALLS_PER_HOURS
# MAX_CALLS_PER_HOURS being a constant, for now 60 calls per hour
# num_calls = num_coordinator_car_0 + num_coordinator_car_1 + ... + num_coordinator_car_n
@pytest.mark.parametrize(
("vehicle_type", "vehicle_count", "scan_interval"),
[
("zoe_40", 1, 300), # 5 coordinators => 5 minutes interval
("captur_fuel", 1, 240), # 4 coordinators => 4 minutes interval
("multi", 2, 540), # 9 coordinators => 9 minutes interval
],
indirect=["vehicle_type"],
)
async def test_dynamic_scan_interval(
hass: HomeAssistant,
config_entry: ConfigEntry,
vehicle_count: int,
scan_interval: int,
freezer: FrozenDateTimeFactory,
fixtures_with_data: dict[str, AsyncMock],
) -> None:
"""Test scan interval."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count
# 2 seconds before the expected scan interval > not called
freezer.tick(datetime.timedelta(seconds=scan_interval - 2))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count
# 2 seconds after the expected scan interval > called
freezer.tick(datetime.timedelta(seconds=4))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count * 2
# scan interval in seconds = (3600 * num_calls) / MAX_CALLS_PER_HOURS
# MAX_CALLS_PER_HOURS being a constant, for now 60 calls per hour
# num_calls = num_coordinator_car_0 + num_coordinator_car_1 + ... + num_coordinator_car_n
@pytest.mark.parametrize(
("vehicle_type", "vehicle_count", "scan_interval"),
[
("zoe_40", 1, 240), # (5-1) coordinators => 4 minutes interval
("captur_fuel", 1, 180), # (4-1) coordinators => 3 minutes interval
("multi", 2, 420), # (9-2) coordinators => 7 minutes interval
],
indirect=["vehicle_type"],
)
async def test_dynamic_scan_interval_failed_coordinator(
hass: HomeAssistant,
config_entry: ConfigEntry,
vehicle_count: int,
scan_interval: int,
freezer: FrozenDateTimeFactory,
fixtures_with_data: dict[str, AsyncMock],
) -> None:
"""Test scan interval."""
fixtures_with_data["battery_status"].side_effect = NotSupportedException(
"err.tech.501",
"This feature is not technically supported by this gateway",
)
fixtures_with_data["lock_status"].side_effect = AccessDeniedException(
"err.func.403",
"Access is denied for this resource",
)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count
# 2 seconds before the expected scan interval > not called
freezer.tick(datetime.timedelta(seconds=scan_interval - 2))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count
# 2 seconds after the expected scan interval > called
freezer.tick(datetime.timedelta(seconds=4))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count * 2