From eb19c7af32e34a4876aaf9bedcbcbe9cffe5299c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 12 Apr 2025 14:58:35 +0200 Subject: [PATCH] Disable Home Connect appliance refresh when frequent disconnects are detected (#142615) * Disable specific updates for an appliance when is done repeatedly * Fix deprecation issues fix tests * Fix message * Avoid fetching appliance info also * Apply suggestions Co-authored-by: Martin Hjelmare * Create specific RepairFlow for enabling appliance's updates --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 21 +- .../components/home_connect/coordinator.py | 68 ++++++- .../components/home_connect/repairs.py | 60 ++++++ .../components/home_connect/strings.json | 11 ++ .../home_connect/test_coordinator.py | 183 +++++++++++++++++- 5 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/home_connect/repairs.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index fe01a3e9564..38db34aa72a 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -11,9 +11,12 @@ import aiohttp from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers import ( + config_entry_oauth2_flow, + config_validation as cv, + issue_registry as ir, +) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth @@ -86,8 +89,18 @@ async def async_unload_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: """Unload a config entry.""" - async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") - async_delete_issue(hass, DOMAIN, "deprecated_command_actions") + issue_registry = ir.async_get(hass) + issues_to_delete = [ + "deprecated_set_program_and_option_actions", + "deprecated_command_actions", + ] + [ + issue_id + for (issue_domain, issue_id) in issue_registry.issues + if issue_domain == DOMAIN + and issue_id.startswith("home_connect_too_many_connected_paired_events") + ] + for issue_id in issues_to_delete: + issue_registry.async_delete(DOMAIN, issue_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 54dc24a6279..4b4ec37ac61 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -39,7 +39,7 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN @@ -47,6 +47,9 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +MAX_EXECUTIONS_TIME_WINDOW = 15 * 60 # 15 minutes +MAX_EXECUTIONS = 5 + type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] @@ -114,6 +117,7 @@ class HomeConnectCoordinator( ] = {} self.device_registry = dr.async_get(self.hass) self.data = {} + self._execution_tracker: dict[str, list[float]] = defaultdict(list) @cached_property def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: @@ -172,7 +176,7 @@ class HomeConnectCoordinator( 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.""" retry_time = 10 while True: @@ -238,6 +242,9 @@ class HomeConnectCoordinator( self._call_event_listener(event_message) case EventType.CONNECTED | EventType.PAIRED: + if self.refreshed_too_often_recently(event_message_ha_id): + continue + appliance_info = await self.client.get_specific_appliance( event_message_ha_id ) @@ -592,3 +599,60 @@ class HomeConnectCoordinator( [], ): listener() + + def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool: + """Check if the appliance data hasn't been refreshed too often recently.""" + + now = self.hass.loop.time() + if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS: + return True + + execution_tracker = self._execution_tracker[appliance_ha_id] = [ + timestamp + for timestamp in self._execution_tracker[appliance_ha_id] + if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW + ] + + execution_tracker.append(now) + + if len(execution_tracker) >= MAX_EXECUTIONS: + ir.async_create_issue( + self.hass, + DOMAIN, + f"home_connect_too_many_connected_paired_events_{appliance_ha_id}", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="home_connect_too_many_connected_paired_events", + data={ + "entry_id": self.config_entry.entry_id, + "appliance_ha_id": appliance_ha_id, + }, + translation_placeholders={ + "appliance_name": self.data[appliance_ha_id].info.name, + "times": str(MAX_EXECUTIONS), + "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), + "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", + "home_assistant_core_new_issue_url": ( + "https://github.com/home-assistant/core/issues/new?template=bug_report.yml" + f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/" + ), + }, + ) + return True + + return False + + async def reset_execution_tracker(self, appliance_ha_id: str) -> None: + """Reset the execution tracker for a specific appliance.""" + self._execution_tracker.pop(appliance_ha_id, None) + appliance_info = await self.client.get_specific_appliance(appliance_ha_id) + + appliance_data = await self._get_appliance_data( + appliance_info, self.data.get(appliance_info.ha_id) + ) + self.data[appliance_ha_id].update(appliance_data) + for listener, context in self._special_listeners.values(): + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context: + listener() + self._call_all_event_listeners_for_appliance(appliance_ha_id) diff --git a/homeassistant/components/home_connect/repairs.py b/homeassistant/components/home_connect/repairs.py new file mode 100644 index 00000000000..21c6775e549 --- /dev/null +++ b/homeassistant/components/home_connect/repairs.py @@ -0,0 +1,60 @@ +"""Repairs flows for Home Connect.""" + +from typing import cast + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from .coordinator import HomeConnectConfigEntry + + +class EnableApplianceUpdatesFlow(RepairsFlow): + """Handler for enabling appliance's updates after being refreshed too many times.""" + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + assert self.data + entry = self.hass.config_entries.async_get_entry( + cast(str, self.data["entry_id"]) + ) + assert entry + entry = cast(HomeConnectConfigEntry, entry) + await entry.runtime_data.reset_execution_tracker( + cast(str, self.data["appliance_ha_id"]) + ) + return self.async_create_entry(data={}) + + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=description_placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id.startswith("home_connect_too_many_connected_paired_events"): + return EnableApplianceUpdatesFlow() + return ConfirmRepairFlow() diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 5b52183fccf..070dcf34f9c 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -110,6 +110,17 @@ } }, "issues": { + "home_connect_too_many_connected_paired_events": { + "title": "{appliance_name} sent too many connected or paired events", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", + "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})." + } + } + } + }, "deprecated_time_alarm_clock_in_automations_scripts": { "title": "Deprecated alarm clock entity detected in some automations or scripts", "fix_flow": { diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index d3b514bcc17..a74c4199318 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from datetime import timedelta +from http import HTTPStatus from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -14,7 +15,9 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + GetSetting, HomeAppliance, + SettingKey, ) from aiohomeconnect.model.error import ( EventStreamInterruptedError, @@ -39,6 +42,8 @@ from homeassistant.config_entries import ConfigEntries, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_STATE_REPORTED, + STATE_OFF, + STATE_ON, STATE_UNAVAILABLE, Platform, ) @@ -48,11 +53,16 @@ from homeassistant.core import ( HomeAssistant, callback, ) -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator INITIAL_FETCH_CLIENT_METHODS = [ "get_settings", @@ -609,3 +619,174 @@ async def test_paired_disconnected_devices_not_fetching( client.get_specific_appliance.assert_awaited_once_with(appliance.ha_id) for method in INITIAL_FETCH_CLIENT_METHODS: assert getattr(client, method).call_count == 0 + + +async def test_coordinator_disabling_updates_for_appliance( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test coordinator disables appliance updates on frequent connect/paired events. + + A repair issue should be created when the updates are disabled. + When the user confirms the issue the updates should be enabled again. + """ + appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" + issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(5) + ] + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + get_settings_original_side_effect = client.get_settings.side_effect + + async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + if ha_id == appliance_ha_id: + return ArrayOfSettings( + [ + GetSetting( + SettingKey.BSH_COMMON_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE.value, + BSH_POWER_OFF, + ) + ] + ) + return cast(ArrayOfSettings, get_settings_original_side_effect(ha_id)) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + assert resp.status == HTTPStatus.OK + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + + +async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_reload( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that updates are enabled again after unloading the entry. + + The repair issue should also be deleted. + """ + appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" + issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(5) + ] + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + get_settings_original_side_effect = client.get_settings.side_effect + + async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + if ha_id == appliance_ha_id: + return ArrayOfSettings( + [ + GetSetting( + SettingKey.BSH_COMMON_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE.value, + BSH_POWER_OFF, + ) + ] + ) + return cast(ArrayOfSettings, get_settings_original_side_effect(ha_id)) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF)