mirror of
https://github.com/home-assistant/core.git
synced 2025-04-19 14:57:52 +00:00
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:
parent
6b65b21ee0
commit
eb19c7af32
@ -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)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
60
homeassistant/components/home_connect/repairs.py
Normal file
60
homeassistant/components/home_connect/repairs.py
Normal 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()
|
@ -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": {
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user