diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 05f8099b168..1dffededf38 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -7,7 +7,9 @@ DOMAIN = "renault" CONF_LOCALE = "locale" 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 COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index e5168fc81fd..1f883435dee 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -32,9 +32,9 @@ from time import time from .const import ( CONF_KAMEREON_ACCOUNT_ID, 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__) @@ -82,7 +82,6 @@ class RenaultHub: async def async_initialise(self, config_entry: RenaultConfigEntry) -> None: """Set up proxy.""" 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) vehicles = await self._account.get_vehicles() @@ -94,6 +93,12 @@ class RenaultHub: raise ConfigEntryNotReady( "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) 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( self, vehicle_link: KamereonVehiclesLink, diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 1ab9bf0bd5a..8d096a734e1 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -91,6 +91,13 @@ class RenaultVehicleProxy: self._scan_interval = scan_interval 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 def details(self) -> models.KamereonVehicleDetails: """Return the specs of the vehicle.""" diff --git a/tests/components/renault/fixtures/vehicle_multi.json b/tests/components/renault/fixtures/vehicle_multi.json new file mode 100644 index 00000000000..18374a8cbd1 --- /dev/null +++ b/tests/components/renault/fixtures/vehicle_multi.json @@ -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" + } + } + ] +} diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index bce50ec4fbf..6d71d2e6412 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -2,11 +2,15 @@ from collections.abc import Generator import datetime -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from renault_api.kamereon.exceptions import QuotaLimitException +from renault_api.kamereon.exceptions import ( + AccessDeniedException, + NotSupportedException, + QuotaLimitException, +) from syrupy.assertion import SnapshotAssertion 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 "Renault API throttled" 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