Fix initial fetch of Home Connect appliance data to handle API rate limit errors (#139379)

* Fix initial fetch of appliance data to handle API rate limit errors

* Apply comments

* Delete stale function

* Handle api rate limit error at options fetching

* Update appliances after stream non-breaking error

* Always initialize coordinator data

* Improve device update

* Update test description

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Diego Rodríguez Royo 2025-03-20 09:39:28 +01:00 committed by GitHub
parent adf3e4fcca
commit 2ec80fd1ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 188 additions and 50 deletions

View File

@ -629,14 +629,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
home_connect_client = HomeConnectClient(config_entry_auth) home_connect_client = HomeConnectClient(config_entry_auth)
coordinator = HomeConnectCoordinator(hass, entry, home_connect_client) coordinator = HomeConnectCoordinator(hass, entry, home_connect_client)
await coordinator.async_config_entry_first_refresh() await coordinator.async_setup()
entry.runtime_data = coordinator entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.runtime_data.start_event_listener() entry.runtime_data.start_event_listener()
entry.async_create_background_task(
hass,
coordinator.async_refresh(),
f"home_connect-initial-full-refresh-{entry.entry_id}",
)
return True return True

View File

@ -137,41 +137,6 @@ def setup_home_connect_entry(
defaultdict(list) defaultdict(list)
) )
entities: list[HomeConnectEntity] = []
for appliance in entry.runtime_data.data.values():
entities_to_add = get_entities_for_appliance(entry, appliance)
if get_option_entities_for_appliance:
entities_to_add.extend(get_option_entities_for_appliance(entry, appliance))
for event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
):
changed_options_listener_remove_callback = (
entry.runtime_data.async_add_listener(
partial(
_create_option_entities,
entry,
appliance,
known_entity_unique_ids,
get_option_entities_for_appliance,
async_add_entities,
),
(appliance.info.ha_id, event_key),
)
)
entry.async_on_unload(changed_options_listener_remove_callback)
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
changed_options_listener_remove_callback
)
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
for entity in entities_to_add
}
)
entities.extend(entities_to_add)
async_add_entities(entities)
entry.async_on_unload( entry.async_on_unload(
entry.runtime_data.async_add_special_listener( entry.runtime_data.async_add_special_listener(
partial( partial(

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio from asyncio import sleep as asyncio_sleep
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
@ -29,6 +29,7 @@ from aiohomeconnect.model.error import (
HomeConnectApiError, HomeConnectApiError,
HomeConnectError, HomeConnectError,
HomeConnectRequestError, HomeConnectRequestError,
TooManyRequestsError,
UnauthorizedError, UnauthorizedError,
) )
from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption
@ -36,11 +37,11 @@ from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN
from .utils import get_dict_from_home_connect_error from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -154,7 +155,7 @@ class HomeConnectCoordinator(
f"home_connect-events_listener_task-{self.config_entry.entry_id}", f"home_connect-events_listener_task-{self.config_entry.entry_id}",
) )
async def _event_listener(self) -> None: async def _event_listener(self) -> None: # noqa: C901
"""Match event with listener for event type.""" """Match event with listener for event type."""
retry_time = 10 retry_time = 10
while True: while True:
@ -269,7 +270,7 @@ class HomeConnectCoordinator(
error, error,
retry_time, retry_time,
) )
await asyncio.sleep(retry_time) await asyncio_sleep(retry_time)
retry_time = min(retry_time * 2, 3600) retry_time = min(retry_time * 2, 3600)
except HomeConnectApiError as error: except HomeConnectApiError as error:
_LOGGER.error("Error while listening for events: %s", error) _LOGGER.error("Error while listening for events: %s", error)
@ -278,6 +279,13 @@ class HomeConnectCoordinator(
) )
break break
# Trigger to delete the possible depaired device entities
# from known_entities variable at common.py
for listener, context in self._special_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
listener()
@callback @callback
def _call_event_listener(self, event_message: EventMessage) -> None: def _call_event_listener(self, event_message: EventMessage) -> None:
"""Call listener for event.""" """Call listener for event."""
@ -295,6 +303,42 @@ class HomeConnectCoordinator(
async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
"""Fetch data from Home Connect.""" """Fetch data from Home Connect."""
await self._async_setup()
for appliance_data in self.data.values():
appliance = appliance_data.info
ha_id = appliance.ha_id
while True:
try:
self.data[ha_id] = await self._get_appliance_data(
appliance, self.data.get(ha_id)
)
except TooManyRequestsError as err:
_LOGGER.debug(
"Rate limit exceeded on initial fetch: %s",
err,
)
await asyncio_sleep(err.retry_after or API_DEFAULT_RETRY_AFTER)
else:
break
for listener, context in self._special_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
listener()
return self.data
async def async_setup(self) -> None:
"""Set up the devices."""
try:
await self._async_setup()
except UpdateFailed as err:
raise ConfigEntryNotReady from err
async def _async_setup(self) -> None:
"""Set up the devices."""
old_appliances = set(self.data.keys())
try: try:
appliances = await self.client.get_home_appliances() appliances = await self.client.get_home_appliances()
except UnauthorizedError as error: except UnauthorizedError as error:
@ -312,12 +356,38 @@ class HomeConnectCoordinator(
translation_placeholders=get_dict_from_home_connect_error(error), translation_placeholders=get_dict_from_home_connect_error(error),
) from error ) from error
return { for appliance in appliances.homeappliances:
appliance.ha_id: await self._get_appliance_data( self.device_registry.async_get_or_create(
appliance, self.data.get(appliance.ha_id) config_entry_id=self.config_entry.entry_id,
identifiers={(DOMAIN, appliance.ha_id)},
manufacturer=appliance.brand,
name=appliance.name,
model=appliance.vib,
) )
for appliance in appliances.homeappliances if appliance.ha_id not in self.data:
} self.data[appliance.ha_id] = HomeConnectApplianceData(
commands=set(),
events={},
info=appliance,
options={},
programs=[],
settings={},
status={},
)
else:
self.data[appliance.ha_id].info.connected = appliance.connected
old_appliances.remove(appliance.ha_id)
for ha_id in old_appliances:
self.data.pop(ha_id, None)
device = self.device_registry.async_get_device(
identifiers={(DOMAIN, ha_id)}
)
if device:
self.device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
async def _get_appliance_data( async def _get_appliance_data(
self, self,
@ -339,6 +409,8 @@ class HomeConnectCoordinator(
await self.client.get_settings(appliance.ha_id) await self.client.get_settings(appliance.ha_id)
).settings ).settings
} }
except TooManyRequestsError:
raise
except HomeConnectError as error: except HomeConnectError as error:
_LOGGER.debug( _LOGGER.debug(
"Error fetching settings for %s: %s", "Error fetching settings for %s: %s",
@ -351,6 +423,8 @@ class HomeConnectCoordinator(
status.key: status status.key: status
for status in (await self.client.get_status(appliance.ha_id)).status for status in (await self.client.get_status(appliance.ha_id)).status
} }
except TooManyRequestsError:
raise
except HomeConnectError as error: except HomeConnectError as error:
_LOGGER.debug( _LOGGER.debug(
"Error fetching status for %s: %s", "Error fetching status for %s: %s",
@ -365,6 +439,8 @@ class HomeConnectCoordinator(
if appliance.type in APPLIANCES_WITH_PROGRAMS: if appliance.type in APPLIANCES_WITH_PROGRAMS:
try: try:
all_programs = await self.client.get_all_programs(appliance.ha_id) all_programs = await self.client.get_all_programs(appliance.ha_id)
except TooManyRequestsError:
raise
except HomeConnectError as error: except HomeConnectError as error:
_LOGGER.debug( _LOGGER.debug(
"Error fetching programs for %s: %s", "Error fetching programs for %s: %s",
@ -421,6 +497,8 @@ class HomeConnectCoordinator(
await self.client.get_available_commands(appliance.ha_id) await self.client.get_available_commands(appliance.ha_id)
).commands ).commands
} }
except TooManyRequestsError:
raise
except HomeConnectError: except HomeConnectError:
commands = set() commands = set()
@ -455,6 +533,8 @@ class HomeConnectCoordinator(
).options ).options
or [] or []
} }
except TooManyRequestsError:
raise
except HomeConnectError as error: except HomeConnectError as error:
_LOGGER.debug( _LOGGER.debug(
"Error fetching options for %s: %s", "Error fetching options for %s: %s",

View File

@ -29,6 +29,7 @@ from homeassistant.components.home_connect.const import (
BSH_DOOR_STATE_OPEN, BSH_DOOR_STATE_OPEN,
BSH_EVENT_PRESENT_STATE_PRESENT, BSH_EVENT_PRESENT_STATE_PRESENT,
BSH_POWER_OFF, BSH_POWER_OFF,
DOMAIN,
) )
from homeassistant.config_entries import ConfigEntries, ConfigEntryState from homeassistant.config_entries import ConfigEntries, ConfigEntryState
from homeassistant.const import EVENT_STATE_REPORTED, Platform from homeassistant.const import EVENT_STATE_REPORTED, Platform
@ -38,7 +39,7 @@ from homeassistant.core import (
HomeAssistant, HomeAssistant,
callback, callback,
) )
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -499,3 +500,44 @@ async def test_event_listener_resilience(
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state assert state
assert state.state == after_event_expected_state assert state.state == after_event_expected_state
async def test_devices_updated_on_refresh(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test handling of devices added or deleted while event stream is down."""
appliances: list[HomeAppliance] = (
client.get_home_appliances.return_value.homeappliances
)
assert len(appliances) >= 3
client.get_home_appliances = AsyncMock(
return_value=ArrayOfHomeAppliances(appliances[:2]),
)
await async_setup_component(hass, "homeassistant", {})
assert config_entry.state == ConfigEntryState.NOT_LOADED
await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
for appliance in appliances[:2]:
assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)})
assert not device_registry.async_get_device({(DOMAIN, appliances[2].ha_id)})
client.get_home_appliances = AsyncMock(
return_value=ArrayOfHomeAppliances(appliances[1:3]),
)
await hass.services.async_call(
"homeassistant",
"update_entity",
{"entity_id": "switch.dishwasher_power"},
blocking=True,
)
assert not device_registry.async_get_device({(DOMAIN, appliances[0].ha_id)})
for appliance in appliances[2:3]:
assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)})

View File

@ -3,11 +3,15 @@
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from http import HTTPStatus from http import HTTPStatus
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from aiohomeconnect.const import OAUTH2_TOKEN from aiohomeconnect.const import OAUTH2_TOKEN
from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey
from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError from aiohomeconnect.model.error import (
HomeConnectError,
TooManyRequestsError,
UnauthorizedError,
)
import aiohttp import aiohttp
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@ -355,6 +359,48 @@ async def test_client_error(
assert client_with_exception.get_home_appliances.call_count == 1 assert client_with_exception.get_home_appliances.call_count == 1
@pytest.mark.parametrize(
"raising_exception_method",
[
"get_settings",
"get_status",
"get_all_programs",
"get_available_commands",
"get_available_program",
],
)
async def test_client_rate_limit_error(
raising_exception_method: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test client errors during setup integration."""
retry_after = 42
original_mock = getattr(client, raising_exception_method)
mock = AsyncMock()
async def side_effect(*args, **kwargs):
if mock.call_count <= 1:
raise TooManyRequestsError("error.key", retry_after=retry_after)
return await original_mock(*args, **kwargs)
mock.side_effect = side_effect
setattr(client, raising_exception_method, mock)
assert config_entry.state == ConfigEntryState.NOT_LOADED
with patch(
"homeassistant.components.home_connect.coordinator.asyncio_sleep",
) as asyncio_sleep_mock:
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
assert mock.call_count >= 2
asyncio_sleep_mock.assert_called_once_with(retry_after)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"service_call", "service_call",
SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,