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:
committed by
GitHub
parent
725b45db7f
commit
c9b5f5f2c1
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user