1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Use a coordinator per appliance in Home Connect (#152518)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
J. Diego Rodríguez Royo
2026-02-19 12:35:19 +01:00
committed by GitHub
parent 725b45db7f
commit c9b5f5f2c1
17 changed files with 628 additions and 533 deletions
@@ -6,13 +6,18 @@ import logging
from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import EventKey
import aiohttp
import jwt
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
issue_registry as ir,
)
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
@@ -23,7 +28,7 @@ from homeassistant.helpers.typing import ConfigType
from .api import AsyncConfigEntryAuth
from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
from .coordinator import HomeConnectConfigEntry, HomeConnectRuntimeData
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -71,19 +76,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
home_connect_client = HomeConnectClient(config_entry_auth)
coordinator = HomeConnectCoordinator(hass, entry, home_connect_client)
await coordinator.async_setup()
entry.runtime_data = coordinator
runtime_data = HomeConnectRuntimeData(hass, entry, home_connect_client)
await runtime_data.setup_appliance_coordinators()
entry.runtime_data = runtime_data
appliances_identifiers = {
(entry.domain, ha_id) for ha_id in entry.runtime_data.appliance_coordinators
}
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry_id=entry.entry_id
)
for device in device_entries:
if not device.identifiers.intersection(appliances_identifiers):
device_registry.async_update_device(
device.id, remove_config_entry_id=entry.entry_id
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
for listener, context in runtime_data.global_listeners.values():
# We call the PAIRED event listener to start adding entities
# from the appliances we already found above
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
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}",
)
for (
appliance_id,
appliance_coordinator,
) in entry.runtime_data.appliance_coordinators.items():
# We refresh each appliance coordinator in the background.
# to ensure that setup time is not impacted by this refresh.
entry.async_create_background_task(
hass,
appliance_coordinator.async_refresh(),
f"home_connect-initial-full-refresh-{entry.entry_id}-{appliance_id}",
)
return True
@@ -104,6 +136,9 @@ async def async_unload_entry(
]
for issue_id in issues_to_delete:
issue_registry.async_delete(DOMAIN, issue_id)
for coordinator in entry.runtime_data.appliance_coordinators.values():
await coordinator.async_shutdown()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity
PARALLEL_UPDATES = 0
@@ -145,19 +145,18 @@ CONNECTED_BINARY_ENTITY_DESCRIPTION = BinarySensorEntityDescription(
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectEntity] = [
HomeConnectConnectivityBinarySensor(
entry.runtime_data, appliance, CONNECTED_BINARY_ENTITY_DESCRIPTION
appliance_coordinator, CONNECTED_BINARY_ENTITY_DESCRIPTION
)
]
entities.extend(
HomeConnectBinarySensor(entry.runtime_data, appliance, description)
HomeConnectBinarySensor(appliance_coordinator, description)
for description in BINARY_SENSORS
if description.key in appliance.status
if description.key in appliance_coordinator.data.status
)
return entities
+11 -28
View File
@@ -10,11 +10,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
@@ -48,20 +44,18 @@ COMMAND_BUTTONS = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectEntity] = []
appliance_data = appliance_coordinator.data
entities.extend(
HomeConnectCommandButtonEntity(entry.runtime_data, appliance, description)
HomeConnectCommandButtonEntity(appliance_coordinator, description)
for description in COMMAND_BUTTONS
if description.key in appliance.commands
if description.key in appliance_data.commands
)
if appliance.info.type in APPLIANCES_WITH_PROGRAMS:
entities.append(
HomeConnectStopProgramButtonEntity(entry.runtime_data, appliance)
)
if appliance_data.info.type in APPLIANCES_WITH_PROGRAMS:
entities.append(HomeConnectStopProgramButtonEntity(appliance_coordinator))
return entities
@@ -87,17 +81,11 @@ class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity):
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: ButtonEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
appliance,
desc,
(appliance.info.ha_id,),
)
super().__init__(appliance_coordinator, desc, context_override=True)
def update_native_value(self) -> None:
"""Set the value of the entity."""
@@ -130,15 +118,10 @@ class HomeConnectCommandButtonEntity(HomeConnectButtonEntity):
class HomeConnectStopProgramButtonEntity(HomeConnectButtonEntity):
"""Button entity for stopping a program."""
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
) -> None:
def __init__(self, appliance_coordinator: HomeConnectApplianceCoordinator) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
appliance,
appliance_coordinator,
ButtonEntityDescription(
key="StopProgram",
translation_key="stop_program",
+25 -25
View File
@@ -14,7 +14,11 @@ from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .coordinator import (
HomeConnectApplianceCoordinator,
HomeConnectApplianceData,
HomeConnectConfigEntry,
)
from .entity import HomeConnectEntity, HomeConnectOptionEntity
@@ -40,11 +44,10 @@ def should_add_option_entity(
def _create_option_entities(
entity_registry: er.EntityRegistry,
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
known_entity_unique_ids: dict[str, str],
get_option_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
],
async_add_entities: AddConfigEntryEntitiesCallback,
@@ -53,13 +56,13 @@ def _create_option_entities(
option_entities_to_add = [
entity
for entity in get_option_entities_for_appliance(
entry, appliance, entity_registry
appliance_coordinator, entity_registry
)
if entity.unique_id not in known_entity_unique_ids
]
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
cast(str, entity.unique_id): appliance_coordinator.data.info.ha_id
for entity in option_entities_to_add
}
)
@@ -71,10 +74,10 @@ def _handle_paired_or_connected_appliance(
entry: HomeConnectConfigEntry,
known_entity_unique_ids: dict[str, str],
get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
[HomeConnectApplianceCoordinator], list[HomeConnectEntity]
],
get_option_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
]
| None,
@@ -90,17 +93,18 @@ def _handle_paired_or_connected_appliance(
"""
entities: list[HomeConnectEntity] = []
entity_registry = er.async_get(hass)
for appliance in entry.runtime_data.data.values():
for appliance_coordinator in entry.runtime_data.appliance_coordinators.values():
appliance_ha_id = appliance_coordinator.data.info.ha_id
entities_to_add = [
entity
for entity in get_entities_for_appliance(entry, appliance)
for entity in get_entities_for_appliance(appliance_coordinator)
if entity.unique_id not in known_entity_unique_ids
]
if get_option_entities_for_appliance:
entities_to_add.extend(
entity
for entity in get_option_entities_for_appliance(
entry, appliance, entity_registry
appliance_coordinator, entity_registry
)
if entity.unique_id not in known_entity_unique_ids
)
@@ -109,28 +113,24 @@ def _handle_paired_or_connected_appliance(
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
):
changed_options_listener_remove_callback = (
entry.runtime_data.async_add_listener(
appliance_coordinator.async_add_listener(
partial(
_create_option_entities,
entity_registry,
entry,
appliance,
appliance_coordinator,
known_entity_unique_ids,
get_option_entities_for_appliance,
async_add_entities,
),
(appliance.info.ha_id, event_key),
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_callbacks[appliance_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
}
{cast(str, entity.unique_id): appliance_ha_id for entity in entities_to_add}
)
entities.extend(entities_to_add)
async_add_entities(entities)
@@ -143,7 +143,7 @@ def _handle_depaired_appliance(
) -> None:
"""Handle a removed appliance."""
for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items():
if appliance_id not in entry.runtime_data.data:
if appliance_id not in entry.runtime_data.appliance_coordinators:
known_entity_unique_ids.pop(entity_unique_id, None)
if appliance_id in changed_options_listener_remove_callbacks:
for listener in changed_options_listener_remove_callbacks.pop(
@@ -156,11 +156,11 @@ def setup_home_connect_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
[HomeConnectApplianceCoordinator], list[HomeConnectEntity]
],
async_add_entities: AddConfigEntryEntitiesCallback,
get_option_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
]
| None = None,
@@ -172,7 +172,7 @@ def setup_home_connect_entry(
)
entry.async_on_unload(
entry.runtime_data.async_add_special_listener(
entry.runtime_data.async_add_global_listener(
partial(
_handle_paired_or_connected_appliance,
hass,
@@ -190,7 +190,7 @@ def setup_home_connect_entry(
)
)
entry.async_on_unload(
entry.runtime_data.async_add_special_listener(
entry.runtime_data.async_add_global_listener(
partial(
_handle_depaired_appliance,
entry,
@@ -3,11 +3,9 @@
from __future__ import annotations
from asyncio import sleep as asyncio_sleep
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import (
@@ -33,7 +31,6 @@ from aiohomeconnect.model.error import (
UnauthorizedError,
)
from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption
from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
@@ -54,7 +51,7 @@ _LOGGER = logging.getLogger(__name__)
MAX_EXECUTIONS_TIME_WINDOW = 60 * 60 # 1 hour
MAX_EXECUTIONS = 8
type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
type HomeConnectConfigEntry = ConfigEntry[HomeConnectRuntimeData]
@dataclass(frozen=True, kw_only=True)
@@ -96,12 +93,14 @@ class HomeConnectApplianceData:
)
class HomeConnectCoordinator(
DataUpdateCoordinator[dict[str, HomeConnectApplianceData]]
):
"""Class to manage fetching Home Connect data."""
class HomeConnectRuntimeData:
"""Class to manage Home Connect's integration runtime data.
It also handles the API server-sent events.
"""
config_entry: HomeConnectConfigEntry
appliance_coordinators: dict[str, HomeConnectApplianceCoordinator]
def __init__(
self,
@@ -110,64 +109,14 @@ class HomeConnectCoordinator(
client: HomeConnectClient,
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=config_entry.entry_id,
)
self.hass = hass
self.config_entry = config_entry
self.client = client
self._special_listeners: dict[
self.global_listeners: dict[
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
] = {}
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]]:
"""Return a dict of all listeners registered for a given context."""
listeners: dict[tuple[str, EventKey], list[CALLBACK_TYPE]] = defaultdict(list)
for listener, context in list(self._listeners.values()):
assert isinstance(context, tuple)
listeners[context].append(listener)
return listeners
@callback
def async_add_listener(
self, update_callback: CALLBACK_TYPE, context: Any = None
) -> Callable[[], None]:
"""Listen for data updates."""
remove_listener = super().async_add_listener(update_callback, context)
self.__dict__.pop("context_listeners", None)
def remove_listener_and_invalidate_context_listeners() -> None:
remove_listener()
self.__dict__.pop("context_listeners", None)
return remove_listener_and_invalidate_context_listeners
@callback
def async_add_special_listener(
self,
update_callback: CALLBACK_TYPE,
context: tuple[EventKey, ...],
) -> Callable[[], None]:
"""Listen for special data updates.
These listeners will not be called on refresh.
"""
@callback
def remove_listener() -> None:
"""Remove update listener."""
self._special_listeners.pop(remove_listener)
if not self._special_listeners:
self._unschedule_refresh()
self._special_listeners[remove_listener] = (update_callback, context)
return remove_listener
self.appliance_coordinators = {}
@callback
def start_event_listener(self) -> None:
@@ -178,7 +127,7 @@ class HomeConnectCoordinator(
f"home_connect-events_listener_task-{self.config_entry.entry_id}",
)
async def _event_listener(self) -> None: # noqa: C901
async def _event_listener(self) -> None:
"""Match event with listener for event type."""
retry_time = 10
while True:
@@ -186,129 +135,37 @@ class HomeConnectCoordinator(
async for event_message in self.client.stream_all_events():
retry_time = 10
event_message_ha_id = event_message.ha_id
if (
event_message_ha_id in self.data
and not self.data[event_message_ha_id].info.connected
):
self.data[event_message_ha_id].info.connected = True
self._call_all_event_listeners_for_appliance(
event_message_ha_id
if event_message_ha_id in self.appliance_coordinators:
if event_message.type == EventType.DEPAIRED:
appliance_coordinator = self.appliance_coordinators.pop(
event_message.ha_id
)
await appliance_coordinator.async_shutdown()
else:
appliance_coordinator = self.appliance_coordinators[
event_message.ha_id
]
if not appliance_coordinator.data.info.connected:
appliance_coordinator.data.info.connected = True
appliance_coordinator.call_all_event_listeners()
elif event_message.type == EventType.PAIRED:
appliance_coordinator = HomeConnectApplianceCoordinator(
self.hass,
self.config_entry,
self.client,
self.global_listeners,
await self.client.get_specific_appliance(
event_message_ha_id
),
)
await appliance_coordinator.async_register_shutdown()
self.appliance_coordinators[event_message.ha_id] = (
appliance_coordinator
)
match event_message.type:
case EventType.STATUS:
statuses = self.data[event_message_ha_id].status
for event in event_message.data.items:
status_key = StatusKey(event.key)
if status_key in statuses:
statuses[status_key].value = event.value
else:
statuses[status_key] = Status(
key=status_key,
raw_key=status_key.value,
value=event.value,
)
if (
status_key == StatusKey.BSH_COMMON_OPERATION_STATE
and event.value == BSH_OPERATION_STATE_PAUSE
and CommandKey.BSH_COMMON_RESUME_PROGRAM
not in (
commands := self.data[
event_message_ha_id
].commands
)
):
# All the appliances that can be paused
# should have the resume command available.
commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM)
for (
listener,
context,
) in self._special_listeners.values():
if (
EventKey.BSH_COMMON_APPLIANCE_DEPAIRED
not in context
):
listener()
self._call_event_listener(event_message)
case EventType.NOTIFY:
settings = self.data[event_message_ha_id].settings
events = self.data[event_message_ha_id].events
for event in event_message.data.items:
event_key = event.key
if event_key in SettingKey.__members__.values(): # type: ignore[comparison-overlap]
setting_key = SettingKey(event_key)
if setting_key in settings:
settings[setting_key].value = event.value
else:
settings[setting_key] = GetSetting(
key=setting_key,
raw_key=setting_key.value,
value=event.value,
)
else:
event_value = event.value
if event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
) and isinstance(event_value, str):
await self.update_options(
event_message_ha_id,
event_key,
ProgramKey(event_value),
)
events[event_key] = event
self._call_event_listener(event_message)
case EventType.EVENT:
events = self.data[event_message_ha_id].events
for event in event_message.data.items:
events[event.key] = event
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
)
appliance_data = await self._get_appliance_data(
appliance_info, self.data.get(appliance_info.ha_id)
)
if event_message_ha_id not in self.data:
self.data[event_message_ha_id] = 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(
event_message_ha_id
)
case EventType.DISCONNECTED:
self.data[event_message_ha_id].info.connected = False
self._call_all_event_listeners_for_appliance(
event_message_ha_id
)
case EventType.DEPAIRED:
device = self.device_registry.async_get_device(
identifiers={(DOMAIN, event_message_ha_id)}
)
if device:
self.device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.data.pop(event_message_ha_id, None)
for listener, context in self._special_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
listener()
assert appliance_coordinator
await appliance_coordinator.event_listener(event_message)
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
_LOGGER.debug(
@@ -327,58 +184,27 @@ class HomeConnectCoordinator(
break
@callback
def _call_event_listener(self, event_message: EventMessage) -> None:
"""Call listener for event."""
for event in event_message.data.items:
for listener in self.context_listeners.get(
(event_message.ha_id, event.key), []
):
listener()
def async_add_global_listener(
self,
update_callback: CALLBACK_TYPE,
context: tuple[EventKey, ...],
) -> Callable[[], None]:
"""Listen for special data updates.
@callback
def _call_all_event_listeners_for_appliance(self, ha_id: str) -> None:
for listener, context in self._listeners.values():
if isinstance(context, tuple) and context[0] == ha_id:
listener()
These listeners will not be called on refresh.
"""
async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
"""Fetch data from Home Connect."""
await self._async_setup()
@callback
def remove_listener() -> None:
"""Remove update listener."""
self.global_listeners.pop(remove_listener)
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
self.global_listeners[remove_listener] = (update_callback, context)
for listener, context in self._special_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
listener()
return remove_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())
async def setup_appliance_coordinators(self) -> None:
"""Set up the coordinators for each appliance."""
try:
appliances = await self.client.get_home_appliances()
except UnauthorizedError as error:
@@ -388,9 +214,7 @@ class HomeConnectCoordinator(
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
except HomeConnectError as error:
for appliance_data in self.data.values():
appliance_data.info.connected = False
raise UpdateFailed(
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="fetch_api_error",
translation_placeholders=get_dict_from_home_connect_error(error),
@@ -404,52 +228,237 @@ class HomeConnectCoordinator(
name=appliance.name,
model=appliance.vib,
)
if appliance.ha_id not in self.data:
self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance)
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)}
new_coordinator = HomeConnectApplianceCoordinator(
self.hass,
self.config_entry,
self.client,
self.global_listeners,
appliance,
)
if device:
self.device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
await new_coordinator.async_register_shutdown()
self.appliance_coordinators[appliance.ha_id] = new_coordinator
# 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:
class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectApplianceData]):
"""Class to manage fetching Home Connect appliance data."""
def __init__(
self,
hass: HomeAssistant,
config_entry: HomeConnectConfigEntry,
client: HomeConnectClient,
global_listeners: dict[
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
],
appliance: HomeAppliance,
) -> None:
"""Initialize."""
# Don't set config_entry attribute to avoid default behavior.
# HomeConnectApplianceCoordinator doesn't follow the
# config entry lifecycle so we can't use the default behavior.
self._config_entry = config_entry
super().__init__(
hass,
_LOGGER,
config_entry=None,
name=f"{self._config_entry.entry_id}-{appliance.ha_id}",
)
self.client = client
self.device_registry = dr.async_get(self.hass)
self.global_listeners = global_listeners
self.data = HomeConnectApplianceData.empty(appliance)
self._execution_tracker: list[float] = []
def _get_listeners_for_event_key(self, event_key: EventKey) -> list[CALLBACK_TYPE]:
return [
listener
for listener, context in list(self._listeners.values())
if context == event_key
]
async def event_listener(self, event_message: EventMessage) -> None:
"""Match event with listener for event type."""
match event_message.type:
case EventType.STATUS:
statuses = self.data.status
for event in event_message.data.items:
status_key = StatusKey(event.key)
if status_key in statuses:
statuses[status_key].value = event.value
else:
statuses[status_key] = Status(
key=status_key,
raw_key=status_key.value,
value=event.value,
)
if (
status_key == StatusKey.BSH_COMMON_OPERATION_STATE
and event.value == BSH_OPERATION_STATE_PAUSE
and CommandKey.BSH_COMMON_RESUME_PROGRAM
not in (commands := self.data.commands)
):
# All the appliances that can be paused
# should have the resume command available.
commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM)
for (
listener,
context,
) in self.global_listeners.values():
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context:
listener()
self._call_event_listener(event_message)
case EventType.NOTIFY:
settings = self.data.settings
events = self.data.events
for event in event_message.data.items:
event_key = event.key
if event_key in SettingKey.__members__.values(): # type: ignore[comparison-overlap]
setting_key = SettingKey(event_key)
if setting_key in settings:
settings[setting_key].value = event.value
else:
settings[setting_key] = GetSetting(
key=setting_key,
raw_key=setting_key.value,
value=event.value,
)
else:
event_value = event.value
if event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
) and isinstance(event_value, str):
await self.update_options(
event_key,
ProgramKey(event_value),
)
events[event_key] = event
self._call_event_listener(event_message)
case EventType.EVENT:
events = self.data.events
for event in event_message.data.items:
events[event.key] = event
self._call_event_listener(event_message)
case EventType.CONNECTED | EventType.PAIRED:
if self.refreshed_too_often_recently():
return
await self.async_refresh()
for (
listener,
context,
) in self.global_listeners.values():
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context:
listener()
self.call_all_event_listeners()
case EventType.DISCONNECTED:
self.data.info.connected = False
self.call_all_event_listeners()
case EventType.DEPAIRED:
device = self.device_registry.async_get_device(
identifiers={(DOMAIN, self.data.info.ha_id)}
)
if device:
self.device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self._config_entry.entry_id,
)
for (
listener,
context,
) in self.global_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
listener()
@callback
def _call_event_listener(self, event_message: EventMessage) -> None:
"""Call listener for event."""
for event in event_message.data.items:
for listener in self._get_listeners_for_event_key(event.key):
listener()
async def _get_appliance_data(
self,
appliance: HomeAppliance,
appliance_data_to_update: HomeConnectApplianceData | None = None,
) -> HomeConnectApplianceData:
@callback
def call_all_event_listeners(self) -> None:
"""Call all listeners."""
for listener, _ in self._listeners.values():
listener()
async def _async_update_data(self) -> HomeConnectApplianceData:
"""Fetch data from Home Connect."""
while True:
try:
try:
self.data.info.connected = (
await self.client.get_specific_appliance(self.data.info.ha_id)
).connected
except HomeConnectError:
self.data.info.connected = False
raise
await self.get_appliance_data()
except TooManyRequestsError as err:
delay = err.retry_after or API_DEFAULT_RETRY_AFTER
_LOGGER.warning(
"Rate limit exceeded, retrying in %s seconds: %s",
delay,
err,
)
await asyncio_sleep(delay)
except UnauthorizedError as error:
# Reauth flow need to be started explicitly as
# we don't use the default config entry coordinator.
self._config_entry.async_start_reauth(self.hass)
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
except HomeConnectError as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_api_error",
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
else:
break
for (
listener,
context,
) in self.global_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
listener()
return self.data
async def get_appliance_data(self) -> None:
"""Get appliance data."""
appliance = self.data.info
self.device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
config_entry_id=self._config_entry.entry_id,
identifiers={(DOMAIN, appliance.ha_id)},
manufacturer=appliance.brand,
name=appliance.name,
model=appliance.vib,
)
if not appliance.connected:
_LOGGER.debug(
"Appliance %s is not connected, skipping data fetch",
appliance.ha_id,
self.data.update(HomeConnectApplianceData.empty(appliance))
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="appliance_disconnected",
translation_placeholders={
"appliance_name": appliance.name,
"ha_id": appliance.ha_id,
},
)
if appliance_data_to_update:
appliance_data_to_update.info.connected = False
return appliance_data_to_update
return HomeConnectApplianceData.empty(appliance)
try:
settings = {
setting.key: setting
@@ -521,9 +530,7 @@ class HomeConnectCoordinator(
current_program_key = program.key
program_options = program.options
if current_program_key:
options = await self.get_options_definitions(
appliance.ha_id, current_program_key
)
options = await self.get_options_definitions(current_program_key)
for option in program_options or []:
option_event_key = EventKey(option.key)
events[option_event_key] = Event(
@@ -550,23 +557,20 @@ class HomeConnectCoordinator(
except HomeConnectError:
commands = set()
appliance_data = HomeConnectApplianceData(
commands=commands,
events=events,
info=appliance,
options=options,
programs=programs,
settings=settings,
status=status,
self.data.update(
HomeConnectApplianceData(
commands=commands,
events=events,
info=appliance,
options=options,
programs=programs,
settings=settings,
status=status,
)
)
if appliance_data_to_update:
appliance_data_to_update.update(appliance_data)
appliance_data = appliance_data_to_update
return appliance_data
async def get_options_definitions(
self, ha_id: str, program_key: ProgramKey
self, program_key: ProgramKey
) -> dict[OptionKey, ProgramDefinitionOption]:
"""Get options with constraints for appliance."""
if program_key is ProgramKey.UNKNOWN:
@@ -576,7 +580,7 @@ class HomeConnectCoordinator(
option.key: option
for option in (
await self.client.get_available_program(
ha_id, program_key=program_key
self.data.info.ha_id, program_key=program_key
)
).options
or []
@@ -586,20 +590,20 @@ class HomeConnectCoordinator(
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching options for %s: %s",
ha_id,
self.data.info.ha_id,
error,
)
return {}
async def update_options(
self, ha_id: str, event_key: EventKey, program_key: ProgramKey
self, event_key: EventKey, program_key: ProgramKey
) -> None:
"""Update options for appliance."""
options = self.data[ha_id].options
events = self.data[ha_id].events
options = self.data.options
events = self.data.events
options_to_notify = options.copy()
options.clear()
options.update(await self.get_options_definitions(ha_id, program_key))
options.update(await self.get_options_definitions(program_key))
for option in options.values():
option_value = option.constraints.default if option.constraints else None
@@ -617,21 +621,18 @@ class HomeConnectCoordinator(
)
options_to_notify.update(options)
for option_key in options_to_notify:
for listener in self.context_listeners.get(
(ha_id, EventKey(option_key)),
[],
):
for listener in self._get_listeners_for_event_key(EventKey(option_key)):
listener()
def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool:
def refreshed_too_often_recently(self) -> bool:
"""Check if the appliance data hasn't been refreshed too often recently."""
now = self.hass.loop.time()
execution_tracker = self._execution_tracker[appliance_ha_id]
execution_tracker = self._execution_tracker
initial_len = len(execution_tracker)
execution_tracker = self._execution_tracker[appliance_ha_id] = [
execution_tracker = self._execution_tracker = [
timestamp
for timestamp in execution_tracker
if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW
@@ -647,7 +648,7 @@ class HomeConnectCoordinator(
"and they will be enabled again whenever the connection stabilizes. "
"Consider trying to unplug the appliance "
"for a while to perform a soft reset",
self.data[appliance_ha_id].info.name,
self.data.info.name,
MAX_EXECUTIONS,
MAX_EXECUTIONS_TIME_WINDOW // 60,
)
@@ -656,7 +657,7 @@ class HomeConnectCoordinator(
_LOGGER.info(
'Connected/paired events from the appliance "%s" have stabilized,'
" updates have been re-enabled",
self.data[appliance_ha_id].info.name,
self.data.info.name,
)
return False
@@ -47,8 +47,10 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
appliance.info.ha_id: await _generate_appliance_diagnostics(appliance)
for appliance in entry.runtime_data.data.values()
appliance_coordinator.data.info.ha_id: await _generate_appliance_diagnostics(
appliance_coordinator.data
)
for appliance_coordinator in entry.runtime_data.appliance_coordinators.values()
}
@@ -59,4 +61,6 @@ async def async_get_device_diagnostics(
ha_id = next(
(identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN),
)
return await _generate_appliance_diagnostics(entry.runtime_data.data[ha_id])
return await _generate_appliance_diagnostics(
entry.runtime_data.appliance_coordinators[ha_id].data
)
@@ -22,34 +22,34 @@ from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import API_DEFAULT_RETRY_AFTER, DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
from .coordinator import HomeConnectApplianceCoordinator
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
class HomeConnectEntity(CoordinatorEntity[HomeConnectApplianceCoordinator]):
"""Generic Home Connect entity (base class)."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: EntityDescription,
context_override: Any | None = None,
) -> None:
"""Initialize the entity."""
context = (appliance.info.ha_id, EventKey(desc.key))
appliance_ha_id = appliance_coordinator.data.info.ha_id
context = EventKey(desc.key)
if context_override is not None:
context = context_override
super().__init__(coordinator, context)
self.appliance = appliance
super().__init__(appliance_coordinator, context)
self.appliance = appliance_coordinator.data
self.entity_description = desc
self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}"
self._attr_unique_id = f"{appliance_ha_id}-{desc.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, appliance.info.ha_id)},
identifiers={(DOMAIN, appliance_ha_id)},
)
self.update_native_value()
+8 -17
View File
@@ -22,11 +22,7 @@ from homeassistant.util import color as color_util
from .common import setup_home_connect_entry
from .const import BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
@@ -78,14 +74,13 @@ LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
HomeConnectLight(entry.runtime_data, appliance, description)
HomeConnectLight(appliance_coordinator, description)
for description in LIGHTS
if description.key in appliance.settings
if description.key in appliance_coordinator.data.settings
]
@@ -110,8 +105,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: HomeConnectLightEntityDescription,
) -> None:
"""Initialize the entity."""
@@ -119,7 +113,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
def get_setting_key_if_setting_exists(
setting_key: SettingKey | None,
) -> SettingKey | None:
if setting_key and setting_key in appliance.settings:
if setting_key and setting_key in appliance_coordinator.data.settings:
return setting_key
return None
@@ -134,7 +128,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
)
self._brightness_scale = desc.brightness_scale
super().__init__(coordinator, appliance, desc)
super().__init__(appliance_coordinator, desc)
match (self._brightness_key, self._custom_color_key):
case (None, None):
@@ -287,10 +281,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
self.async_on_remove(
self.coordinator.async_add_listener(
self._handle_coordinator_update,
(
self.appliance.info.ha_id,
EventKey(key),
),
EventKey(key),
)
)
@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry, should_add_option_entity
from .const import DOMAIN, UNIT_MAP
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
from .utils import get_dict_from_home_connect_error
@@ -123,28 +123,26 @@ NUMBER_OPTIONS = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
HomeConnectNumberEntity(entry.runtime_data, appliance, description)
HomeConnectNumberEntity(appliance_coordinator, description)
for description in NUMBERS
if description.key in appliance.settings
if description.key in appliance_coordinator.data.settings
]
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description)
HomeConnectOptionNumberEntity(appliance_coordinator, description)
for description in NUMBER_OPTIONS
if should_add_option_entity(
description, appliance, entity_registry, Platform.NUMBER
description, appliance_coordinator.data, entity_registry, Platform.NUMBER
)
]
+18 -28
View File
@@ -41,11 +41,7 @@ from .const import (
VENTING_LEVEL_OPTIONS,
WARMING_LEVEL_OPTIONS,
)
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
@@ -336,37 +332,37 @@ PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
*(
[
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
HomeConnectProgramSelectEntity(appliance_coordinator, desc)
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
]
if appliance.programs
if appliance_coordinator.data.programs
else []
),
*[
HomeConnectSelectEntity(entry.runtime_data, appliance, desc)
HomeConnectSelectEntity(appliance_coordinator, desc)
for desc in SELECT_ENTITY_DESCRIPTIONS
if desc.key in appliance.settings
if desc.key in appliance_coordinator.data.settings
],
]
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
"""Get a list of entities."""
return [
HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc)
HomeConnectSelectOptionEntity(appliance_coordinator, desc)
for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS
if should_add_option_entity(desc, appliance, entity_registry, Platform.SELECT)
if should_add_option_entity(
desc, appliance_coordinator.data, entity_registry, Platform.SELECT
)
]
@@ -392,14 +388,12 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: HomeConnectProgramSelectEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
appliance,
appliance_coordinator,
desc,
)
self.set_options()
@@ -429,7 +423,7 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
self.async_on_remove(
self.coordinator.async_add_listener(
self.refresh_options,
(self.appliance.info.ha_id, EventKey.BSH_COMMON_APPLIANCE_CONNECTED),
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
)
)
@@ -470,15 +464,13 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: HomeConnectSelectEntityDescription,
) -> None:
"""Initialize the entity."""
self._original_option_keys = set(desc.values_translation_key)
super().__init__(
coordinator,
appliance,
appliance_coordinator,
desc,
)
@@ -547,15 +539,13 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity):
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: HomeConnectSelectEntityDescription,
) -> None:
"""Initialize the entity."""
self._original_option_keys = set(desc.values_translation_key)
super().__init__(
coordinator,
appliance,
appliance_coordinator,
desc,
)
+10 -10
View File
@@ -26,7 +26,7 @@ from .const import (
BSH_OPERATION_STATE_RUN,
UNIT_MAP,
)
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity, constraint_fetcher
_LOGGER = logging.getLogger(__name__)
@@ -508,26 +508,26 @@ EVENT_SENSORS = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
*[
HomeConnectEventSensor(entry.runtime_data, appliance, description)
HomeConnectEventSensor(appliance_coordinator, description)
for description in EVENT_SENSORS
if description.appliance_types
and appliance.info.type in description.appliance_types
and appliance_coordinator.data.info.type in description.appliance_types
],
*[
HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
HomeConnectProgramSensor(appliance_coordinator, desc)
for desc in BSH_PROGRAM_SENSORS
if desc.appliance_types and appliance.info.type in desc.appliance_types
if desc.appliance_types
and appliance_coordinator.data.info.type in desc.appliance_types
],
*[
HomeConnectSensor(entry.runtime_data, appliance, description)
HomeConnectSensor(appliance_coordinator, description)
for description in SENSORS
if description.key in appliance.status
if description.key in appliance_coordinator.data.status
],
]
@@ -607,7 +607,7 @@ class HomeConnectProgramSensor(HomeConnectSensor):
self.async_on_remove(
self.coordinator.async_add_listener(
self._handle_operation_state_event,
(self.appliance.info.ha_id, EventKey.BSH_COMMON_STATUS_OPERATION_STATE),
EventKey.BSH_COMMON_STATUS_OPERATION_STATE,
)
)
@@ -1290,6 +1290,9 @@
}
},
"exceptions": {
"appliance_disconnected": {
"message": "Appliance {appliance_name} ({ha_id}) is disconnected"
},
"appliance_not_found": {
"message": "Appliance for device ID {device_id} not found"
},
@@ -16,7 +16,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from .common import setup_home_connect_entry, should_add_option_entity
from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .utils import get_dict_from_home_connect_error
@@ -170,36 +170,32 @@ SWITCH_OPTIONS = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectEntity] = []
if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings:
if SettingKey.BSH_COMMON_POWER_STATE in appliance_coordinator.data.settings:
entities.append(
HomeConnectPowerSwitch(
entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION
)
HomeConnectPowerSwitch(appliance_coordinator, POWER_SWITCH_DESCRIPTION)
)
entities.extend(
HomeConnectSwitch(entry.runtime_data, appliance, description)
HomeConnectSwitch(appliance_coordinator, description)
for description in SWITCHES
if description.key in appliance.settings
if description.key in appliance_coordinator.data.settings
)
return entities
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description)
HomeConnectSwitchOptionEntity(appliance_coordinator, description)
for description in SWITCH_OPTIONS
if should_add_option_entity(
description, appliance, entity_registry, Platform.SWITCH
description, appliance_coordinator.data, entity_registry, Platform.SWITCH
)
]
+21 -14
View File
@@ -165,7 +165,7 @@ async def mock_integration_setup(
def _get_set_program_side_effect(
event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey
event_queue: asyncio.Queue[list[EventMessage | Exception]], event_key: EventKey
):
"""Set program side effect."""
@@ -208,7 +208,7 @@ def _get_set_program_side_effect(
def _get_set_setting_side_effect(
event_queue: asyncio.Queue[list[EventMessage]],
event_queue: asyncio.Queue[list[EventMessage | Exception]],
):
"""Set settings side effect."""
@@ -239,7 +239,7 @@ def _get_set_setting_side_effect(
def _get_set_program_options_side_effect(
event_queue: asyncio.Queue[list[EventMessage]],
event_queue: asyncio.Queue[list[EventMessage | Exception]],
):
"""Set programs side effect."""
@@ -279,6 +279,16 @@ def _get_set_program_options_side_effect(
return set_program_options_side_effect
def _get_specific_appliance_side_effect(
appliances: list[HomeAppliance], ha_id: str
) -> HomeAppliance:
"""Get specific appliance side effect."""
for appliance_ in appliances:
if appliance_.ha_id == ha_id:
return appliance_
pytest.fail(f"Mock didn't include appliance with id {ha_id}")
@pytest.fixture(name="client")
def mock_client(
appliances: list[HomeAppliance],
@@ -291,9 +301,9 @@ def mock_client(
autospec=HomeConnectClient,
)
event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue()
event_queue: asyncio.Queue[list[EventMessage | Exception]] = asyncio.Queue()
async def add_events(events: list[EventMessage]) -> None:
async def add_events(events: list[EventMessage | Exception]) -> None:
await event_queue.put(events)
mock.add_events = add_events
@@ -327,19 +337,13 @@ def mock_client(
"""Mock stream_all_events."""
while True:
for event in await event_queue.get():
if isinstance(event, Exception):
raise event
yield event
mock.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances(appliances))
def _get_specific_appliance_side_effect(ha_id: str) -> HomeAppliance:
"""Get specific appliance side effect."""
for appliance_ in appliances:
if appliance_.ha_id == ha_id:
return appliance_
raise HomeConnectApiError("error.key", "error description")
mock.get_specific_appliance = AsyncMock(
side_effect=_get_specific_appliance_side_effect
side_effect=lambda ha_id: _get_specific_appliance_side_effect(appliances, ha_id)
)
mock.stream_all_events = stream_all_events
@@ -468,6 +472,9 @@ def mock_client_with_exception(
appliances = [appliance] if appliance else appliances
mock.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances(appliances))
mock.get_specific_appliance = AsyncMock(
side_effect=lambda ha_id: _get_specific_appliance_side_effect(appliances, ha_id)
)
mock.stream_all_events = stream_all_events
mock.start_program = AsyncMock(side_effect=exception)
+149 -18
View File
@@ -2,6 +2,7 @@
from collections.abc import Awaitable, Callable
from datetime import timedelta
import re
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
@@ -23,6 +24,8 @@ from aiohomeconnect.model.error import (
HomeConnectApiError,
HomeConnectError,
HomeConnectRequestError,
TooManyRequestsError,
UnauthorizedError,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -101,7 +104,7 @@ async def test_coordinator_failure_refresh_and_stream(
assert state
assert state.state != STATE_UNAVAILABLE
client.get_home_appliances.side_effect = HomeConnectError()
client.get_specific_appliance.side_effect = HomeConnectError()
# Force a coordinator refresh.
await hass.services.async_call(
@@ -118,10 +121,8 @@ async def test_coordinator_failure_refresh_and_stream(
# Test that the entity becomes available again after a successful update.
client.get_home_appliances.side_effect = None
client.get_home_appliances.return_value = ArrayOfHomeAppliances(
[HomeAppliance.from_json(appliance_data)]
)
client.get_specific_appliance.side_effect = None
client.get_specific_appliance.return_value = HomeAppliance.from_json(appliance_data)
# Move time forward to pass the debounce time.
freezer.tick(timedelta(hours=1))
@@ -144,7 +145,7 @@ async def test_coordinator_failure_refresh_and_stream(
# Test that the event stream makes the entity go available too.
# First make the entity unavailable.
client.get_home_appliances.side_effect = HomeConnectError()
client.get_specific_appliance.side_effect = HomeConnectError()
# Move time forward to pass the debounce time
freezer.tick(timedelta(hours=1))
@@ -165,10 +166,8 @@ async def test_coordinator_failure_refresh_and_stream(
assert state.state == STATE_UNAVAILABLE
# Now make the entity available again.
client.get_home_appliances.side_effect = None
client.get_home_appliances.return_value = ArrayOfHomeAppliances(
[HomeAppliance.from_json(appliance_data)]
)
client.get_specific_appliance.side_effect = None
client.get_specific_appliance.return_value = HomeAppliance.from_json(appliance_data)
# One event should make all entities for this appliance available again.
event_message = EventMessage(
@@ -509,6 +508,7 @@ async def test_devices_updated_on_refresh(
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
platforms: list[str],
) -> None:
"""Test handling of devices added or deleted while event stream is down."""
appliances: list[HomeAppliance] = (
@@ -530,18 +530,56 @@ async def test_devices_updated_on_refresh(
client.get_home_appliances = AsyncMock(
return_value=ArrayOfHomeAppliances(appliances[1:3]),
)
await hass.services.async_call(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
{ATTR_ENTITY_ID: "switch.dishwasher_power"},
blocking=True,
)
with (
patch("homeassistant.components.home_connect.PLATFORMS", platforms),
patch(
"homeassistant.components.home_connect.HomeConnectClient",
return_value=client,
),
):
await client.add_events([HomeConnectApiError("error.key", "error description")])
await hass.async_block_till_done()
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)})
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
async def test_paired_event(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
) -> None:
"""Test that Home Connect API is not fetched after pairing a disconnected device."""
client.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances([]))
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.PAIRED,
data=ArrayOfEvents([]),
)
]
)
await hass.async_block_till_done()
# Ideally, the get_specific_appliance should be called once
# but because paired event is not pretty frequent, we allow it to be
# called twice. One when creating the coordinator,
# and another on first coordinator refresh (to get connected status)
assert client.get_specific_appliance.call_count == 2
for call in client.get_specific_appliance.call_args_list:
assert call.args[0] == appliance.ha_id
for method in INITIAL_FETCH_CLIENT_METHODS:
getattr(client, method).assert_awaited_once()
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
async def test_paired_disconnected_devices_not_fetching(
hass: HomeAssistant,
@@ -567,9 +605,15 @@ async def test_paired_disconnected_devices_not_fetching(
)
await hass.async_block_till_done()
client.get_specific_appliance.assert_awaited_once_with(appliance.ha_id)
# Ideally, the get_specific_appliance should be called once
# but because paired event is not pretty frequent, we allow it to be
# called twice. One when creating the coordinator,
# and another on first coordinator refresh (to get connected status)
assert client.get_specific_appliance.call_count == 2
for call in client.get_specific_appliance.call_args_list:
assert call.args[0] == appliance.ha_id
for method in INITIAL_FETCH_CLIENT_METHODS:
assert getattr(client, method).call_count == 0
getattr(client, method).assert_not_awaited()
async def test_coordinator_disabling_updates_for_appliance(
@@ -757,3 +801,90 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r
await hass.async_block_till_done()
assert hass.states.is_state("switch.dishwasher_power", STATE_OFF)
@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True)
async def test_auth_error_while_updating_appliance(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
) -> None:
"""Test that the configuration entry is set to require reauth when an auth error happens."""
entity_id = "switch.dishwasher_power"
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
assert hass.states.get(entity_id)
client.get_specific_appliance = AsyncMock(
side_effect=UnauthorizedError("unauthorized")
)
await async_setup_component(hass, HA_DOMAIN, {})
await hass.services.async_call(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
flows_in_progress = hass.config_entries.flow.async_progress()
assert len(flows_in_progress) == 1
result = flows_in_progress[0]
assert result["step_id"] == "reauth_confirm"
assert result["context"]["entry_id"] == config_entry.entry_id
assert result["context"]["source"] == "reauth"
@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True)
@pytest.mark.parametrize(
("side_effect", "log_level", "string_in_log"),
[
(
HomeConnectError("mocked-error"),
"ERROR",
r".*mocked-error.*",
),
(
[
TooManyRequestsError("rate-limit-error", retry_after=0.1),
Exception("error-to-stop-retrying"),
],
"WARNING",
r"Rate limit exceeded, retrying in 0.1 seconds.*rate-limit-error",
),
],
)
async def test_other_errors_while_updating_appliance(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
caplog: pytest.LogCaptureFixture,
side_effect: HomeConnectError | list[Exception],
log_level: str,
string_in_log: str,
) -> None:
"""Test that other errors are informed through the logs."""
entity_id = "switch.dishwasher_power"
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
assert hass.states.get(entity_id)
client.get_specific_appliance = AsyncMock(side_effect=side_effect)
await async_setup_component(hass, HA_DOMAIN, {})
caplog.clear()
await hass.services.async_call(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert any(
record.levelname == log_level and re.search(string_in_log, record.message)
for record in caplog.records
)
+1 -15
View File
@@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock
from aiohomeconnect.model import (
ArrayOfEvents,
ArrayOfHomeAppliances,
ArrayOfPrograms,
Event,
EventKey,
@@ -334,20 +333,7 @@ async def test_program_options_retrieval_after_appliance_connection(
option_entity_id: str,
) -> None:
"""Test that the options are correctly retrieved at the start and updated on program updates."""
array_of_home_appliances = client.get_home_appliances.return_value
async def get_home_appliances_with_options_mock() -> ArrayOfHomeAppliances:
return ArrayOfHomeAppliances(
[
appliance
for appliance in array_of_home_appliances.homeappliances
if appliance.ha_id != appliance.ha_id
]
)
client.get_home_appliances = AsyncMock(
side_effect=get_home_appliances_with_options_mock
)
appliance.connected = False
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
@@ -194,35 +194,6 @@ async def test_set_program_and_options_exceptions(
await hass.services.async_call(**service_call)
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
@pytest.mark.parametrize(
"service_call",
SERVICE_KV_CALL_PARAMS,
)
async def test_services_exception_device_id(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
client_with_exception: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
service_call: dict[str, Any],
) -> None:
"""Raise a HomeAssistantError when there is an API error."""
assert await integration_setup(client_with_exception)
assert config_entry.state is ConfigEntryState.LOADED
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, appliance.ha_id)},
)
service_call["service_data"]["device_id"] = device_entry.id
with pytest.raises(HomeAssistantError):
await hass.services.async_call(**service_call)
async def test_services_appliance_not_found(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,