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 <marhje52@gmail.com>

* Create specific RepairFlow for enabling appliance's updates

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Diego Rodríguez Royo 2025-04-12 14:58:35 +02:00 committed by GitHub
parent 6b65b21ee0
commit eb19c7af32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 336 additions and 7 deletions

View File

@ -11,9 +11,12 @@ import aiohttp
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady 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.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .api import AsyncConfigEntryAuth from .api import AsyncConfigEntryAuth
@ -86,8 +89,18 @@ async def async_unload_entry(
hass: HomeAssistant, entry: HomeConnectConfigEntry hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> bool: ) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") issue_registry = ir.async_get(hass)
async_delete_issue(hass, DOMAIN, "deprecated_command_actions") 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) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -39,7 +39,7 @@ 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, ConfigEntryNotReady 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN 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__) _LOGGER = logging.getLogger(__name__)
MAX_EXECUTIONS_TIME_WINDOW = 15 * 60 # 15 minutes
MAX_EXECUTIONS = 5
type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
@ -114,6 +117,7 @@ class HomeConnectCoordinator(
] = {} ] = {}
self.device_registry = dr.async_get(self.hass) self.device_registry = dr.async_get(self.hass)
self.data = {} self.data = {}
self._execution_tracker: dict[str, list[float]] = defaultdict(list)
@cached_property @cached_property
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: 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}", 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:
@ -238,6 +242,9 @@ class HomeConnectCoordinator(
self._call_event_listener(event_message) self._call_event_listener(event_message)
case EventType.CONNECTED | EventType.PAIRED: case EventType.CONNECTED | EventType.PAIRED:
if self.refreshed_too_often_recently(event_message_ha_id):
continue
appliance_info = await self.client.get_specific_appliance( appliance_info = await self.client.get_specific_appliance(
event_message_ha_id event_message_ha_id
) )
@ -592,3 +599,60 @@ class HomeConnectCoordinator(
[], [],
): ):
listener() 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)

View File

@ -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()

View File

@ -110,6 +110,17 @@
} }
}, },
"issues": { "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": { "deprecated_time_alarm_clock_in_automations_scripts": {
"title": "Deprecated alarm clock entity detected in some automations or scripts", "title": "Deprecated alarm clock entity detected in some automations or scripts",
"fix_flow": { "fix_flow": {

View File

@ -2,6 +2,7 @@
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus
from typing import Any, cast from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@ -14,7 +15,9 @@ from aiohomeconnect.model import (
EventKey, EventKey,
EventMessage, EventMessage,
EventType, EventType,
GetSetting,
HomeAppliance, HomeAppliance,
SettingKey,
) )
from aiohomeconnect.model.error import ( from aiohomeconnect.model.error import (
EventStreamInterruptedError, EventStreamInterruptedError,
@ -39,6 +42,8 @@ from homeassistant.config_entries import ConfigEntries, ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
EVENT_STATE_REPORTED, EVENT_STATE_REPORTED,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
Platform, Platform,
) )
@ -48,11 +53,16 @@ from homeassistant.core import (
HomeAssistant, HomeAssistant,
callback, 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.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import ClientSessionGenerator
INITIAL_FETCH_CLIENT_METHODS = [ INITIAL_FETCH_CLIENT_METHODS = [
"get_settings", "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) client.get_specific_appliance.assert_awaited_once_with(appliance.ha_id)
for method in INITIAL_FETCH_CLIENT_METHODS: for method in INITIAL_FETCH_CLIENT_METHODS:
assert getattr(client, method).call_count == 0 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)