Better throttling handling for the Renault API (#141667)

* Added some better throttling handling for the Renault API, it fixes #106777 HA ticket

* Added some better throttling handling for the Renault API, it fixes #106777 HA ticket, test fixing

* Update homeassistant/components/renault/coordinator.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Update homeassistant/components/renault/coordinator.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Update homeassistant/components/renault/coordinator.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Update homeassistant/components/renault/coordinator.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Update homeassistant/components/renault/coordinator.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Update homeassistant/components/renault/coordinator.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Update homeassistant/components/renault/coordinator.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Update homeassistant/components/renault/renault_hub.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* bigger testsuite for  #106777 HA ticket

* bigger testsuite for  #106777 HA ticket

* bigger testsuite for  #106777 HA ticket

* bigger testsuite for  #106777 HA ticket

* Adjust tests

* Update homeassistant/components/renault/coordinator.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Update homeassistant/components/renault/renault_hub.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Update homeassistant/components/renault/renault_hub.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Update tests/components/renault/test_sensor.py

* Update tests/components/renault/test_sensor.py

* Update tests/components/renault/test_sensor.py

* requested changes  #106777 HA ticket

* Use unkown

* Fix test

* Fix test again

* Reduce and fix

* Use assumed_state

* requested changes  #106777 HA ticket

---------

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
tmenguy 2025-04-01 17:14:21 +02:00 committed by GitHub
parent 23b79b2f39
commit da9b3dc68b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 81 additions and 5 deletions

View File

@ -9,6 +9,9 @@ CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id"
DEFAULT_SCAN_INTERVAL = 420 # 7 minutes DEFAULT_SCAN_INTERVAL = 420 # 7 minutes
# If throttled time to pause the updates, in seconds
COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON, Platform.BUTTON,

View File

@ -12,6 +12,7 @@ from renault_api.kamereon.exceptions import (
AccessDeniedException, AccessDeniedException,
KamereonResponseException, KamereonResponseException,
NotSupportedException, NotSupportedException,
QuotaLimitException,
) )
from renault_api.kamereon.models import KamereonVehicleDataAttributes from renault_api.kamereon.models import KamereonVehicleDataAttributes
@ -20,6 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
if TYPE_CHECKING: if TYPE_CHECKING:
from . import RenaultConfigEntry from . import RenaultConfigEntry
from .renault_hub import RenaultHub
T = TypeVar("T", bound=KamereonVehicleDataAttributes) T = TypeVar("T", bound=KamereonVehicleDataAttributes)
@ -37,6 +39,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
self, self,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: RenaultConfigEntry, config_entry: RenaultConfigEntry,
hub: RenaultHub,
logger: logging.Logger, logger: logging.Logger,
*, *,
name: str, name: str,
@ -54,10 +57,24 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
) )
self.access_denied = False self.access_denied = False
self.not_supported = False self.not_supported = False
self.assumed_state = False
self._has_already_worked = False self._has_already_worked = False
self._hub = hub
async def _async_update_data(self) -> T: async def _async_update_data(self) -> T:
"""Fetch the latest data from the source.""" """Fetch the latest data from the source."""
if self._hub.is_throttled():
if not self._has_already_worked:
raise UpdateFailed("Renault hub currently throttled: init skipped")
# we have been throttled and decided to cooldown
# so do not count this update as an error
# coordinator. last_update_success should still be ok
self.logger.debug("Renault hub currently throttled: scan skipped")
self.assumed_state = True
return self.data
try: try:
async with _PARALLEL_SEMAPHORE: async with _PARALLEL_SEMAPHORE:
data = await self.update_method() data = await self.update_method()
@ -70,6 +87,16 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
self.access_denied = True self.access_denied = True
raise UpdateFailed(f"This endpoint is denied: {err}") from err raise UpdateFailed(f"This endpoint is denied: {err}") from err
except QuotaLimitException as err:
# The data we got is not bad per see, initiate cooldown for all coordinators
self._hub.set_throttled()
if self._has_already_worked:
self.assumed_state = True
self.logger.warning("Renault API throttled")
return self.data
raise UpdateFailed(f"Renault API throttled: {err}") from err
except NotSupportedException as err: except NotSupportedException as err:
# Disable because the vehicle does not support this Renault endpoint. # Disable because the vehicle does not support this Renault endpoint.
self.update_interval = None self.update_interval = None
@ -81,6 +108,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
raise UpdateFailed(f"Error communicating with API: {err}") from err raise UpdateFailed(f"Error communicating with API: {err}") from err
self._has_already_worked = True self._has_already_worked = True
self.assumed_state = False
return data return data
async def async_config_entry_first_refresh(self) -> None: async def async_config_entry_first_refresh(self) -> None:

View File

@ -60,3 +60,8 @@ class RenaultDataEntity(
def _get_data_attr(self, key: str) -> StateType: def _get_data_attr(self, key: str) -> StateType:
"""Return the attribute value from the coordinator data.""" """Return the attribute value from the coordinator data."""
return cast(StateType, getattr(self.coordinator.data, key)) return cast(StateType, getattr(self.coordinator.data, key))
@property
def assumed_state(self) -> bool:
"""Return True if unable to access real state of the entity."""
return self.coordinator.assumed_state

View File

@ -27,7 +27,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
if TYPE_CHECKING: if TYPE_CHECKING:
from . import RenaultConfigEntry from . import RenaultConfigEntry
from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL from time import time
from .const import (
CONF_KAMEREON_ACCOUNT_ID,
COOLING_UPDATES_SECONDS,
DEFAULT_SCAN_INTERVAL,
)
from .renault_vehicle import RenaultVehicleProxy from .renault_vehicle import RenaultVehicleProxy
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -45,6 +51,24 @@ class RenaultHub:
self._account: RenaultAccount | None = None self._account: RenaultAccount | None = None
self._vehicles: dict[str, RenaultVehicleProxy] = {} self._vehicles: dict[str, RenaultVehicleProxy] = {}
self._got_throttled_at_time: float | None = None
def set_throttled(self) -> None:
"""We got throttled, we need to adjust the rate limit."""
if self._got_throttled_at_time is None:
self._got_throttled_at_time = time()
def is_throttled(self) -> bool:
"""Check if we are throttled."""
if self._got_throttled_at_time is None:
return False
if time() - self._got_throttled_at_time > COOLING_UPDATES_SECONDS:
self._got_throttled_at_time = None
return False
return True
async def attempt_login(self, username: str, password: str) -> bool: async def attempt_login(self, username: str, password: str) -> bool:
"""Attempt login to Renault servers.""" """Attempt login to Renault servers."""
try: try:
@ -99,6 +123,7 @@ class RenaultHub:
vehicle = RenaultVehicleProxy( vehicle = RenaultVehicleProxy(
hass=self._hass, hass=self._hass,
config_entry=config_entry, config_entry=config_entry,
hub=self,
vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), vehicle=await renault_account.get_api_vehicle(vehicle_link.vin),
details=vehicle_link.vehicleDetails, details=vehicle_link.vehicleDetails,
scan_interval=scan_interval, scan_interval=scan_interval,

View File

@ -20,6 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from . import RenaultConfigEntry from . import RenaultConfigEntry
from .renault_hub import RenaultHub
from .const import DOMAIN from .const import DOMAIN
from .coordinator import RenaultDataUpdateCoordinator from .coordinator import RenaultDataUpdateCoordinator
@ -68,6 +69,7 @@ class RenaultVehicleProxy:
self, self,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: RenaultConfigEntry, config_entry: RenaultConfigEntry,
hub: RenaultHub,
vehicle: RenaultVehicle, vehicle: RenaultVehicle,
details: models.KamereonVehicleDetails, details: models.KamereonVehicleDetails,
scan_interval: timedelta, scan_interval: timedelta,
@ -87,6 +89,7 @@ class RenaultVehicleProxy:
self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {}
self.hvac_target_temperature = 21 self.hvac_target_temperature = 21
self._scan_interval = scan_interval self._scan_interval = scan_interval
self._hub = hub
@property @property
def details(self) -> models.KamereonVehicleDetails: def details(self) -> models.KamereonVehicleDetails:
@ -104,6 +107,7 @@ class RenaultVehicleProxy:
coord.key: RenaultDataUpdateCoordinator( coord.key: RenaultDataUpdateCoordinator(
self.hass, self.hass,
self.config_entry, self.config_entry,
self._hub,
LOGGER, LOGGER,
name=f"{self.details.vin} {coord.key}", name=f"{self.details.vin} {coord.key}",
update_method=coord.update_method(self._vehicle), update_method=coord.update_method(self._vehicle),

View File

@ -10,7 +10,7 @@ from renault_api.kamereon.exceptions import QuotaLimitException
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
@ -184,7 +184,7 @@ async def test_sensor_throttling_during_setup(
for get_data_mock in patches.values(): for get_data_mock in patches.values():
get_data_mock.side_effect = None get_data_mock.side_effect = None
patches["battery_status"].return_value.batteryLevel = 55 patches["battery_status"].return_value.batteryLevel = 55
freezer.tick(datetime.timedelta(minutes=10)) freezer.tick(datetime.timedelta(minutes=20))
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -196,6 +196,7 @@ async def test_sensor_throttling_after_init(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
vehicle_type: str, vehicle_type: str,
caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test for Renault sensors with a throttling error during setup.""" """Test for Renault sensors with a throttling error during setup."""
@ -209,8 +210,11 @@ async def test_sensor_throttling_after_init(
# Initial state # Initial state
entity_id = "sensor.reg_number_battery" entity_id = "sensor.reg_number_battery"
assert hass.states.get(entity_id).state == "60" assert hass.states.get(entity_id).state == "60"
assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE)
assert "Renault API throttled: scan skipped" not in caplog.text
# Test QuotaLimitException state # Test QuotaLimitException state
caplog.clear()
for get_data_mock in patches.values(): for get_data_mock in patches.values():
get_data_mock.side_effect = QuotaLimitException( get_data_mock.side_effect = QuotaLimitException(
"err.func.wired.overloaded", "You have reached your quota limit" "err.func.wired.overloaded", "You have reached your quota limit"
@ -219,14 +223,21 @@ async def test_sensor_throttling_after_init(
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE assert hass.states.get(entity_id).state == "60"
assert hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE)
assert "Renault API throttled" in caplog.text
assert "Renault hub currently throttled: scan skipped" in caplog.text
# Test QuotaLimitException recovery, with new battery level # Test QuotaLimitException recovery, with new battery level
caplog.clear()
for get_data_mock in patches.values(): for get_data_mock in patches.values():
get_data_mock.side_effect = None get_data_mock.side_effect = None
patches["battery_status"].return_value.batteryLevel = 55 patches["battery_status"].return_value.batteryLevel = 55
freezer.tick(datetime.timedelta(minutes=10)) freezer.tick(datetime.timedelta(minutes=20))
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "55" assert hass.states.get(entity_id).state == "55"
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