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.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)

View File

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

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": {
"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": {

View File

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