From b637129208e45620a1e6d264b92844f16787f49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 30 Jan 2025 02:42:41 +0100 Subject: [PATCH] Migrate from homeconnect dependency to aiohomeconnect (#136116) * Migrate from homeconnect dependency to aiohomeconnect * Reload the integration if there is an API error on event stream * fix typos at coordinator tests * Setup config entry at coordinator tests * fix ruff * Bump aiohomeconnect to version 0.11.4 * Fix set program options * Use context based updates at coordinator * Improved how `context_callbacks` cache is invalidated * fix * fixes and improvements at coordinator Co-authored-by: Martin Hjelmare * Remove stale Entity inheritance * Small improvement for light subscriptions * Remove non-needed function It had its purpose before some refactoring before the firs commit, no is no needed as is only used at HomeConnectEntity constructor * Static methods and variables at conftest * Refresh the data after an event stream interruption * Cleaned debug logs * Fetch programs at coordinator * Improvements Co-authored-by: Martin Hjelmare * Simplify obtaining power settings from coordinator data Co-authored-by: Martin Hjelmare * Remove unnecessary statement * use `is UNDEFINED` instead of `isinstance` * Request power setting only when it is strictly necessary * Bump aiohomeconnect to 0.12.1 * use raw keys for diagnostics * Use keyword arguments where needed * Remove unnecessary statements Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 325 +++++----- homeassistant/components/home_connect/api.py | 79 +-- .../home_connect/application_credentials.py | 4 +- .../components/home_connect/binary_sensor.py | 102 ++- .../components/home_connect/const.py | 129 +--- .../components/home_connect/coordinator.py | 258 ++++++++ .../components/home_connect/diagnostics.py | 49 +- .../components/home_connect/entity.py | 59 +- .../components/home_connect/light.py | 241 +++---- .../components/home_connect/manifest.json | 2 +- .../components/home_connect/number.py | 101 +-- .../components/home_connect/select.py | 278 ++------- .../components/home_connect/sensor.py | 224 ++++--- .../components/home_connect/strings.json | 39 +- .../components/home_connect/switch.py | 290 ++++----- homeassistant/components/home_connect/time.py | 61 +- .../components/home_connect/utils.py | 29 + requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/home_connect/conftest.py | 380 ++++++----- .../home_connect/fixtures/settings.json | 24 +- .../snapshots/test_diagnostics.ambr | 588 +++++++----------- .../home_connect/test_binary_sensor.py | 145 +++-- .../home_connect/test_config_flow.py | 7 +- .../home_connect/test_coordinator.py | 367 +++++++++++ .../home_connect/test_diagnostics.py | 90 +-- tests/components/home_connect/test_init.py | 220 ++++--- tests/components/home_connect/test_light.py | 400 +++++++----- tests/components/home_connect/test_number.py | 154 +++-- tests/components/home_connect/test_select.py | 202 +++--- tests/components/home_connect/test_sensor.py | 249 +++++--- tests/components/home_connect/test_switch.py | 548 +++++++++------- tests/components/home_connect/test_time.py | 102 ++- 33 files changed, 3117 insertions(+), 2641 deletions(-) create mode 100644 homeassistant/components/home_connect/coordinator.py create mode 100644 homeassistant/components/home_connect/utils.py create mode 100644 tests/components/home_connect/test_coordinator.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index d7c042c2a91..a019ae0f250 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,17 +2,16 @@ from __future__ import annotations -from datetime import timedelta import logging -import re from typing import Any, cast -from requests import HTTPError +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import CommandKey, Option, OptionKey +from aiohomeconnect.model.error import HomeConnectError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_entry_oauth2_flow, @@ -21,16 +20,13 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -from . import api +from .api import AsyncConfigEntryAuth from .const import ( ATTR_KEY, ATTR_PROGRAM, ATTR_UNIT, ATTR_VALUE, - BSH_PAUSE, - BSH_RESUME, DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP, SERVICE_OPTION_ACTIVE, @@ -44,15 +40,11 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) - -type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth] +from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) -RE_CAMEL_CASE = re.compile(r"(? api.HomeConnectAppliance: - """Return a Home Connect appliance instance given a device id or a device entry.""" - if device_id is not None and device_entry is None: - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) - assert device_entry, "Either a device id or a device entry must be provided" +async def _get_client_and_ha_id( + hass: HomeAssistant, device_id: str +) -> tuple[HomeConnectClient, str]: + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ServiceValidationError("Device entry not found for device id") + entry: HomeConnectConfigEntry | None = None + for entry_id in device_entry.config_entries: + _entry = hass.config_entries.async_get_entry(entry_id) + assert _entry + if _entry.domain == DOMAIN: + entry = cast(HomeConnectConfigEntry, _entry) + break + if entry is None: + raise ServiceValidationError( + "Home Connect config entry not found for that device id" + ) ha_id = next( ( @@ -119,158 +118,148 @@ def _get_appliance( ), None, ) - assert ha_id - - def find_appliance( - entry: HomeConnectConfigEntry, - ) -> api.HomeConnectAppliance | None: - for device in entry.runtime_data.devices: - appliance = device.appliance - if appliance.haId == ha_id: - return appliance - return None - - if entry is None: - for entry_id in device_entry.config_entries: - entry = hass.config_entries.async_get_entry(entry_id) - assert entry - if entry.domain == DOMAIN: - entry = cast(HomeConnectConfigEntry, entry) - if (appliance := find_appliance(entry)) is not None: - return appliance - elif (appliance := find_appliance(entry)) is not None: - return appliance - raise ValueError(f"Appliance for device id {device_entry.id} not found") - - -def _get_appliance_or_raise_service_validation_error( - hass: HomeAssistant, device_id: str -) -> api.HomeConnectAppliance: - """Return a Home Connect appliance instance or raise a service validation error.""" - try: - return _get_appliance(hass, device_id) - except (ValueError, AssertionError) as err: + if ha_id is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="appliance_not_found", translation_placeholders={ "device_id": device_id, }, - ) from err - - -async def _run_appliance_service[*_Ts]( - hass: HomeAssistant, - appliance: api.HomeConnectAppliance, - method: str, - *args: *_Ts, - error_translation_key: str, - error_translation_placeholders: dict[str, str], -) -> None: - try: - await hass.async_add_executor_job(getattr(appliance, method), *args) - except api.HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=error_translation_key, - translation_placeholders={ - **get_dict_from_home_connect_error(err), - **error_translation_placeholders, - }, - ) from err + ) + return entry.runtime_data.client, ha_id async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - async def _async_service_program(call, method): + async def _async_service_program(call: ServiceCall, start: bool): """Execute calls to services taking a program.""" program = call.data[ATTR_PROGRAM] - device_id = call.data[ATTR_DEVICE_ID] - - options = [] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) option_key = call.data.get(ATTR_KEY) - if option_key is not None: - option = {ATTR_KEY: option_key, ATTR_VALUE: call.data[ATTR_VALUE]} - - option_unit = call.data.get(ATTR_UNIT) - if option_unit is not None: - option[ATTR_UNIT] = option_unit - - options.append(option) - await _run_appliance_service( - hass, - _get_appliance_or_raise_service_validation_error(hass, device_id), - method, - program, - options, - error_translation_key=method, - error_translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, - }, + options = ( + [ + Option( + OptionKey(option_key), + call.data[ATTR_VALUE], + unit=call.data.get(ATTR_UNIT), + ) + ] + if option_key is not None + else None ) - async def _async_service_command(call, command): - """Execute calls to services executing a command.""" - device_id = call.data[ATTR_DEVICE_ID] + try: + if start: + await client.start_program(ha_id, program_key=program, options=options) + else: + await client.set_selected_program( + ha_id, program_key=program, options=options + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_program" if start else "select_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, + }, + ) from err - appliance = _get_appliance_or_raise_service_validation_error(hass, device_id) - await _run_appliance_service( - hass, - appliance, - "execute_command", - command, - error_translation_key="execute_command", - error_translation_placeholders={"command": command}, - ) - - async def _async_service_key_value(call, method): - """Execute calls to services taking a key and value.""" - key = call.data[ATTR_KEY] + async def _async_service_set_program_options(call: ServiceCall, active: bool): + """Execute calls to services taking a program.""" + option_key = call.data[ATTR_KEY] value = call.data[ATTR_VALUE] unit = call.data.get(ATTR_UNIT) - device_id = call.data[ATTR_DEVICE_ID] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - await _run_appliance_service( - hass, - _get_appliance_or_raise_service_validation_error(hass, device_id), - method, - *((key, value) if unit is None else (key, value, unit)), - error_translation_key=method, - error_translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_KEY: key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), - }, - ) + try: + if active: + await client.set_active_program_option( + ha_id, + option_key=OptionKey(option_key), + value=value, + unit=unit, + ) + else: + await client.set_selected_program_option( + ha_id, + option_key=OptionKey(option_key), + value=value, + unit=unit, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_options_active_program" + if active + else "set_options_selected_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_KEY: option_key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) from err - async def async_service_option_active(call): + async def _async_service_command(call: ServiceCall, command_key: CommandKey): + """Execute calls to services executing a command.""" + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) + + try: + await client.put_command(ha_id, command_key=command_key, value=True) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "command": command_key.value, + }, + ) from err + + async def async_service_option_active(call: ServiceCall): """Service for setting an option for an active program.""" - await _async_service_key_value(call, "set_options_active_program") + await _async_service_set_program_options(call, True) - async def async_service_option_selected(call): + async def async_service_option_selected(call: ServiceCall): """Service for setting an option for a selected program.""" - await _async_service_key_value(call, "set_options_selected_program") + await _async_service_set_program_options(call, False) - async def async_service_setting(call): + async def async_service_setting(call: ServiceCall): """Service for changing a setting.""" - await _async_service_key_value(call, "set_setting") + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - async def async_service_pause_program(call): + try: + await client.set_setting(ha_id, setting_key=key, value=value) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_setting", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_KEY: key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) from err + + async def async_service_pause_program(call: ServiceCall): """Service for pausing a program.""" - await _async_service_command(call, BSH_PAUSE) + await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - async def async_service_resume_program(call): + async def async_service_resume_program(call: ServiceCall): """Service for resuming a paused program.""" - await _async_service_command(call, BSH_RESUME) + await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - async def async_service_select_program(call): + async def async_service_select_program(call: ServiceCall): """Service for selecting a program.""" - await _async_service_program(call, "select_program") + await _async_service_program(call, False) - async def async_service_start_program(call): + async def async_service_start_program(call: ServiceCall): """Service for starting a program.""" - await _async_service_program(call, "start_program") + await _async_service_program(call, True) hass.services.async_register( DOMAIN, @@ -323,12 +312,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) ) ) - entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - await update_all_devices(hass, entry) + config_entry_auth = AsyncConfigEntryAuth(hass, session) + + home_connect_client = HomeConnectClient(config_entry_auth) + + coordinator = HomeConnectCoordinator(hass, entry, home_connect_client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.runtime_data.start_event_listener() + return True @@ -339,21 +337,6 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -@Throttle(SCAN_INTERVAL) -async def update_all_devices( - hass: HomeAssistant, entry: HomeConnectConfigEntry -) -> None: - """Update all the devices.""" - hc_api = entry.runtime_data - - try: - await hass.async_add_executor_job(hc_api.get_devices) - for device in hc_api.devices: - await hass.async_add_executor_job(device.initialize) - except HTTPError as err: - _LOGGER.warning("Cannot update devices: %s", err.response.status_code) - - async def async_migrate_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: @@ -382,25 +365,3 @@ async def async_migrate_entry( _LOGGER.debug("Migration to version %s successful", entry.version) return True - - -def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]: - """Return a dict from a Home Connect error.""" - return { - "description": cast(dict[str, Any], err.args[0]).get("description", "?") - if len(err.args) > 0 and isinstance(err.args[0], dict) - else err.args[0] - if len(err.args) > 0 and isinstance(err.args[0], str) - else "?", - } - - -def bsh_key_to_translation_key(bsh_key: str) -> str: - """Convert a BSH key to a translation key format. - - This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, - and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. - """ - return "_".join( - RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") - ).lower() diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index 453f926c402..5d711dae032 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -1,85 +1,28 @@ """API for Home Connect bound to HASS OAuth.""" -from asyncio import run_coroutine_threadsafe -import logging +from aiohomeconnect.client import AbstractAuth +from aiohomeconnect.const import API_ENDPOINT -import homeconnect -from homeconnect.api import HomeConnectAppliance, HomeConnectError - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.dispatcher import dispatcher_send - -from .const import ATTR_KEY, ATTR_VALUE, BSH_ACTIVE_PROGRAM, SIGNAL_UPDATE_ENTITIES - -_LOGGER = logging.getLogger(__name__) +from homeassistant.helpers.httpx_client import get_async_client -class ConfigEntryAuth(homeconnect.HomeConnectAPI): +class AsyncConfigEntryAuth(AbstractAuth): """Provide Home Connect authentication tied to an OAuth2 based config entry.""" def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, + oauth_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize Home Connect Auth.""" self.hass = hass - self.config_entry = config_entry - self.session = config_entry_oauth2_flow.OAuth2Session( - hass, config_entry, implementation - ) - super().__init__(self.session.token) - self.devices: list[HomeConnectDevice] = [] + super().__init__(get_async_client(hass), host=API_ENDPOINT) + self.session = oauth_session - def refresh_tokens(self) -> dict: - """Refresh and return new Home Connect tokens using Home Assistant OAuth2 session.""" - run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self.hass.loop - ).result() + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self.session.async_ensure_token_valid() - return self.session.token - - def get_devices(self) -> list[HomeConnectAppliance]: - """Get a dictionary of devices.""" - appl: list[HomeConnectAppliance] = self.get_appliances() - self.devices = [HomeConnectDevice(self.hass, app) for app in appl] - return self.devices - - -class HomeConnectDevice: - """Generic Home Connect device.""" - - def __init__(self, hass: HomeAssistant, appliance: HomeConnectAppliance) -> None: - """Initialize the device class.""" - self.hass = hass - self.appliance = appliance - - def initialize(self) -> None: - """Fetch the info needed to initialize the device.""" - try: - self.appliance.get_status() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch appliance status. Probably offline") - try: - self.appliance.get_settings() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch settings. Probably offline") - try: - program_active = self.appliance.get_programs_active() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch active programs. Probably offline") - program_active = None - if program_active and ATTR_KEY in program_active: - self.appliance.status[BSH_ACTIVE_PROGRAM] = { - ATTR_VALUE: program_active[ATTR_KEY] - } - self.appliance.listen_events(callback=self.event_callback) - - def event_callback(self, appliance: HomeConnectAppliance) -> None: - """Handle event.""" - _LOGGER.debug("Update triggered on %s", appliance.name) - _LOGGER.debug(self.appliance.status) - dispatcher_send(self.hass, SIGNAL_UPDATE_ENTITIES, appliance.haId) + return self.session.token["access_token"] diff --git a/homeassistant/components/home_connect/application_credentials.py b/homeassistant/components/home_connect/application_credentials.py index 3d5a407b487..d66255e6810 100644 --- a/homeassistant/components/home_connect/application_credentials.py +++ b/homeassistant/components/home_connect/application_credentials.py @@ -1,10 +1,10 @@ """Application credentials platform for Home Connect.""" +from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN - async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: """Return authorization server.""" diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index f9775918f16..90743c829e2 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -1,7 +1,9 @@ """Provides a binary sensor for Home Connect.""" from dataclasses import dataclass -import logging +from typing import cast + +from aiohomeconnect.model import StatusKey from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( @@ -19,26 +21,21 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) -from . import HomeConnectConfigEntry -from .api import HomeConnectDevice from .const import ( - ATTR_VALUE, - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, - BSH_REMOTE_CONTROL_ACTIVATION_STATE, - BSH_REMOTE_START_ALLOWANCE_STATE, DOMAIN, - REFRIGERATION_STATUS_DOOR_CHILLER, REFRIGERATION_STATUS_DOOR_CLOSED, - REFRIGERATION_STATUS_DOOR_FREEZER, REFRIGERATION_STATUS_DOOR_OPEN, - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, +) +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, ) from .entity import HomeConnectEntity -_LOGGER = logging.getLogger(__name__) REFRIGERATION_DOOR_BOOLEAN_MAP = { REFRIGERATION_STATUS_DOOR_CLOSED: False, REFRIGERATION_STATUS_DOOR_OPEN: True, @@ -54,19 +51,19 @@ class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription): BINARY_SENSORS = ( HomeConnectBinarySensorEntityDescription( - key=BSH_REMOTE_CONTROL_ACTIVATION_STATE, + key=StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE, translation_key="remote_control", ), HomeConnectBinarySensorEntityDescription( - key=BSH_REMOTE_START_ALLOWANCE_STATE, + key=StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, translation_key="remote_start", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.LocalControlActive", + key=StatusKey.BSH_COMMON_LOCAL_CONTROL_ACTIVE, translation_key="local_control", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.BatteryChargingState", + key=StatusKey.BSH_COMMON_BATTERY_CHARGING_STATE, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, boolean_map={ "BSH.Common.EnumType.BatteryChargingState.Charging": True, @@ -75,7 +72,7 @@ BINARY_SENSORS = ( translation_key="battery_charging_state", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.ChargingConnection", + key=StatusKey.BSH_COMMON_CHARGING_CONNECTION, device_class=BinarySensorDeviceClass.PLUG, boolean_map={ "BSH.Common.EnumType.ChargingConnection.Connected": True, @@ -84,31 +81,31 @@ BINARY_SENSORS = ( translation_key="charging_connection", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.DustBoxInserted", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED, translation_key="dust_box_inserted", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.Lifted", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LIFTED, translation_key="lifted", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.Lost", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST, translation_key="lost", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_CHILLER, + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="chiller_door", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_FREEZER, + key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="freezer_door", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + key=StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="refrigerator_door", @@ -123,19 +120,17 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect binary sensor.""" - def get_entities() -> list[BinarySensorEntity]: - entities: list[BinarySensorEntity] = [] - for device in entry.runtime_data.devices: - entities.extend( - HomeConnectBinarySensor(device, description) - for description in BINARY_SENSORS - if description.key in device.appliance.status - ) - if BSH_DOOR_STATE in device.appliance.status: - entities.append(HomeConnectDoorBinarySensor(device)) - return entities + entities: list[BinarySensorEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectBinarySensor(entry.runtime_data, appliance, description) + for description in BINARY_SENSORS + if description.key in appliance.status + ) + if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: + entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): @@ -143,25 +138,15 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): entity_description: HomeConnectBinarySensorEntityDescription - @property - def available(self) -> bool: - """Return true if the binary sensor is available.""" - return self._attr_is_on is not None - - async def async_update(self) -> None: - """Update the binary sensor's status.""" - if not self.device.appliance.status or not ( - status := self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) - ): - self._attr_is_on = None - return - if self.entity_description.boolean_map: - self._attr_is_on = self.entity_description.boolean_map.get(status) - elif status not in [True, False]: - self._attr_is_on = None - else: + def update_native_value(self) -> None: + """Set the native value of the binary sensor.""" + status = self.appliance.status[cast(StatusKey, self.bsh_key)].value + if isinstance(status, bool): self._attr_is_on = status - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) + elif self.entity_description.boolean_map: + self._attr_is_on = self.entity_description.boolean_map.get(status) + else: + self._attr_is_on = None class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): @@ -171,13 +156,15 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): def __init__( self, - device: HomeConnectDevice, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, ) -> None: """Initialize the entity.""" super().__init__( - device, + coordinator, + appliance, HomeConnectBinarySensorEntityDescription( - key=BSH_DOOR_STATE, + key=StatusKey.BSH_COMMON_DOOR_STATE, device_class=BinarySensorDeviceClass.DOOR, boolean_map={ BSH_DOOR_STATE_CLOSED: False, @@ -186,8 +173,8 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): }, ), ) - self._attr_unique_id = f"{device.appliance.haId}-Door" - self._attr_name = f"{device.appliance.name} Door" + self._attr_unique_id = f"{appliance.info.ha_id}-Door" + self._attr_name = f"{appliance.info.name} Door" async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -234,6 +221,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed from hass.""" + await super().async_will_remove_from_hass() async_delete_issue( self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" ) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index e20cf3b1fa0..127aa1ffe92 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -1,9 +1,9 @@ """Constants for the Home Connect integration.""" +from aiohomeconnect.model import EventKey, SettingKey, StatusKey + DOMAIN = "home_connect" -OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize" -OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token" APPLIANCES_WITH_PROGRAMS = ( "CleaningRobot", @@ -17,93 +17,35 @@ APPLIANCES_WITH_PROGRAMS = ( "WasherDryer", ) -BSH_POWER_STATE = "BSH.Common.Setting.PowerState" + BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" -BSH_SELECTED_PROGRAM = "BSH.Common.Root.SelectedProgram" -BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" -BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" -BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" -BSH_CHILD_LOCK_STATE = "BSH.Common.Setting.ChildLock" -BSH_REMAINING_PROGRAM_TIME = "BSH.Common.Option.RemainingProgramTime" -BSH_COMMON_OPTION_DURATION = "BSH.Common.Option.Duration" -BSH_COMMON_OPTION_PROGRAM_PROGRESS = "BSH.Common.Option.ProgramProgress" BSH_EVENT_PRESENT_STATE_PRESENT = "BSH.Common.EnumType.EventPresentState.Present" BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confirmed" BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off" -BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" + BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause" BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" -COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" -COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" - -COFFEE_EVENT_BEAN_CONTAINER_EMPTY = ( - "ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty" -) -COFFEE_EVENT_WATER_TANK_EMPTY = "ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty" -COFFEE_EVENT_DRIP_TRAY_FULL = "ConsumerProducts.CoffeeMaker.Event.DripTrayFull" - -DISHWASHER_EVENT_SALT_NEARLY_EMPTY = "Dishcare.Dishwasher.Event.SaltNearlyEmpty" -DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY = ( - "Dishcare.Dishwasher.Event.RinseAidNearlyEmpty" -) - -REFRIGERATION_INTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.Internal.Power" -REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS = ( - "Refrigeration.Common.Setting.Light.Internal.Brightness" -) -REFRIGERATION_EXTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.External.Power" -REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS = ( - "Refrigeration.Common.Setting.Light.External.Brightness" -) - -REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer" -REFRIGERATION_SUPERMODEREFRIGERATOR = ( - "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator" -) -REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled" - -REFRIGERATION_STATUS_DOOR_CHILLER = "Refrigeration.Common.Status.Door.ChillerCommon" -REFRIGERATION_STATUS_DOOR_FREEZER = "Refrigeration.Common.Status.Door.Freezer" -REFRIGERATION_STATUS_DOOR_REFRIGERATOR = "Refrigeration.Common.Status.Door.Refrigerator" REFRIGERATION_STATUS_DOOR_CLOSED = "Refrigeration.Common.EnumType.Door.States.Closed" REFRIGERATION_STATUS_DOOR_OPEN = "Refrigeration.Common.EnumType.Door.States.Open" -REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR = ( - "Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator" -) -REFRIGERATION_EVENT_DOOR_ALARM_FREEZER = ( - "Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer" -) -REFRIGERATION_EVENT_TEMP_ALARM_FREEZER = ( - "Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer" -) - -BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled" -BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness" -BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor" BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR = ( "BSH.Common.EnumType.AmbientLightColor.CustomColor" ) -BSH_AMBIENT_LIGHT_CUSTOM_COLOR = "BSH.Common.Setting.AmbientLightCustomColor" -BSH_DOOR_STATE = "BSH.Common.Status.DoorState" + BSH_DOOR_STATE_CLOSED = "BSH.Common.EnumType.DoorState.Closed" BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked" BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open" -BSH_PAUSE = "BSH.Common.Command.PauseProgram" -BSH_RESUME = "BSH.Common.Command.ResumeProgram" - -SIGNAL_UPDATE_ENTITIES = "home_connect.update_entities" SERVICE_OPTION_ACTIVE = "set_option_active" SERVICE_OPTION_SELECTED = "set_option_selected" @@ -113,51 +55,44 @@ SERVICE_SELECT_PROGRAM = "select_program" SERVICE_SETTING = "change_setting" SERVICE_START_PROGRAM = "start_program" -ATTR_ALLOWED_VALUES = "allowedvalues" -ATTR_AMBIENT = "ambient" -ATTR_BSH_KEY = "bsh_key" -ATTR_CONSTRAINTS = "constraints" -ATTR_DESC = "desc" -ATTR_DEVICE = "device" + ATTR_KEY = "key" ATTR_PROGRAM = "program" -ATTR_SENSOR_TYPE = "sensor_type" -ATTR_SIGN = "sign" -ATTR_STEPSIZE = "stepsize" ATTR_UNIT = "unit" ATTR_VALUE = "value" -SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" +SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id" SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program" SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" + OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { - "ChildLock": BSH_CHILD_LOCK_STATE, - "Operation State": BSH_OPERATION_STATE, - "Light": COOKING_LIGHTING, - "AmbientLight": BSH_AMBIENT_LIGHT_ENABLED, - "Power": BSH_POWER_STATE, - "Remaining Program Time": BSH_REMAINING_PROGRAM_TIME, - "Duration": BSH_COMMON_OPTION_DURATION, - "Program Progress": BSH_COMMON_OPTION_PROGRAM_PROGRESS, - "Remote Control": BSH_REMOTE_CONTROL_ACTIVATION_STATE, - "Remote Start": BSH_REMOTE_START_ALLOWANCE_STATE, - "Supermode Freezer": REFRIGERATION_SUPERMODEFREEZER, - "Supermode Refrigerator": REFRIGERATION_SUPERMODEREFRIGERATOR, - "Dispenser Enabled": REFRIGERATION_DISPENSER, - "Internal Light": REFRIGERATION_INTERNAL_LIGHT_POWER, - "External Light": REFRIGERATION_EXTERNAL_LIGHT_POWER, - "Chiller Door": REFRIGERATION_STATUS_DOOR_CHILLER, - "Freezer Door": REFRIGERATION_STATUS_DOOR_FREEZER, - "Refrigerator Door": REFRIGERATION_STATUS_DOOR_REFRIGERATOR, - "Door Alarm Freezer": REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, - "Door Alarm Refrigerator": REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, - "Temperature Alarm Freezer": REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, - "Bean Container Empty": COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - "Water Tank Empty": COFFEE_EVENT_WATER_TANK_EMPTY, - "Drip Tray Full": COFFEE_EVENT_DRIP_TRAY_FULL, + "ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK, + "Operation State": StatusKey.BSH_COMMON_OPERATION_STATE, + "Light": SettingKey.COOKING_COMMON_LIGHTING, + "AmbientLight": SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED, + "Power": SettingKey.BSH_COMMON_POWER_STATE, + "Remaining Program Time": EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME, + "Duration": EventKey.BSH_COMMON_OPTION_DURATION, + "Program Progress": EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, + "Remote Control": StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE, + "Remote Start": StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, + "Supermode Freezer": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER, + "Supermode Refrigerator": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR, + "Dispenser Enabled": SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED, + "Internal Light": SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER, + "External Light": SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER, + "Chiller Door": StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER, + "Freezer Door": StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, + "Refrigerator Door": StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR, + "Door Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + "Door Alarm Refrigerator": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, + "Temperature Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, + "Bean Container Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + "Water Tank Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, + "Drip Tray Full": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, } diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py new file mode 100644 index 00000000000..2c70d74150e --- /dev/null +++ b/homeassistant/components/home_connect/coordinator.py @@ -0,0 +1,258 @@ +"""Coordinator for Home Connect.""" + +import asyncio +from collections import defaultdict +from collections.abc import Callable +from dataclasses import dataclass, field +import logging +from typing import Any + +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + HomeAppliance, + SettingKey, + Status, + StatusKey, +) +from aiohomeconnect.model.error import ( + EventStreamInterruptedError, + HomeConnectApiError, + HomeConnectError, + HomeConnectRequestError, +) +from aiohomeconnect.model.program import EnumerateAvailableProgram +from propcache.api import cached_property + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN +from .utils import get_dict_from_home_connect_error + +_LOGGER = logging.getLogger(__name__) + +type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] + +EVENT_STREAM_RECONNECT_DELAY = 30 + + +@dataclass(frozen=True, kw_only=True) +class HomeConnectApplianceData: + """Class to hold Home Connect appliance data.""" + + events: dict[EventKey, Event] = field(default_factory=dict) + info: HomeAppliance + programs: list[EnumerateAvailableProgram] = field(default_factory=list) + settings: dict[SettingKey, GetSetting] + status: dict[StatusKey, Status] + + def update(self, other: "HomeConnectApplianceData") -> None: + """Update data with data from other instance.""" + self.events.update(other.events) + self.info.connected = other.info.connected + self.programs.clear() + self.programs.extend(other.programs) + self.settings.update(other.settings) + self.status.update(other.status) + + +class HomeConnectCoordinator( + DataUpdateCoordinator[dict[str, HomeConnectApplianceData]] +): + """Class to manage fetching Home Connect data.""" + + config_entry: HomeConnectConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HomeConnectConfigEntry, + client: HomeConnectClient, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.entry_id, + ) + self.client = client + + @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 start_event_listener(self) -> None: + """Start event listener.""" + self.config_entry.async_create_background_task( + self.hass, + self._event_listener(), + f"home_connect-events_listener_task-{self.config_entry.entry_id}", + ) + + async def _event_listener(self) -> None: + """Match event with listener for event type.""" + while True: + try: + async for event_message in self.client.stream_all_events(): + 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, + ) + + 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: + if event.key in SettingKey: + 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: + events[event.key] = event + + 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) + + except (EventStreamInterruptedError, HomeConnectRequestError) as error: + _LOGGER.debug( + "Non-breaking error (%s) while listening for events," + " continuing in 30 seconds", + type(error).__name__, + ) + await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY) + except HomeConnectApiError as error: + _LOGGER.error("Error while listening for events: %s", error) + self.hass.config_entries.async_schedule_reload( + self.config_entry.entry_id + ) + break + # if there was a non-breaking error, we continue listening + # but we need to refresh the data to get the possible changes + # that happened while the event stream was interrupted + await self.async_refresh() + + @callback + def _call_event_listener(self, event_message: EventMessage): + """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() + + async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: + """Fetch data from Home Connect.""" + try: + appliances = await self.client.get_home_appliances() + 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 + + appliances_data = self.data or {} + for appliance in appliances.homeappliances: + try: + settings = { + setting.key: setting + for setting in ( + await self.client.get_settings(appliance.ha_id) + ).settings + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching settings for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + settings = {} + try: + status = { + status.key: status + for status in (await self.client.get_status(appliance.ha_id)).status + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching status for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + status = {} + appliance_data = HomeConnectApplianceData( + info=appliance, settings=settings, status=status + ) + if appliance.ha_id in appliances_data: + appliances_data[appliance.ha_id].update(appliance_data) + appliance_data = appliances_data[appliance.ha_id] + else: + appliances_data[appliance.ha_id] = appliance_data + if ( + appliance.type in APPLIANCES_WITH_PROGRAMS + and not appliance_data.programs + ): + try: + appliance_data.programs.extend( + ( + await self.client.get_available_programs(appliance.ha_id) + ).programs + ) + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching programs for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + return appliances_data diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index e095bc503ab..fd74277a815 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -4,33 +4,25 @@ from __future__ import annotations from typing import Any -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.client import Client as HomeConnectClient from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from . import HomeConnectConfigEntry, _get_appliance -from .api import HomeConnectDevice +from .const import DOMAIN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]: - try: - programs = appliance.get_programs_available() - except HomeConnectError: - programs = None +async def _generate_appliance_diagnostics( + client: HomeConnectClient, appliance: HomeConnectApplianceData +) -> dict[str, Any]: return { - "connected": appliance.connected, - "status": appliance.status, - "programs": programs, - } - - -def _generate_entry_diagnostics( - devices: list[HomeConnectDevice], -) -> dict[str, dict[str, Any]]: - return { - device.appliance.haId: _generate_appliance_diagnostics(device.appliance) - for device in devices + **appliance.info.to_dict(), + "status": {key.value: status.value for key, status in appliance.status.items()}, + "settings": { + key.value: setting.value for key, setting in appliance.settings.items() + }, + "programs": [program.raw_key for program in appliance.programs], } @@ -38,14 +30,21 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return await hass.async_add_executor_job( - _generate_entry_diagnostics, entry.runtime_data.devices - ) + return { + appliance.info.ha_id: await _generate_appliance_diagnostics( + entry.runtime_data.client, appliance + ) + for appliance in entry.runtime_data.data.values() + } async def async_get_device_diagnostics( hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - appliance = _get_appliance(hass, device_entry=device, entry=entry) - return await hass.async_add_executor_job(_generate_appliance_diagnostics, appliance) + ha_id = next( + (identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN), + ) + return await _generate_appliance_diagnostics( + entry.runtime_data.client, entry.runtime_data.data[ha_id] + ) diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 0ae4a28b8d4..ba8500fe8b6 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,55 +1,56 @@ """Home Connect entity base class.""" +from abc import abstractmethod import logging +from aiohomeconnect.model import EventKey + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import HomeConnectDevice -from .const import DOMAIN, SIGNAL_UPDATE_ENTITIES +from .const import DOMAIN +from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator _LOGGER = logging.getLogger(__name__) -class HomeConnectEntity(Entity): +class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): """Generic Home Connect entity (base class).""" _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, device: HomeConnectDevice, desc: EntityDescription) -> None: + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: EntityDescription, + ) -> None: """Initialize the entity.""" - self.device = device + super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key))) + self.appliance = appliance self.entity_description = desc - self._attr_unique_id = f"{device.appliance.haId}-{self.bsh_key}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.appliance.haId)}, - manufacturer=device.appliance.brand, - model=device.appliance.vib, - name=device.appliance.name, + identifiers={(DOMAIN, appliance.info.ha_id)}, + manufacturer=appliance.info.brand, + model=appliance.info.vib, + name=appliance.info.name, ) + self.update_native_value() - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITIES, self._update_callback - ) - ) + @abstractmethod + def update_native_value(self) -> None: + """Set the value of the entity.""" @callback - def _update_callback(self, ha_id: str) -> None: - """Update data.""" - if ha_id == self.device.appliance.haId: - self.async_entity_update() - - @callback - def async_entity_update(self) -> None: - """Update the entity.""" - _LOGGER.debug("Entity update triggered on %s", self) - self.async_schedule_update_ha_state(True) + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_native_value() + self.async_write_ha_state() + _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state) @property def bsh_key(self) -> str: diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 3e81bcbddad..9d1c4d7a55b 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -2,10 +2,10 @@ from dataclasses import dataclass import logging -from math import ceil -from typing import Any +from typing import Any, cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -20,25 +20,18 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color as color_util -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error -from .api import HomeConnectDevice from .const import ( - ATTR_VALUE, - BSH_AMBIENT_LIGHT_BRIGHTNESS, - BSH_AMBIENT_LIGHT_COLOR, BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_ENABLED, - COOKING_LIGHTING, - COOKING_LIGHTING_BRIGHTNESS, DOMAIN, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_POWER, - REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_INTERNAL_LIGHT_POWER, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, ) +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -47,38 +40,38 @@ _LOGGER = logging.getLogger(__name__) class HomeConnectLightEntityDescription(LightEntityDescription): """Light entity description.""" - brightness_key: str | None = None - color_key: str | None = None + brightness_key: SettingKey | None = None + color_key: SettingKey | None = None enable_custom_color_value_key: str | None = None - custom_color_key: str | None = None + custom_color_key: SettingKey | None = None brightness_scale: tuple[float, float] = (0.0, 100.0) LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = ( HomeConnectLightEntityDescription( - key=REFRIGERATION_INTERNAL_LIGHT_POWER, - brightness_key=REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, + key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER, + brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_BRIGHTNESS, brightness_scale=(1.0, 100.0), translation_key="internal_light", ), HomeConnectLightEntityDescription( - key=REFRIGERATION_EXTERNAL_LIGHT_POWER, - brightness_key=REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, + key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER, + brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_BRIGHTNESS, brightness_scale=(1.0, 100.0), translation_key="external_light", ), HomeConnectLightEntityDescription( - key=COOKING_LIGHTING, - brightness_key=COOKING_LIGHTING_BRIGHTNESS, + key=SettingKey.COOKING_COMMON_LIGHTING, + brightness_key=SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS, brightness_scale=(10.0, 100.0), translation_key="cooking_lighting", ), HomeConnectLightEntityDescription( - key=BSH_AMBIENT_LIGHT_ENABLED, - brightness_key=BSH_AMBIENT_LIGHT_BRIGHTNESS, - color_key=BSH_AMBIENT_LIGHT_COLOR, + key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED, + brightness_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS, + color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, enable_custom_color_value_key=BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - custom_color_key=BSH_AMBIENT_LIGHT_CUSTOM_COLOR, + custom_color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR, brightness_scale=(10.0, 100.0), translation_key="ambient_light", ), @@ -92,16 +85,14 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect light.""" - def get_entities() -> list[LightEntity]: - """Get a list of entities.""" - return [ - HomeConnectLight(device, description) + async_add_entities( + [ + HomeConnectLight(entry.runtime_data, appliance, description) for description in LIGHTS - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) class HomeConnectLight(HomeConnectEntity, LightEntity): @@ -110,13 +101,17 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): entity_description: LightEntityDescription def __init__( - self, device: HomeConnectDevice, desc: HomeConnectLightEntityDescription + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectLightEntityDescription, ) -> None: """Initialize the entity.""" - super().__init__(device, desc) - def get_setting_key_if_setting_exists(setting_key: str | None) -> str | None: - if setting_key and setting_key in device.appliance.status: + def get_setting_key_if_setting_exists( + setting_key: SettingKey | None, + ) -> SettingKey | None: + if setting_key and setting_key in appliance.settings: return setting_key return None @@ -131,6 +126,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): ) self._brightness_scale = desc.brightness_scale + super().__init__(coordinator, appliance, desc) + match (self._brightness_key, self._custom_color_key): case (None, None): self._attr_color_mode = ColorMode.ONOFF @@ -144,10 +141,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Switch the light on, change brightness, change color.""" - _LOGGER.debug("Switching light on for: %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.bsh_key, True + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=True, ) except HomeConnectError as err: raise HomeAssistantError( @@ -158,15 +156,15 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - if self._custom_color_key: + if self._color_key and self._custom_color_key: if ( ATTR_RGB_COLOR in kwargs or ATTR_HS_COLOR in kwargs ) and self._enable_custom_color_value_key: try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._color_key, - self._enable_custom_color_value_key, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._color_key, + value=self._enable_custom_color_value_key, ) except HomeConnectError as err: raise HomeAssistantError( @@ -181,10 +179,10 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): if ATTR_RGB_COLOR in kwargs: hex_val = color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._custom_color_key, - f"#{hex_val}", + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._custom_color_key, + value=f"#{hex_val}", ) except HomeConnectError as err: raise HomeAssistantError( @@ -195,10 +193,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - elif (ATTR_BRIGHTNESS in kwargs or ATTR_HS_COLOR in kwargs) and ( - self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs + return + if (self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs) and ( + self._attr_hs_color is not None or ATTR_HS_COLOR in kwargs ): - brightness = 10 + ceil( + brightness = round( color_util.brightness_to_value( self._brightness_scale, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness), @@ -207,41 +206,36 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) - if hs_color is not None: - rgb = color_util.color_hsv_to_RGB( - hs_color[0], hs_color[1], brightness + rgb = color_util.color_hsv_to_RGB(hs_color[0], hs_color[1], brightness) + hex_val = color_util.color_rgb_to_hex(*rgb) + try: + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._custom_color_key, + value=f"#{hex_val}", ) - hex_val = color_util.color_rgb_to_hex(*rgb) - try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._custom_color_key, - f"#{hex_val}", - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_light_color", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - }, - ) from err + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_light_color", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + }, + ) from err + return - elif self._brightness_key and ATTR_BRIGHTNESS in kwargs: - _LOGGER.debug( - "Changing brightness for: %s, to: %s", - self.name, - kwargs[ATTR_BRIGHTNESS], - ) - brightness = ceil( + if self._brightness_key and ATTR_BRIGHTNESS in kwargs: + brightness = round( color_util.brightness_to_value( self._brightness_scale, kwargs[ATTR_BRIGHTNESS] ) ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self._brightness_key, brightness + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._brightness_key, + value=brightness, ) except HomeConnectError as err: raise HomeAssistantError( @@ -253,14 +247,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): }, ) from err - self.async_entity_update() - async def async_turn_off(self, **kwargs: Any) -> None: """Switch the light off.""" - _LOGGER.debug("Switching light off for: %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.bsh_key, False + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=False, ) except HomeConnectError as err: raise HomeAssistantError( @@ -271,30 +264,50 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - self.async_entity_update() - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + keys_to_listen = [] + if self._brightness_key: + keys_to_listen.append(self._brightness_key) + if self._color_key and self._custom_color_key: + keys_to_listen.extend([self._color_key, self._custom_color_key]) + for key in keys_to_listen: + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_coordinator_update, + ( + self.appliance.info.ha_id, + EventKey(key), + ), + ) + ) + + def update_native_value(self) -> None: """Update the light's status.""" - if self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is True: - self._attr_is_on = True - elif ( - self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is False - ): - self._attr_is_on = False - else: - self._attr_is_on = None + self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value - _LOGGER.debug("Updated, new light state: %s", self._attr_is_on) - - if self._custom_color_key: - color = self.device.appliance.status.get(self._custom_color_key, {}) - - if not color: + if self._brightness_key: + brightness = cast( + float, self.appliance.settings[self._brightness_key].value + ) + self._attr_brightness = color_util.value_to_brightness( + self._brightness_scale, brightness + ) + _LOGGER.debug( + "Updated %s, new brightness: %s", self.entity_id, self._attr_brightness + ) + if self._color_key and self._custom_color_key: + color = cast(str, self.appliance.settings[self._color_key].value) + if color != self._enable_custom_color_value_key: self._attr_rgb_color = None self._attr_hs_color = None - self._attr_brightness = None else: - color_value = color.get(ATTR_VALUE)[1:] + custom_color = cast( + str, self.appliance.settings[self._custom_color_key].value + ) + color_value = custom_color[1:] rgb = color_util.rgb_hex_to_rgb_list(color_value) self._attr_rgb_color = (rgb[0], rgb[1], rgb[2]) hsv = color_util.color_RGB_to_hsv(*rgb) @@ -303,16 +316,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): self._brightness_scale, hsv[2] ) _LOGGER.debug( - "Updated, new color (%s) and new brightness (%s) ", + "Updated %s, new color (%s) and new brightness (%s) ", + self.entity_id, color_value, self._attr_brightness, ) - elif self._brightness_key: - brightness = self.device.appliance.status.get(self._brightness_key, {}) - if brightness is None: - self._attr_brightness = None - else: - self._attr_brightness = color_util.value_to_brightness( - self._brightness_scale, brightness[ATTR_VALUE] - ) - _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index e041e13d36b..905a7c67f11 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["homeconnect"], - "requirements": ["homeconnect==0.8.0"] + "requirements": ["aiohomeconnect==0.12.1"] } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 0703b4772bb..7c6101950bf 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -1,12 +1,12 @@ """Provides number enties for Home Connect.""" import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -15,66 +15,63 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - ATTR_CONSTRAINTS, - ATTR_STEPSIZE, - ATTR_UNIT, - ATTR_VALUE, DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) NUMBERS = ( NumberEntityDescription( - key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, device_class=NumberDeviceClass.TEMPERATURE, translation_key="refrigerator_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureFreezer", + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_FREEZER, device_class=NumberDeviceClass.TEMPERATURE, translation_key="freezer_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.BottleCooler.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_BOTTLE_COOLER_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="bottle_cooler_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerLeft.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_LEFT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_left_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerCommon.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_COMMON_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerRight.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_RIGHT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_right_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment2.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_2_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_2_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment3.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_3_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_3_setpoint_temperature", ), @@ -87,17 +84,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect number.""" - - def get_entities() -> list[HomeConnectNumberEntity]: - """Get a list of entities.""" - return [ - HomeConnectNumberEntity(device, description) + async_add_entities( + [ + HomeConnectNumberEntity(entry.runtime_data, appliance, description) for description in NUMBERS - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): @@ -112,10 +106,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): self.entity_id, ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self.bsh_key, - value, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=value, ) except HomeConnectError as err: raise HomeAssistantError( @@ -132,34 +126,41 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): async def async_fetch_constraints(self) -> None: """Fetch the max and min values and step for the number entity.""" try: - data = await self.hass.async_add_executor_job( - self.device.appliance.get, f"/settings/{self.bsh_key}" + data = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key) ) except HomeConnectError as err: _LOGGER.error("An error occurred: %s", err) - return - if not data or not (constraints := data.get(ATTR_CONSTRAINTS)): - return - self._attr_native_max_value = constraints.get(ATTR_MAX) - self._attr_native_min_value = constraints.get(ATTR_MIN) - self._attr_native_step = constraints.get(ATTR_STEPSIZE) - self._attr_native_unit_of_measurement = data.get(ATTR_UNIT) + else: + self.set_constraints(data) - async def async_update(self) -> None: - """Update the number setting status.""" - if not (data := self.device.appliance.status.get(self.bsh_key)): - _LOGGER.error("No value for %s", self.bsh_key) - self._attr_native_value = None + def set_constraints(self, setting: GetSetting) -> None: + """Set constraints for the number entity.""" + if not (constraints := setting.constraints): return - self._attr_native_value = data.get(ATTR_VALUE, None) - _LOGGER.debug("Updated, new value: %s", self._attr_native_value) + if constraints.max: + self._attr_native_max_value = constraints.max + if constraints.min: + self._attr_native_min_value = constraints.min + if constraints.step_size: + self._attr_native_step = constraints.step_size + else: + self._attr_native_step = 0.1 if setting.type == "Double" else 1 + def update_native_value(self) -> None: + """Update status when an event for the entity is received.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_value = cast(float, data.value) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_unit_of_measurement = data.unit + self.set_constraints(data) if ( not hasattr(self, "_attr_native_min_value") - or self._attr_native_min_value is None or not hasattr(self, "_attr_native_max_value") - or self._attr_native_max_value is None or not hasattr(self, "_attr_native_step") - or self._attr_native_step is None ): await self.async_fetch_constraints() diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index a4a5861afbe..c7408094aed 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,191 +1,28 @@ """Provides a select platform for Home Connect.""" -import contextlib -import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, ProgramKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM +from .coordinator import ( + HomeConnectApplianceData, HomeConnectConfigEntry, - bsh_key_to_translation_key, - get_dict_from_home_connect_error, -) -from .api import HomeConnectDevice -from .const import ( - APPLIANCES_WITH_PROGRAMS, - ATTR_VALUE, - BSH_ACTIVE_PROGRAM, - BSH_SELECTED_PROGRAM, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + HomeConnectCoordinator, ) from .entity import HomeConnectEntity - -_LOGGER = logging.getLogger(__name__) +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error TRANSLATION_KEYS_PROGRAMS_MAP = { - bsh_key_to_translation_key(program): program - for program in ( - "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll", - "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanMap", - "ConsumerProducts.CleaningRobot.Program.Basic.GoHome", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso", - "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee", - "ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee", - "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeGrande", - "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino", - "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato", - "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte", - "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth", - "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Doppio", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye", - "ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater", - "Dishcare.Dishwasher.Program.PreRinse", - "Dishcare.Dishwasher.Program.Auto1", - "Dishcare.Dishwasher.Program.Auto2", - "Dishcare.Dishwasher.Program.Auto3", - "Dishcare.Dishwasher.Program.Eco50", - "Dishcare.Dishwasher.Program.Quick45", - "Dishcare.Dishwasher.Program.Intensiv70", - "Dishcare.Dishwasher.Program.Normal65", - "Dishcare.Dishwasher.Program.Glas40", - "Dishcare.Dishwasher.Program.GlassCare", - "Dishcare.Dishwasher.Program.NightWash", - "Dishcare.Dishwasher.Program.Quick65", - "Dishcare.Dishwasher.Program.Normal45", - "Dishcare.Dishwasher.Program.Intensiv45", - "Dishcare.Dishwasher.Program.AutoHalfLoad", - "Dishcare.Dishwasher.Program.IntensivPower", - "Dishcare.Dishwasher.Program.MagicDaily", - "Dishcare.Dishwasher.Program.Super60", - "Dishcare.Dishwasher.Program.Kurz60", - "Dishcare.Dishwasher.Program.ExpressSparkle65", - "Dishcare.Dishwasher.Program.MachineCare", - "Dishcare.Dishwasher.Program.SteamFresh", - "Dishcare.Dishwasher.Program.MaximumCleaning", - "Dishcare.Dishwasher.Program.MixedLoad", - "LaundryCare.Dryer.Program.Cotton", - "LaundryCare.Dryer.Program.Synthetic", - "LaundryCare.Dryer.Program.Mix", - "LaundryCare.Dryer.Program.Blankets", - "LaundryCare.Dryer.Program.BusinessShirts", - "LaundryCare.Dryer.Program.DownFeathers", - "LaundryCare.Dryer.Program.Hygiene", - "LaundryCare.Dryer.Program.Jeans", - "LaundryCare.Dryer.Program.Outdoor", - "LaundryCare.Dryer.Program.SyntheticRefresh", - "LaundryCare.Dryer.Program.Towels", - "LaundryCare.Dryer.Program.Delicates", - "LaundryCare.Dryer.Program.Super40", - "LaundryCare.Dryer.Program.Shirts15", - "LaundryCare.Dryer.Program.Pillow", - "LaundryCare.Dryer.Program.AntiShrink", - "LaundryCare.Dryer.Program.MyTime.MyDryingTime", - "LaundryCare.Dryer.Program.TimeCold", - "LaundryCare.Dryer.Program.TimeWarm", - "LaundryCare.Dryer.Program.InBasket", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold20", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold30", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold60", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm30", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm40", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm60", - "LaundryCare.Dryer.Program.Dessous", - "Cooking.Common.Program.Hood.Automatic", - "Cooking.Common.Program.Hood.Venting", - "Cooking.Common.Program.Hood.DelayedShutOff", - "Cooking.Oven.Program.HeatingMode.PreHeating", - "Cooking.Oven.Program.HeatingMode.HotAir", - "Cooking.Oven.Program.HeatingMode.HotAirEco", - "Cooking.Oven.Program.HeatingMode.HotAirGrilling", - "Cooking.Oven.Program.HeatingMode.TopBottomHeating", - "Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco", - "Cooking.Oven.Program.HeatingMode.BottomHeating", - "Cooking.Oven.Program.HeatingMode.PizzaSetting", - "Cooking.Oven.Program.HeatingMode.SlowCook", - "Cooking.Oven.Program.HeatingMode.IntensiveHeat", - "Cooking.Oven.Program.HeatingMode.KeepWarm", - "Cooking.Oven.Program.HeatingMode.PreheatOvenware", - "Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial", - "Cooking.Oven.Program.HeatingMode.Desiccation", - "Cooking.Oven.Program.HeatingMode.Defrost", - "Cooking.Oven.Program.HeatingMode.Proof", - "Cooking.Oven.Program.HeatingMode.HotAir30Steam", - "Cooking.Oven.Program.HeatingMode.HotAir60Steam", - "Cooking.Oven.Program.HeatingMode.HotAir80Steam", - "Cooking.Oven.Program.HeatingMode.HotAir100Steam", - "Cooking.Oven.Program.HeatingMode.SabbathProgramme", - "Cooking.Oven.Program.Microwave.90Watt", - "Cooking.Oven.Program.Microwave.180Watt", - "Cooking.Oven.Program.Microwave.360Watt", - "Cooking.Oven.Program.Microwave.600Watt", - "Cooking.Oven.Program.Microwave.900Watt", - "Cooking.Oven.Program.Microwave.1000Watt", - "Cooking.Oven.Program.Microwave.Max", - "Cooking.Oven.Program.HeatingMode.WarmingDrawer", - "LaundryCare.Washer.Program.Cotton", - "LaundryCare.Washer.Program.Cotton.CottonEco", - "LaundryCare.Washer.Program.Cotton.Eco4060", - "LaundryCare.Washer.Program.Cotton.Colour", - "LaundryCare.Washer.Program.EasyCare", - "LaundryCare.Washer.Program.Mix", - "LaundryCare.Washer.Program.Mix.NightWash", - "LaundryCare.Washer.Program.DelicatesSilk", - "LaundryCare.Washer.Program.Wool", - "LaundryCare.Washer.Program.Sensitive", - "LaundryCare.Washer.Program.Auto30", - "LaundryCare.Washer.Program.Auto40", - "LaundryCare.Washer.Program.Auto60", - "LaundryCare.Washer.Program.Chiffon", - "LaundryCare.Washer.Program.Curtains", - "LaundryCare.Washer.Program.DarkWash", - "LaundryCare.Washer.Program.Dessous", - "LaundryCare.Washer.Program.Monsoon", - "LaundryCare.Washer.Program.Outdoor", - "LaundryCare.Washer.Program.PlushToy", - "LaundryCare.Washer.Program.ShirtsBlouses", - "LaundryCare.Washer.Program.SportFitness", - "LaundryCare.Washer.Program.Towels", - "LaundryCare.Washer.Program.WaterProof", - "LaundryCare.Washer.Program.PowerSpeed59", - "LaundryCare.Washer.Program.Super153045.Super15", - "LaundryCare.Washer.Program.Super153045.Super1530", - "LaundryCare.Washer.Program.DownDuvet.Duvet", - "LaundryCare.Washer.Program.Rinse.RinseSpinDrain", - "LaundryCare.Washer.Program.DrumClean", - "LaundryCare.WasherDryer.Program.Cotton", - "LaundryCare.WasherDryer.Program.Cotton.Eco4060", - "LaundryCare.WasherDryer.Program.Mix", - "LaundryCare.WasherDryer.Program.EasyCare", - "LaundryCare.WasherDryer.Program.WashAndDry60", - "LaundryCare.WasherDryer.Program.WashAndDry90", - ) + bsh_key_to_translation_key(program.value): cast(ProgramKey, program) + for program in ProgramKey + if program != ProgramKey.UNKNOWN } PROGRAMS_TRANSLATION_KEYS_MAP = { @@ -194,11 +31,11 @@ PROGRAMS_TRANSLATION_KEYS_MAP = { PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( SelectEntityDescription( - key=BSH_ACTIVE_PROGRAM, + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, translation_key="active_program", ), SelectEntityDescription( - key=BSH_SELECTED_PROGRAM, + key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, translation_key="selected_program", ), ) @@ -211,31 +48,12 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect select entities.""" - def get_entities() -> list[HomeConnectProgramSelectEntity]: - """Get a list of entities.""" - entities: list[HomeConnectProgramSelectEntity] = [] - programs_not_found = set() - for device in entry.runtime_data.devices: - if device.appliance.type in APPLIANCES_WITH_PROGRAMS: - with contextlib.suppress(HomeConnectError): - programs = device.appliance.get_programs_available() - if programs: - for program in programs.copy(): - if program not in PROGRAMS_TRANSLATION_KEYS_MAP: - programs.remove(program) - if program not in programs_not_found: - _LOGGER.info( - 'The program "%s" is not part of the official Home Connect API specification', - program, - ) - programs_not_found.add(program) - entities.extend( - HomeConnectProgramSelectEntity(device, programs, desc) - for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS - ) - return entities - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities( + HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) + for appliance in entry.runtime_data.data.values() + for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS + if appliance.info.type in APPLIANCES_WITH_PROGRAMS + ) class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): @@ -243,48 +61,45 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): def __init__( self, - device: HomeConnectDevice, - programs: list[str], + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, desc: SelectEntityDescription, ) -> None: """Initialize the entity.""" super().__init__( - device, + coordinator, + appliance, desc, ) self._attr_options = [ - PROGRAMS_TRANSLATION_KEYS_MAP[program] for program in programs + PROGRAMS_TRANSLATION_KEYS_MAP[program.key] + for program in appliance.programs + if program.key != ProgramKey.UNKNOWN ] - self.start_on_select = desc.key == BSH_ACTIVE_PROGRAM + self.start_on_select = desc.key == EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM + self._attr_current_option = None - async def async_update(self) -> None: - """Update the program selection status.""" - program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) - if not program: - program_translation_key = None - elif not ( - program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program) - ): - _LOGGER.debug( - 'The program "%s" is not part of the official Home Connect API specification', - program, - ) - self._attr_current_option = program_translation_key - _LOGGER.debug("Updated, new program: %s", self._attr_current_option) + def update_native_value(self) -> None: + """Set the program value.""" + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + self._attr_current_option = ( + PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value)) + if event + else None + ) async def async_select_option(self, option: str) -> None: """Select new program.""" - bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] - _LOGGER.debug( - "Starting program: %s" if self.start_on_select else "Selecting program: %s", - bsh_key, - ) - if self.start_on_select: - target = self.device.appliance.start_program - else: - target = self.device.appliance.select_program + program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] try: - await self.hass.async_add_executor_job(target, bsh_key) + if self.start_on_select: + await self.coordinator.client.start_program( + self.appliance.info.ha_id, program_key=program_key + ) + else: + await self.coordinator.client.set_selected_program( + self.appliance.info.ha_id, program_key=program_key + ) except HomeConnectError as err: if self.start_on_select: translation_key = "start_program" @@ -295,7 +110,6 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): translation_key=translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, }, ) from err - self.async_entity_update() diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index c11254d2c02..5e7c417a172 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,10 +1,11 @@ """Provides a sensor for Home Connect.""" from dataclasses import dataclass -from datetime import datetime, timedelta -import logging +from datetime import timedelta from typing import cast +from aiohomeconnect.model import EventKey, StatusKey + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -12,38 +13,26 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util, slugify -from . import HomeConnectConfigEntry from .const import ( APPLIANCES_WITH_PROGRAMS, - ATTR_VALUE, - BSH_DOOR_STATE, - BSH_OPERATION_STATE, BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - COFFEE_EVENT_DRIP_TRAY_FULL, - COFFEE_EVENT_WATER_TANK_EMPTY, - DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, - DISHWASHER_EVENT_SALT_NEARLY_EMPTY, - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, - REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, - REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity -_LOGGER = logging.getLogger(__name__) - - EVENT_OPTIONS = ["confirmed", "off", "present"] @dataclass(frozen=True, kw_only=True) -class HomeConnectSensorEntityDescription(SensorEntityDescription): +class HomeConnectSensorEntityDescription( + SensorEntityDescription, +): """Entity Description class for sensors.""" default_value: str | None = None @@ -52,7 +41,7 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription): BSH_PROGRAM_SENSORS = ( HomeConnectSensorEntityDescription( - key="BSH.Common.Option.RemainingProgramTime", + key=EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME, device_class=SensorDeviceClass.TIMESTAMP, translation_key="program_finish_time", appliance_types=( @@ -67,13 +56,13 @@ BSH_PROGRAM_SENSORS = ( ), ), HomeConnectSensorEntityDescription( - key="BSH.Common.Option.Duration", + key=EventKey.BSH_COMMON_OPTION_DURATION, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, appliance_types=("Oven",), ), HomeConnectSensorEntityDescription( - key="BSH.Common.Option.ProgramProgress", + key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, native_unit_of_measurement=PERCENTAGE, translation_key="program_progress", appliance_types=APPLIANCES_WITH_PROGRAMS, @@ -82,7 +71,7 @@ BSH_PROGRAM_SENSORS = ( SENSORS = ( HomeConnectSensorEntityDescription( - key=BSH_OPERATION_STATE, + key=StatusKey.BSH_COMMON_OPERATION_STATE, device_class=SensorDeviceClass.ENUM, options=[ "inactive", @@ -98,7 +87,7 @@ SENSORS = ( translation_key="operation_state", ), HomeConnectSensorEntityDescription( - key=BSH_DOOR_STATE, + key=StatusKey.BSH_COMMON_DOOR_STATE, device_class=SensorDeviceClass.ENUM, options=[ "closed", @@ -108,59 +97,59 @@ SENSORS = ( translation_key="door", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffee", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterPowderCoffee", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_POWDER_COFFEE, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="powder_coffee_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWater", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER, native_unit_of_measurement=UnitOfVolume.MILLILITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWaterCups", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER_CUPS, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_cups_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterFrothyMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_FROTHY_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="frothy_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffeeAndMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE_AND_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_and_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterRistrettoEspresso", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_RISTRETTO_ESPRESSO, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="ristretto_espresso_counter", ), HomeConnectSensorEntityDescription( - key="BSH.Common.Status.BatteryLevel", + key=StatusKey.BSH_COMMON_BATTERY_LEVEL, device_class=SensorDeviceClass.BATTERY, translation_key="battery_level", ), HomeConnectSensorEntityDescription( - key="BSH.Common.Status.Video.CameraState", + key=StatusKey.BSH_COMMON_VIDEO_CAMERA_STATE, device_class=SensorDeviceClass.ENUM, options=[ "disabled", @@ -174,7 +163,7 @@ SENSORS = ( translation_key="camera_state", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.LastSelectedMap", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LAST_SELECTED_MAP, device_class=SensorDeviceClass.ENUM, options=[ "tempmap", @@ -188,7 +177,7 @@ SENSORS = ( EVENT_SENSORS = ( HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -196,7 +185,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -204,7 +193,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Refrigerator"), ), HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -212,7 +201,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -220,7 +209,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_WATER_TANK_EMPTY, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -228,7 +217,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_DRIP_TRAY_FULL, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -236,7 +225,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -244,7 +233,7 @@ EVENT_SENSORS = ( appliance_types=("Dishwasher",), ), HomeConnectSensorEntityDescription( - key=DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, + key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -261,33 +250,30 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect sensor.""" - def get_entities() -> list[SensorEntity]: - """Get a list of entities.""" - entities: list[SensorEntity] = [] - for device in entry.runtime_data.devices: - entities.extend( - HomeConnectSensor( - device, - description, - ) - for description in EVENT_SENSORS - if description.appliance_types - and device.appliance.type in description.appliance_types + entities: list[SensorEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectEventSensor( + entry.runtime_data, + appliance, + description, ) - entities.extend( - HomeConnectProgramSensor(device, desc) - for desc in BSH_PROGRAM_SENSORS - if desc.appliance_types - and device.appliance.type in desc.appliance_types - ) - entities.extend( - HomeConnectSensor(device, description) - for description in SENSORS - if description.key in device.appliance.status - ) - return entities + for description in EVENT_SENSORS + if description.appliance_types + and appliance.info.type in description.appliance_types + ) + entities.extend( + HomeConnectProgramSensor(entry.runtime_data, appliance, desc) + for desc in BSH_PROGRAM_SENSORS + if desc.appliance_types and appliance.info.type in desc.appliance_types + ) + entities.extend( + HomeConnectSensor(entry.runtime_data, appliance, description) + for description in SENSORS + if description.key in appliance.status + ) - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectSensor(HomeConnectEntity, SensorEntity): @@ -295,44 +281,25 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): entity_description: HomeConnectSensorEntityDescription - async def async_update(self) -> None: - """Update the sensor's status.""" - appliance_status = self.device.appliance.status - if ( - self.bsh_key not in appliance_status - or ATTR_VALUE not in appliance_status[self.bsh_key] - ): - self._attr_native_value = self.entity_description.default_value - _LOGGER.debug("Updated, new state: %s", self._attr_native_value) - return - status = appliance_status[self.bsh_key] + def update_native_value(self) -> None: + """Set the value of the sensor.""" + status = self.appliance.status[cast(StatusKey, self.bsh_key)].value + self._update_native_value(status) + + def _update_native_value(self, status: str | float) -> None: + """Set the value of the sensor based on the given value.""" match self.device_class: case SensorDeviceClass.TIMESTAMP: - if ATTR_VALUE not in status: - self._attr_native_value = None - elif ( - self._attr_native_value is not None - and isinstance(self._attr_native_value, datetime) - and self._attr_native_value < dt_util.utcnow() - ): - # if the date is supposed to be in the future but we're - # already past it, set state to None. - self._attr_native_value = None - else: - seconds = float(status[ATTR_VALUE]) - self._attr_native_value = dt_util.utcnow() + timedelta( - seconds=seconds - ) + self._attr_native_value = dt_util.utcnow() + timedelta( + seconds=cast(float, status) + ) case SensorDeviceClass.ENUM: # Value comes back as an enum, we only really care about the # last part, so split it off # https://developer.home-connect.com/docs/status/operation_state - self._attr_native_value = slugify( - cast(str, status.get(ATTR_VALUE)).split(".")[-1] - ) + self._attr_native_value = slugify(cast(str, status).split(".")[-1]) case _: - self._attr_native_value = status.get(ATTR_VALUE) - _LOGGER.debug("Updated, new state: %s", self._attr_native_value) + self._attr_native_value = status class HomeConnectProgramSensor(HomeConnectSensor): @@ -340,6 +307,31 @@ class HomeConnectProgramSensor(HomeConnectSensor): program_running: bool = False + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + 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), + ) + ) + + @callback + def _handle_operation_state_event(self) -> None: + """Update status when an event for the entity is received.""" + self.program_running = ( + status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) + ) is not None and status.value in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + if not self.program_running: + # reset the value when the program is not running, paused or finished + self._attr_native_value = None + self.async_write_ha_state() + @property def available(self) -> bool: """Return true if the sensor is available.""" @@ -347,20 +339,20 @@ class HomeConnectProgramSensor(HomeConnectSensor): # Otherwise, some sensors report erroneous values. return super().available and self.program_running - async def async_update(self) -> None: + def update_native_value(self) -> None: + """Update the program sensor's status.""" + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + if event: + self._update_native_value(event.value) + + +class HomeConnectEventSensor(HomeConnectSensor): + """Sensor class for Home Connect events.""" + + def update_native_value(self) -> None: """Update the sensor's status.""" - self.program_running = ( - BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status) - and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] - and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] - in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] - ) - if self.program_running: - await super().async_update() - else: - # reset the value when the program is not running, paused or finished - self._attr_native_value = None + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + if event: + self._update_native_value(event.value) + elif not self._attr_native_value: + self._attr_native_value = self.entity_description.default_value diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7ededaae5b7..d163d04a6f7 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -26,64 +26,67 @@ "message": "Appliance for device ID {device_id} not found" }, "turn_on_light": { - "message": "Error turning on {entity_id}: {description}" + "message": "Error turning on {entity_id}: {error}" }, "turn_off_light": { - "message": "Error turning off {entity_id}: {description}" + "message": "Error turning off {entity_id}: {error}" }, "set_light_brightness": { - "message": "Error setting brightness of {entity_id}: {description}" + "message": "Error setting brightness of {entity_id}: {error}" }, "select_light_custom_color": { - "message": "Error selecting custom color of {entity_id}: {description}" + "message": "Error selecting custom color of {entity_id}: {error}" }, "set_light_color": { - "message": "Error setting color of {entity_id}: {description}" + "message": "Error setting color of {entity_id}: {error}" }, "set_setting_entity": { - "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {error}" }, "set_setting": { - "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {error}" }, "turn_on": { - "message": "Error turning on {entity_id} ({key}): {description}" + "message": "Error turning on {entity_id} ({key}): {error}" }, "turn_off": { - "message": "Error turning off {entity_id} ({key}): {description}" + "message": "Error turning off {entity_id} ({key}): {error}" }, "select_program": { - "message": "Error selecting program {program}: {description}" + "message": "Error selecting program {program}: {error}" }, "start_program": { - "message": "Error starting program {program}: {description}" + "message": "Error starting program {program}: {error}" }, "pause_program": { - "message": "Error pausing program: {description}" + "message": "Error pausing program: {error}" }, "stop_program": { - "message": "Error stopping program: {description}" + "message": "Error stopping program: {error}" }, "set_options_active_program": { - "message": "Error setting options for the active program: {description}" + "message": "Error setting options for the active program: {error}" }, "set_options_selected_program": { - "message": "Error setting options for the selected program: {description}" + "message": "Error setting options for the selected program: {error}" }, "execute_command": { - "message": "Error executing command {command}: {description}" + "message": "Error executing command {command}: {error}" }, "power_on": { - "message": "Error turning on {appliance_name}: {description}" + "message": "Error turning on {appliance_name}: {error}" }, "power_off": { - "message": "Error turning off {appliance_name} with value \"{value}\": {description}" + "message": "Error turning off {appliance_name} with value \"{value}\": {error}" }, "turn_off_not_supported": { "message": "{appliance_name} does not support turning off or entering standby mode." }, "unable_to_retrieve_turn_off": { "message": "Unable to turn off {appliance_name} because its support for turning off or entering standby mode could not be determined." + }, + "fetch_api_error": { + "message": "Error obtaining data from the API: {error}" } }, "issues": { diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 1bd02e03eb1..c3a0858e0bb 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -1,10 +1,11 @@ """Provides a switch for Home Connect.""" -import contextlib import logging -from typing import Any +from typing import Any, cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, ProgramKey, SettingKey +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.program import EnumerateAvailableProgram from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -18,87 +19,83 @@ from homeassistant.helpers.issue_registry import ( async_create_issue, async_delete_issue, ) +from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - APPLIANCES_WITH_PROGRAMS, - ATTR_ALLOWED_VALUES, - ATTR_CONSTRAINTS, - ATTR_VALUE, - BSH_ACTIVE_PROGRAM, - BSH_CHILD_LOCK_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, - BSH_POWER_STATE, DOMAIN, - REFRIGERATION_DISPENSER, - REFRIGERATION_SUPERMODEFREEZER, - REFRIGERATION_SUPERMODEREFRIGERATOR, SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) -from .entity import HomeConnectDevice, HomeConnectEntity +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) +from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) SWITCHES = ( SwitchEntityDescription( - key=BSH_CHILD_LOCK_STATE, + key=SettingKey.BSH_COMMON_CHILD_LOCK, translation_key="child_lock", ), SwitchEntityDescription( - key="ConsumerProducts.CoffeeMaker.Setting.CupWarmer", + key=SettingKey.CONSUMER_PRODUCTS_COFFEE_MAKER_CUP_WARMER, translation_key="cup_warmer", ), SwitchEntityDescription( - key=REFRIGERATION_SUPERMODEFREEZER, + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER, translation_key="freezer_super_mode", ), SwitchEntityDescription( - key=REFRIGERATION_SUPERMODEREFRIGERATOR, + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR, translation_key="refrigerator_super_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.EcoMode", + key=SettingKey.REFRIGERATION_COMMON_ECO_MODE, translation_key="eco_mode", ), SwitchEntityDescription( - key="Cooking.Oven.Setting.SabbathMode", + key=SettingKey.COOKING_OVEN_SABBATH_MODE, translation_key="sabbath_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.SabbathMode", + key=SettingKey.REFRIGERATION_COMMON_SABBATH_MODE, translation_key="sabbath_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.VacationMode", + key=SettingKey.REFRIGERATION_COMMON_VACATION_MODE, translation_key="vacation_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.FreshMode", + key=SettingKey.REFRIGERATION_COMMON_FRESH_MODE, translation_key="fresh_mode", ), SwitchEntityDescription( - key=REFRIGERATION_DISPENSER, + key=SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED, translation_key="dispenser_enabled", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.Door.AssistantFridge", + key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FRIDGE, translation_key="door_assistant_fridge", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.Door.AssistantFreezer", + key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FREEZER, translation_key="door_assistant_freezer", ), ) POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( - key=BSH_POWER_STATE, + key=SettingKey.BSH_COMMON_POWER_STATE, translation_key="power", ) @@ -110,29 +107,26 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect switch.""" - def get_entities() -> list[SwitchEntity]: - """Get a list of entities.""" - entities: list[SwitchEntity] = [] - for device in entry.runtime_data.devices: - if device.appliance.type in APPLIANCES_WITH_PROGRAMS: - with contextlib.suppress(HomeConnectError): - programs = device.appliance.get_programs_available() - if programs: - entities.extend( - HomeConnectProgramSwitch(device, program) - for program in programs - ) - if BSH_POWER_STATE in device.appliance.status: - entities.append(HomeConnectPowerSwitch(device)) - entities.extend( - HomeConnectSwitch(device, description) - for description in SWITCHES - if description.key in device.appliance.status + entities: list[SwitchEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectProgramSwitch(entry.runtime_data, appliance, program) + for program in appliance.programs + if program.key != ProgramKey.UNKNOWN + ) + if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings: + entities.append( + HomeConnectPowerSwitch( + entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION + ) ) + entities.extend( + HomeConnectSwitch(entry.runtime_data, appliance, description) + for description in SWITCHES + if description.key in appliance.settings + ) - return entities - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): @@ -140,11 +134,11 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on setting.""" - - _LOGGER.debug("Turning on %s", self.entity_description.key) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.entity_description.key, True + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=True, ) except HomeConnectError as err: self._attr_available = False @@ -158,19 +152,15 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): }, ) from err - self._attr_available = True - self.async_entity_update() - async def async_turn_off(self, **kwargs: Any) -> None: """Turn off setting.""" - - _LOGGER.debug("Turning off %s", self.entity_description.key) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.entity_description.key, False + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=False, ) except HomeConnectError as err: - _LOGGER.error("Error while trying to turn off: %s", err) self._attr_available = False raise HomeAssistantError( translation_domain=DOMAIN, @@ -182,38 +172,35 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): }, ) from err - self._attr_available = True - self.async_entity_update() - - async def async_update(self) -> None: + def update_native_value(self) -> None: """Update the switch's status.""" - - self._attr_is_on = self.device.appliance.status.get( - self.entity_description.key, {} - ).get(ATTR_VALUE) - self._attr_available = True - _LOGGER.debug( - "Updated %s, new state: %s", - self.entity_description.key, - self._attr_is_on, - ) + self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): """Switch class for Home Connect.""" - def __init__(self, device: HomeConnectDevice, program_name: str) -> None: + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + program: EnumerateAvailableProgram, + ) -> None: """Initialize the entity.""" - desc = " ".join(["Program", program_name.split(".")[-1]]) - if device.appliance.type == "WasherDryer": + desc = " ".join(["Program", program.key.split(".")[-1]]) + if appliance.info.type == "WasherDryer": desc = " ".join( - ["Program", program_name.split(".")[-3], program_name.split(".")[-1]] + ["Program", program.key.split(".")[-3], program.key.split(".")[-1]] ) - super().__init__(device, SwitchEntityDescription(key=program_name)) - self._attr_name = f"{device.appliance.name} {desc}" - self._attr_unique_id = f"{device.appliance.haId}-{desc}" + super().__init__( + coordinator, + appliance, + SwitchEntityDescription(key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM), + ) + self._attr_name = f"{appliance.info.name} {desc}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc}" self._attr_has_entity_name = False - self.program_name = program_name + self.program = program async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -266,10 +253,9 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Start the program.""" - _LOGGER.debug("Tried to turn on program %s", self.program_name) try: - await self.hass.async_add_executor_job( - self.device.appliance.start_program, self.program_name + await self.coordinator.client.start_program( + self.appliance.info.ha_id, program_key=self.program.key ) except HomeConnectError as err: raise HomeAssistantError( @@ -277,16 +263,14 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): translation_key="start_program", translation_placeholders={ **get_dict_from_home_connect_error(err), - "program": self.program_name, + "program": self.program.key, }, ) from err - self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: """Stop the program.""" - _LOGGER.debug("Tried to stop program %s", self.program_name) try: - await self.hass.async_add_executor_job(self.device.appliance.stop_program) + await self.coordinator.client.stop_program(self.appliance.info.ha_id) except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -295,48 +279,25 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): **get_dict_from_home_connect_error(err), }, ) from err - self.async_entity_update() - async def async_update(self) -> None: - """Update the switch's status.""" - state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {}) - if state.get(ATTR_VALUE) == self.program_name: - self._attr_is_on = True - else: - self._attr_is_on = False - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) + def update_native_value(self) -> None: + """Update the switch's status based on if the program related to this entity is currently active.""" + event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM) + self._attr_is_on = bool(event and event.value == self.program.key) class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): """Power switch class for Home Connect.""" - power_off_state: str | None - - def __init__(self, device: HomeConnectDevice) -> None: - """Initialize the entity.""" - super().__init__( - device, - POWER_SWITCH_DESCRIPTION, - ) - if ( - power_state := device.appliance.status.get(BSH_POWER_STATE, {}).get( - ATTR_VALUE - ) - ) and power_state in [BSH_POWER_OFF, BSH_POWER_STANDBY]: - self.power_off_state = power_state - - async def async_added_to_hass(self) -> None: - """Add the entity to the hass instance.""" - await super().async_added_to_hass() - if not hasattr(self, "power_off_state"): - await self.async_fetch_power_off_state() + power_off_state: str | None | UndefinedType = UNDEFINED async def async_turn_on(self, **kwargs: Any) -> None: """Switch the device on.""" - _LOGGER.debug("Tried to switch on %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, BSH_POWER_STATE, BSH_POWER_ON + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=BSH_POWER_ON, ) except HomeConnectError as err: self._attr_is_on = False @@ -345,36 +306,36 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name, + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, }, ) from err - self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: """Switch the device off.""" - if not hasattr(self, "power_off_state"): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_retrieve_turn_off", - translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name - }, - ) + if self.power_off_state is UNDEFINED: + await self.async_fetch_power_off_state() + if self.power_off_state is UNDEFINED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_retrieve_turn_off", + translation_placeholders={ + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name + }, + ) if self.power_off_state is None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off_not_supported", translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name }, ) - _LOGGER.debug("tried to switch off %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - BSH_POWER_STATE, - self.power_off_state, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=self.power_off_state, ) except HomeConnectError as err: self._attr_is_on = True @@ -383,46 +344,51 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name, + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state, }, ) from err - self.async_entity_update() - async def async_update(self) -> None: - """Update the switch's status.""" - if ( - self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) - == BSH_POWER_ON - ): + def update_native_value(self) -> None: + """Set the value of the entity.""" + power_state = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE] + value = cast(str, power_state.value) + if value == BSH_POWER_ON: self._attr_is_on = True elif ( - hasattr(self, "power_off_state") - and self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) - == self.power_off_state + isinstance(self.power_off_state, str) + and self.power_off_state + and value == self.power_off_state ): self._attr_is_on = False + elif self.power_off_state is UNDEFINED and value in [ + BSH_POWER_OFF, + BSH_POWER_STANDBY, + ]: + self.power_off_state = value + self._attr_is_on = False else: self._attr_is_on = None - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) async def async_fetch_power_off_state(self) -> None: """Fetch the power off state.""" - try: - data = await self.hass.async_add_executor_job( - self.device.appliance.get, f"/settings/{self.bsh_key}" - ) - except HomeConnectError as err: - _LOGGER.error("An error occurred: %s", err) - return - if not data or not ( - allowed_values := data.get(ATTR_CONSTRAINTS, {}).get(ATTR_ALLOWED_VALUES) - ): + data = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE] + + if not data.constraints or not data.constraints.allowed_values: + try: + data = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + ) + except HomeConnectError as err: + _LOGGER.error("An error occurred fetching the power settings: %s", err) + return + if not data.constraints or not data.constraints.allowed_values: return - if BSH_POWER_OFF in allowed_values: + if BSH_POWER_OFF in data.constraints.allowed_values: self.power_off_state = BSH_POWER_OFF - elif BSH_POWER_STANDBY in allowed_values: + elif BSH_POWER_STANDBY in data.constraints.allowed_values: self.power_off_state = BSH_POWER_STANDBY else: self.power_off_state = None diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index c1f125cd2f7..5ed07424082 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -1,32 +1,30 @@ """Provides time enties for Home Connect.""" from datetime import time -import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - ATTR_VALUE, DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity - -_LOGGER = logging.getLogger(__name__) - +from .utils import get_dict_from_home_connect_error TIME_ENTITIES = ( TimeEntityDescription( - key="BSH.Common.Setting.AlarmClock", + key=SettingKey.BSH_COMMON_ALARM_CLOCK, translation_key="alarm_clock", ), ) @@ -39,16 +37,14 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect switch.""" - def get_entities() -> list[HomeConnectTimeEntity]: - """Get a list of entities.""" - return [ - HomeConnectTimeEntity(device, description) + async_add_entities( + [ + HomeConnectTimeEntity(entry.runtime_data, appliance, description) for description in TIME_ENTITIES - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) def seconds_to_time(seconds: int) -> time: @@ -68,17 +64,11 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): async def async_set_value(self, value: time) -> None: """Set the native value of the entity.""" - _LOGGER.debug( - "Tried to set value %s to %s for %s", - value, - self.bsh_key, - self.entity_id, - ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self.bsh_key, - time_to_seconds(value), + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=time_to_seconds(value), ) except HomeConnectError as err: raise HomeAssistantError( @@ -92,16 +82,7 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): }, ) from err - async def async_update(self) -> None: - """Update the Time setting status.""" - data = self.device.appliance.status.get(self.bsh_key) - if data is None: - _LOGGER.error("No value for %s", self.bsh_key) - self._attr_native_value = None - return - seconds = data.get(ATTR_VALUE, None) - if seconds is not None: - self._attr_native_value = seconds_to_time(seconds) - else: - self._attr_native_value = None - _LOGGER.debug("Updated, new value: %s", self._attr_native_value) + def update_native_value(self) -> None: + """Set the value of the entity.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_value = seconds_to_time(data.value) diff --git a/homeassistant/components/home_connect/utils.py b/homeassistant/components/home_connect/utils.py new file mode 100644 index 00000000000..108465072e1 --- /dev/null +++ b/homeassistant/components/home_connect/utils.py @@ -0,0 +1,29 @@ +"""Utility functions for Home Connect.""" + +import re + +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError + +RE_CAMEL_CASE = re.compile(r"(? dict[str, str]: + """Return a translation string from a Home Connect error.""" + return { + "error": str(err) + if isinstance(err, HomeConnectApiError) + else type(err).__name__ + } + + +def bsh_key_to_translation_key(bsh_key: str) -> str: + """Convert a BSH key to a translation key format. + + This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, + and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. + """ + return "_".join( + RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") + ).lower() diff --git a/requirements_all.txt b/requirements_all.txt index 9e6da1045a4..731b1cdeb67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,6 +263,9 @@ aioharmony==0.4.1 # homeassistant.components.hassio aiohasupervisor==0.2.2b6 +# homeassistant.components.home_connect +aiohomeconnect==0.12.1 + # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -1148,9 +1151,6 @@ home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 -# homeassistant.components.home_connect -homeconnect==0.8.0 - # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76ae46099c2..db89f8db9d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -248,6 +248,9 @@ aioharmony==0.4.1 # homeassistant.components.hassio aiohasupervisor==0.2.2b6 +# homeassistant.components.home_connect +aiohomeconnect==0.12.1 + # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -977,9 +980,6 @@ home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 -# homeassistant.components.home_connect -homeconnect==0.8.0 - # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 2ac8c851e1b..af039f04c03 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -1,18 +1,32 @@ """Test fixtures for home_connect.""" -from collections.abc import Awaitable, Callable, Generator +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable +import copy import time -from typing import Any -from unittest.mock import MagicMock, Mock, PropertyMock, patch +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + ArrayOfAvailablePrograms, + ArrayOfEvents, + ArrayOfHomeAppliances, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Option, +) +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect import update_all_devices from homeassistant.components.home_connect.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -20,12 +34,17 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_object_fixture -MOCK_APPLIANCES_PROPERTIES = { - x["name"]: x - for x in load_json_object_fixture("home_connect/appliances.json")["data"][ - "homeappliances" - ] -} +MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( + load_json_object_fixture("home_connect/appliances.json")["data"] +) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture( + "home_connect/programs-available.json" +) +MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") +MOCK_STATUS = ArrayOfStatus.from_dict( + load_json_object_fixture("home_connect/status.json")["data"] +) + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -102,32 +121,23 @@ def platforms() -> list[Platform]: return [] -async def bypass_throttle(hass: HomeAssistant, config_entry: MockConfigEntry): - """Add kwarg to disable throttle.""" - await update_all_devices(hass, config_entry, no_throttle=True) - - -@pytest.fixture(name="bypass_throttle") -def mock_bypass_throttle() -> Generator[None]: - """Fixture to bypass the throttle decorator in __init__.""" - with patch( - "homeassistant.components.home_connect.update_all_devices", - side_effect=bypass_throttle, - ): - yield - - @pytest.fixture(name="integration_setup") async def mock_integration_setup( hass: HomeAssistant, platforms: list[Platform], config_entry: MockConfigEntry, -) -> Callable[[], Awaitable[bool]]: +) -> Callable[[MagicMock], Awaitable[bool]]: """Fixture to set up the integration.""" config_entry.add_to_hass(hass) - async def run() -> bool: - with patch("homeassistant.components.home_connect.PLATFORMS", platforms): + async def run(client: MagicMock) -> bool: + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch( + "homeassistant.components.home_connect.HomeConnectClient" + ) as client_mock, + ): + client_mock.return_value = client result = await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return result @@ -135,125 +145,205 @@ async def mock_integration_setup( return run -@pytest.fixture(name="get_appliances") -def mock_get_appliances() -> Generator[MagicMock]: - """Mock ConfigEntryAuth parent (HomeAssistantAPI) method.""" - with patch( - "homeassistant.components.home_connect.api.ConfigEntryAuth.get_appliances", - ) as mock: - yield mock +def _get_set_program_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey +): + """Set program side effect.""" + + async def set_program_side_effect(ha_id: str, *_, **kwargs) -> None: + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=str(kwargs["program_key"]), + ), + *[ + Event( + key=(option_event := EventKey(option.key)), + raw_key=option_event.value, + timestamp=0, + level="", + handling="", + value=str(option.key), + ) + for option in cast( + list[Option], kwargs.get("options", []) + ) + ], + ] + ), + ), + ] + ) + + return set_program_side_effect -@pytest.fixture(name="appliance") -def mock_appliance(request: pytest.FixtureRequest) -> MagicMock: +def _get_set_key_value_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], parameter_key: str +): + """Set program options side effect.""" + + async def set_key_value_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs[parameter_key]) + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + ) + ] + ), + ), + ] + ) + + return set_key_value_side_effect + + +async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePrograms: + """Get available programs.""" + appliance_type = next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type + if appliance_type not in MOCK_PROGRAMS: + raise HomeConnectApiError("error.key", "error description") + + return ArrayOfAvailablePrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) + + +async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + """Get settings.""" + return ArrayOfSettings.from_dict( + MOCK_SETTINGS.get( + next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + ) + + +@pytest.fixture(name="client") +def mock_client(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect.""" + + mock = MagicMock( + autospec=HomeConnectClient, + ) + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def add_events(events: list[EventMessage]) -> None: + await event_queue.put(events) + + mock.add_events = add_events + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.stream_all_events = stream_all_events + mock.start_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM + ) + ) + mock.set_selected_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM + ), + ) + mock.set_active_program_option = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + ) + mock.set_selected_program_option = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + ) + mock.set_setting = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"), + ) + mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) + mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) + mock.get_available_programs = AsyncMock( + side_effect=_get_available_programs_side_effect + ) + mock.put_command = AsyncMock() + + mock.side_effect = mock + return mock + + +@pytest.fixture(name="client_with_exception") +def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect that raise exceptions.""" + mock = MagicMock( + autospec=HomeConnectClient, + ) + + exception = HomeConnectError() + if hasattr(request, "param") and request.param: + exception = request.param + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.stream_all_events = stream_all_events + + mock.start_program = AsyncMock(side_effect=exception) + mock.stop_program = AsyncMock(side_effect=exception) + mock.get_available_programs = AsyncMock(side_effect=exception) + mock.set_selected_program = AsyncMock(side_effect=exception) + mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_option = AsyncMock(side_effect=exception) + mock.set_setting = AsyncMock(side_effect=exception) + mock.get_settings = AsyncMock(side_effect=exception) + mock.get_setting = AsyncMock(side_effect=exception) + mock.get_status = AsyncMock(side_effect=exception) + mock.get_available_programs = AsyncMock(side_effect=exception) + mock.put_command = AsyncMock(side_effect=exception) + + return mock + + +@pytest.fixture(name="appliance_ha_id") +def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str: """Fixture to mock Appliance.""" app = "Washer" if hasattr(request, "param") and request.param: app = request.param - - mock = MagicMock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.return_value = {} - mock.get_programs_available.return_value = [] - mock.get_status.return_value = {} - mock.get_settings.return_value = {} - - return mock - - -@pytest.fixture(name="problematic_appliance") -def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock: - """Fixture to mock a problematic Appliance.""" - app = "Washer" - if hasattr(request, "param") and request.param: - app = request.param - - mock = Mock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.side_effect = HomeConnectError - mock.get_programs_active.side_effect = HomeConnectError - mock.get_programs_available.side_effect = HomeConnectError - mock.start_program.side_effect = HomeConnectError - mock.select_program.side_effect = HomeConnectError - mock.pause_program.side_effect = HomeConnectError - mock.stop_program.side_effect = HomeConnectError - mock.set_options_active_program.side_effect = HomeConnectError - mock.set_options_selected_program.side_effect = HomeConnectError - mock.get_status.side_effect = HomeConnectError - mock.get_settings.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.execute_command.side_effect = HomeConnectError - - return mock - - -def get_all_appliances(): - """Return a list of `HomeConnectAppliance` instances for all appliances.""" - - appliances = {} - - data = load_json_object_fixture("home_connect/appliances.json").get("data") - programs_active = load_json_object_fixture("home_connect/programs-active.json") - programs_available = load_json_object_fixture( - "home_connect/programs-available.json" - ) - - def listen_callback(mock, callback): - callback["callback"](mock) - - for home_appliance in data["homeappliances"]: - api_status = load_json_object_fixture("home_connect/status.json") - api_settings = load_json_object_fixture("home_connect/settings.json") - - ha_id = home_appliance["haId"] - ha_type = home_appliance["type"] - - appliance = MagicMock(spec=HomeConnectAppliance, **home_appliance) - appliance.name = home_appliance["name"] - appliance.listen_events.side_effect = ( - lambda app=appliance, **x: listen_callback(app, x) - ) - appliance.get_programs_active.return_value = programs_active.get( - ha_type, {} - ).get("data", {}) - appliance.get_programs_available.return_value = [ - program["key"] - for program in programs_available.get(ha_type, {}) - .get("data", {}) - .get("programs", []) - ] - appliance.get_status.return_value = HomeConnectAppliance.json2dict( - api_status.get("data", {}).get("status", []) - ) - appliance.get_settings.return_value = HomeConnectAppliance.json2dict( - api_settings.get(ha_type, {}).get("data", {}).get("settings", []) - ) - setattr(appliance, "status", {}) - appliance.status.update(appliance.get_status.return_value) - appliance.status.update(appliance.get_settings.return_value) - appliance.set_setting.side_effect = ( - lambda x, y, appliance=appliance: appliance.status.update({x: {"value": y}}) - ) - appliance.start_program.side_effect = ( - lambda x, appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {"value": x}} - ) - ) - appliance.stop_program.side_effect = ( - lambda appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {}} - ) - ) - - appliances[ha_id] = appliance - - return list(appliances.values()) + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.type == app: + return appliance.ha_id + raise ValueError(f"Appliance {app} not found") diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index 1b9bec57276..a357d8fb43e 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -2,6 +2,11 @@ "Dishwasher": { "data": { "settings": [ + { + "key": "BSH.Common.Setting.ChildLock", + "value": false, + "type": "Boolean" + }, { "key": "BSH.Common.Setting.AmbientLightEnabled", "value": true, @@ -26,7 +31,13 @@ { "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", - "type": "BSH.Common.EnumType.PowerState" + "type": "BSH.Common.EnumType.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.On", + "BSH.Common.EnumType.PowerState.Off" + ] + } }, { "key": "BSH.Common.Setting.ChildLock", @@ -92,6 +103,11 @@ "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", "type": "BSH.Common.EnumType.PowerState" + }, + { + "key": "BSH.Common.Setting.AlarmClock", + "value": 0, + "type": "Integer" } ] } @@ -154,6 +170,12 @@ "max": 100, "access": "readWrite" } + }, + { + "key": "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + "value": 8, + "unit": "°C", + "type": "Double" } ] } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index f3131eac52f..f3c73a32d95 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -2,255 +2,209 @@ # name: test_async_get_config_entry_diagnostics dict({ 'BOSCH-000000000-000000000000': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/00', + 'ha_id': 'BOSCH-000000000-000000000000', + 'name': 'DNE', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'DNE', + 'vib': 'HCS000000', }), 'BOSCH-HCS000000-D00000000001': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/01', + 'ha_id': 'BOSCH-HCS000000-D00000000001', + 'name': 'WasherDryer', 'programs': list([ 'LaundryCare.WasherDryer.Program.Mix', 'LaundryCare.Washer.Option.Temperature', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'WasherDryer', + 'vib': 'HCS000001', }), 'BOSCH-HCS000000-D00000000002': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/02', + 'ha_id': 'BOSCH-HCS000000-D00000000002', + 'name': 'Refrigerator', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Refrigerator', + 'vib': 'HCS000002', }), 'BOSCH-HCS000000-D00000000003': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/03', + 'ha_id': 'BOSCH-HCS000000-D00000000003', + 'name': 'Freezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Freezer', + 'vib': 'HCS000003', }), 'BOSCH-HCS000000-D00000000004': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/04', + 'ha_id': 'BOSCH-HCS000000-D00000000004', + 'name': 'Hood', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ColorTemperature': dict({ - 'type': 'BSH.Common.EnumType.ColorTemperature', - 'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Cooking.Common.Setting.Lighting': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Cooking.Common.Setting.LightingBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Cooking.Hood.Setting.ColorTemperaturePercent': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'Cooking.Common.Setting.Lighting': True, + 'Cooking.Common.Setting.LightingBrightness': 70, + 'Cooking.Hood.Setting.ColorTemperaturePercent': 70, + 'unknown': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hood', + 'vib': 'HCS000004', }), 'BOSCH-HCS000000-D00000000005': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/05', + 'ha_id': 'BOSCH-HCS000000-D00000000005', + 'name': 'Hob', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hob', + 'vib': 'HCS000005', }), 'BOSCH-HCS000000-D00000000006': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/06', + 'ha_id': 'BOSCH-HCS000000-D00000000006', + 'name': 'CookProcessor', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CookProcessor', + 'vib': 'HCS000006', }), 'BOSCH-HCS01OVN1-43E0065FE245': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS01OVN1/03', + 'ha_id': 'BOSCH-HCS01OVN1-43E0065FE245', + 'name': 'Oven', 'programs': list([ 'Cooking.Oven.Program.HeatingMode.HotAir', 'Cooking.Oven.Program.HeatingMode.TopBottomHeating', 'Cooking.Oven.Program.HeatingMode.PizzaSetting', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'Cooking.Oven.Program.HeatingMode.HotAir', - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AlarmClock': 0, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Oven', + 'vib': 'HCS01OVN1', }), 'BOSCH-HCS04DYR1-831694AE3C5A': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS04DYR1/03', + 'ha_id': 'BOSCH-HCS04DYR1-831694AE3C5A', + 'name': 'Dryer', 'programs': list([ 'LaundryCare.Dryer.Program.Cotton', 'LaundryCare.Dryer.Program.Synthetic', 'LaundryCare.Dryer.Program.Mix', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dryer', + 'vib': 'HCS04DYR1', }), 'BOSCH-HCS06COM1-D70390681C2C': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS06COM1/03', + 'ha_id': 'BOSCH-HCS06COM1-D70390681C2C', + 'name': 'CoffeeMaker', 'programs': list([ 'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso', 'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato', @@ -259,26 +213,24 @@ 'ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato', 'ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CoffeeMaker', + 'vib': 'HCS06COM1', }), 'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -286,51 +238,30 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }), 'SIEMENS-HCS03WCH1-7BC6383CF794': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS03WCH1/03', + 'ha_id': 'SIEMENS-HCS03WCH1-7BC6383CF794', + 'name': 'Washer', 'programs': list([ 'LaundryCare.Washer.Program.Cotton', 'LaundryCare.Washer.Program.EasyCare', @@ -338,97 +269,55 @@ 'LaundryCare.Washer.Program.DelicatesSilk', 'LaundryCare.Washer.Program.Wool', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'BSH.Common.Root.ActiveProgram', - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Washer', + 'vib': 'HCS03WCH1', }), 'SIEMENS-HCS05FRF1-304F4F9E541D': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS05FRF1/03', + 'ha_id': 'SIEMENS-HCS05FRF1-304F4F9E541D', + 'name': 'FridgeFreezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Setting.Dispenser.Enabled': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.Common.Setting.Light.External.Brightness': dict({ - 'constraints': dict({ - 'access': 'readWrite', - 'max': 100, - 'min': 0, - }), - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Setting.Light.External.Power': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), + 'settings': dict({ + 'Refrigeration.Common.Setting.Dispenser.Enabled': False, + 'Refrigeration.Common.Setting.Light.External.Brightness': 70, + 'Refrigeration.Common.Setting.Light.External.Power': True, + 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator': 8, + 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': False, + 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': False, }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'FridgeFreezer', + 'vib': 'HCS05FRF1', }), }) # --- # name: test_async_get_device_diagnostics dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -436,47 +325,22 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }) # --- diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 8e108cc2b0a..182051ad64a 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,32 +1,29 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectAPI +from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, ) from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_component import async_update_entity +import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry @pytest.fixture @@ -35,123 +32,166 @@ def platforms() -> list[str]: return [Platform.BINARY_SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test binary sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize( - ("state", "expected"), + ("value", "expected"), [ (BSH_DOOR_STATE_CLOSED, "off"), (BSH_DOOR_STATE_LOCKED, "off"), (BSH_DOOR_STATE_OPEN, "on"), - ("", "unavailable"), + ("", STATE_UNKNOWN), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors_door_states( + appliance_ha_id: str, expected: str, - state: str, + value: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Tests for Appliance door states.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": state}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ("entity_id", "event_key", "event_value_update", "expected", "appliance_ha_id"), [ + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + False, + STATE_OFF, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + True, + STATE_ON, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + "", + STATE_UNKNOWN, + "Washer", + ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_CLOSED, STATE_OFF, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_OPEN, STATE_ON, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, "", - STATE_UNAVAILABLE, + STATE_UNKNOWN, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_bianry_sensors_fridge_door_states( +async def test_binary_sensors_functionality( entity_id: str, - status_key: str, + event_key: EventKey, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Tests for Home Connect Fridge appliance door states.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" assert await async_setup_component( @@ -189,8 +229,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": BSH_DOOR_STATE_OPEN}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 80f53e20b39..c015a881343 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -3,6 +3,7 @@ from http import HTTPStatus from unittest.mock import patch +from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN import pytest from homeassistant import config_entries, setup @@ -10,11 +11,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect.const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py new file mode 100644 index 00000000000..51f42a98f42 --- /dev/null +++ b/tests/components/home_connect/test_coordinator.py @@ -0,0 +1,367 @@ +"""Test for Home Connect coordinator.""" + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) +from aiohomeconnect.model.error import ( + EventStreamInterruptedError, + HomeConnectApiError, + HomeConnectError, + HomeConnectRequestError, +) +import pytest + +from homeassistant.components.home_connect.const import ( + BSH_DOOR_STATE_LOCKED, + BSH_DOOR_STATE_OPEN, + BSH_EVENT_PRESENT_STATE_PRESENT, + BSH_POWER_OFF, +) +from homeassistant.config_entries import ConfigEntries, ConfigEntryState +from homeassistant.const import EVENT_STATE_REPORTED, Platform +from homeassistant.core import ( + Event as HassEvent, + EventStateReportedData, + HomeAssistant, + callback, +) +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR, Platform.SWITCH] + + +async def test_coordinator_update( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the coordinator can update.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_coordinator_update_failing_get_appliances( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the coordinator raises ConfigEntryNotReady when it fails to get appliances.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = HomeConnectError() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_update_failing_get_settings_status( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that although is not possible to get settings and status, the config entry is loaded. + + This is for cases where some appliances are reachable and some are not in the same configuration entry. + """ + # Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + +@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize( + ("event_type", "event_key", "event_value", "entity_id"), + [ + ( + EventType.STATUS, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "sensor.dishwasher_door", + ), + ( + EventType.NOTIFY, + EventKey.BSH_COMMON_SETTING_POWER_STATE, + BSH_POWER_OFF, + "switch.dishwasher_power", + ), + ( + EventType.EVENT, + EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + BSH_EVENT_PRESENT_STATE_PRESENT, + "sensor.dishwasher_salt_nearly_empty", + ), + ], +) +async def test_event_listener( + event_type: EventType, + event_key: EventKey, + event_value: str, + entity_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the event listener works.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + event_message = EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ) + await client.add_events([event_message]) + await hass.async_block_till_done() + + new_state = hass.states.get(entity_id) + assert new_state + assert new_state.state != state.state + + # Following, we are gonna check that the listeners are clean up correctly + new_entity_id = entity_id + "_new" + listener = MagicMock() + + @callback + def listener_callback(event: HassEvent[EventStateReportedData]) -> None: + listener(event.data["entity_id"]) + + @callback + def event_filter(_: EventStateReportedData) -> bool: + return True + + hass.bus.async_listen(EVENT_STATE_REPORTED, listener_callback, event_filter) + + entity_registry.async_update_entity(entity_id, new_entity_id=new_entity_id) + await hass.async_block_till_done() + await client.add_events([event_message]) + await hass.async_block_till_done() + + # Because the entity's id has been updated, the entity has been unloaded + # and the listener has been removed, and the new entity adds a new listener, + # so the only entity that should report states is the one with the new entity id + listener.assert_called_once_with(new_entity_id) + + +async def tests_receive_setting_and_status_for_first_time_at_events( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is capable of receiving settings and status for the first time.""" + client.get_setting = AsyncMock(return_value=ArrayOfSettings([])) + client.get_status = AsyncMock(return_value=ArrayOfStatus([])) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL, + raw_key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert len(config_entry._background_tasks) == 1 + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_event_listener_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the configuration entry is reloaded when the event stream raises an API error.""" + client_with_exception.stream_all_events = MagicMock( + side_effect=HomeConnectApiError("error.key", "error description") + ) + + with patch.object( + ConfigEntries, + "async_schedule_reload", + ) as mock_schedule_reload: + await integration_setup(client_with_exception) + await hass.async_block_till_done() + + client_with_exception.stream_all_events.assert_called_once() + mock_schedule_reload.assert_called_once_with(config_entry.entry_id) + assert not config_entry._background_tasks + + +@pytest.mark.parametrize( + "exception", + [HomeConnectRequestError(), EventStreamInterruptedError()], +) +@pytest.mark.parametrize( + ( + "entity_id", + "initial_state", + "status_key", + "status_value", + "after_refresh_expected_state", + "event_key", + "event_value", + "after_event_expected_state", + ), + [ + ( + "sensor.washer_door", + "closed", + StatusKey.BSH_COMMON_DOOR_STATE, + BSH_DOOR_STATE_LOCKED, + "locked", + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "open", + ), + ], +) +@patch( + "homeassistant.components.home_connect.coordinator.EVENT_STREAM_RECONNECT_DELAY", 0 +) +async def test_event_listener_resilience( + entity_id: str, + initial_state: str, + status_key: StatusKey, + status_value: Any, + after_refresh_expected_state: str, + event_key: EventKey, + event_value: Any, + after_event_expected_state: str, + exception: HomeConnectError, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is resilient to interruptions.""" + future = hass.loop.create_future() + + async def stream_exception(): + yield await future + + client.stream_all_events = MagicMock( + side_effect=[stream_exception(), client.stream_all_events()] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(config_entry._background_tasks) == 1 + + assert hass.states.is_state(entity_id, initial_state) + + client.get_status.return_value = ArrayOfStatus( + [Status(key=status_key, raw_key=status_key.value, value=status_value)], + ) + await hass.async_block_till_done() + future.set_exception(exception) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert client.stream_all_events.call_count == 2 + assert hass.states.is_state(entity_id, after_refresh_expected_state) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, after_event_expected_state) diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index f2db6e2b67a..ab6823411dc 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -1,11 +1,9 @@ """Test diagnostics for Home Connect.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError -import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.home_connect.diagnostics import ( @@ -16,43 +14,37 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .conftest import get_all_appliances - from tests.common import MockConfigEntry -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_device_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED device = device_registry.async_get_or_create( @@ -61,69 +53,3 @@ async def test_async_get_device_diagnostics( ) assert await async_get_device_diagnostics(hass, config_entry, device) == snapshot - - -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_not_found( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, "Random-Device-ID")}, - ) - - with pytest.raises(ValueError): - await async_get_device_diagnostics(hass, config_entry, device) - - -@pytest.mark.parametrize( - ("api_error", "expected_connection_status"), - [ - (HomeConnectError(), "unknown"), - ( - HomeConnectError( - { - "key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed", - } - ), - "offline", - ), - ], -) -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_api_error( - api_error: HomeConnectError, - expected_connection_status: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - appliance.get_programs_available.side_effect = api_error - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.haId)}, - ) - - diagnostics = await async_get_device_diagnostics(hass, config_entry, device) - assert diagnostics["programs"] is None diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 69601efb42d..f62feca700a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -2,27 +2,18 @@ from collections.abc import Awaitable, Callable from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch -from freezegun.api import FrozenDateTimeFactory +from aiohomeconnect.const import OAUTH2_TOKEN +from aiohomeconnect.model import SettingKey, StatusKey +from aiohomeconnect.model.error import HomeConnectError import pytest -from requests import HTTPError import requests_mock +import respx from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.home_connect import ( - SCAN_INTERVAL, - bsh_key_to_translation_key, -) -from homeassistant.components.home_connect.const import ( - BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, - BSH_POWER_STATE, - BSH_REMOTE_START_ALLOWANCE_STATE, - COOKING_LIGHTING, - DOMAIN, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.components.home_connect.utils import bsh_key_to_translation_key from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -39,7 +30,6 @@ from .conftest import ( FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, SERVER_ACCESS_TOKEN, - get_all_appliances, ) from tests.common import MockConfigEntry @@ -126,28 +116,26 @@ SERVICE_PROGRAM_CALL_PARAMS = [ ] SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_options_active_program", - "set_option_selected": "set_options_selected_program", + "set_option_active": "set_active_program_option", + "set_option_selected": "set_selected_program_option", "change_setting": "set_setting", - "pause_program": "execute_command", - "resume_program": "execute_command", - "select_program": "select_program", + "pause_program": "put_command", + "resume_program": "put_command", + "select_program": "set_selected_program", "start_program": "start_program", } -@pytest.mark.usefixtures("bypass_throttle") -async def test_api_setup( +async def test_entry_setup( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test setup and unload.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await hass.config_entries.async_unload(config_entry.entry_id) @@ -156,72 +144,60 @@ async def test_api_setup( assert config_entry.state == ConfigEntryState.NOT_LOADED -async def test_update_throttle( - appliance: Mock, - freezer: FrozenDateTimeFactory, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, -) -> None: - """Test to check Throttle functionality.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - get_appliances_call_count = get_appliances.call_count - - # First re-load after 1 minute is not blocked. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds + 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - # Second re-load is blocked by Throttle. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds - 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - -@pytest.mark.usefixtures("bypass_throttle") async def test_exception_handling( - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize("token_expiration_time", [12345]) -@pytest.mark.usefixtures("bypass_throttle") +@respx.mock async def test_token_refresh_success( - integration_setup: Callable[[], Awaitable[bool]], + hass: HomeAssistant, + platforms: list[Platform], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, requests_mock: requests_mock.Mocker, setup_credentials: None, + client: MagicMock, ) -> None: """Test where token is expired and the refresh attempt succeeds.""" assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN) - requests_mock.get("/api/homeappliances", json={"data": {"homeappliances": []}}) - aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, ) - assert await integration_setup() + appliances = client.get_home_appliances.return_value + + async def mock_get_home_appliances(): + await client._auth.async_get_access_token() + return appliances + + client.get_home_appliances.return_value = None + client.get_home_appliances.side_effect = mock_get_home_appliances + + def init_side_effect(auth) -> MagicMock: + client._auth = auth + return client + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock, + ): + client_mock.side_effect = MagicMock(side_effect=init_side_effect) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.LOADED # Verify token request @@ -240,45 +216,43 @@ async def test_token_refresh_success( ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_http_error( +async def test_client_error( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: - """Test HTTP errors during setup integration.""" - get_appliances.side_effect = HTTPError(response=MagicMock()) + """Test client errors during setup integration.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = HomeConnectError() assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - assert get_appliances.call_count == 1 + assert not await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert client_with_exception.get_home_appliances.call_count == 1 @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") async def test_services( - service_call: list[dict[str, Any]], + service_call: dict[str, Any], hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, ) -> None: """Create and test services.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_name = service_call["service"] @@ -286,8 +260,7 @@ async def test_services( await hass.services.async_call(**service_call) await hass.async_block_till_done() assert ( - getattr(appliance, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count - == 1 + getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 ) @@ -295,26 +268,24 @@ async def test_services( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") async def test_services_exception( - service_call: list[dict[str, Any]], + service_call: dict[str, Any], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, + appliance_ha_id: str, device_registry: dr.DeviceRegistry, ) -> None: """Raise a HomeAssistantError when there is an API error.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, problematic_appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -323,25 +294,47 @@ async def test_services_exception( await hass.services.async_call(**service_call) -@pytest.mark.usefixtures("bypass_throttle") async def test_services_appliance_not_found( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + device_registry: dr.DeviceRegistry, ) -> None: """Raise a ServiceValidationError when device id does not match.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED service_call = SERVICE_KV_CALL_PARAMS[0] service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" + with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): + await hass.services.async_call(**service_call) + + unrelated_config_entry = MockConfigEntry( + domain="TEST", + ) + unrelated_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=unrelated_config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises( + ServiceValidationError, match=r"Home Connect config entry.*not found" + ): + await hass.services.async_call(**service_call) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): await hass.services.async_call(**service_call) @@ -351,7 +344,7 @@ async def test_entity_migration( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_v1_1: MockConfigEntry, - appliance: Mock, + appliance_ha_id: str, platforms: list[Platform], ) -> None: """Test entity migration.""" @@ -360,34 +353,39 @@ async def test_entity_migration( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_v1_1.entry_id, - identifiers={(DOMAIN, appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) test_entities = [ ( SENSOR_DOMAIN, "Operation State", - BSH_OPERATION_STATE, + StatusKey.BSH_COMMON_OPERATION_STATE, ), ( SWITCH_DOMAIN, "ChildLock", - BSH_CHILD_LOCK_STATE, + SettingKey.BSH_COMMON_CHILD_LOCK, ), ( SWITCH_DOMAIN, "Power", - BSH_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE, ), ( BINARY_SENSOR_DOMAIN, "Remote Start", - BSH_REMOTE_START_ALLOWANCE_STATE, + StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, ), ( LIGHT_DOMAIN, "Light", - COOKING_LIGHTING, + SettingKey.COOKING_COMMON_LIGHTING, + ), + ( # An already migrated entity + SWITCH_DOMAIN, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, ), ] @@ -395,7 +393,7 @@ async def test_entity_migration( entity_registry.async_get_or_create( domain, DOMAIN, - f"{appliance.haId}-{old_unique_id_suffix}", + f"{appliance_ha_id}-{old_unique_id_suffix}", device_id=device_entry.id, config_entry=config_entry_v1_1, ) @@ -406,7 +404,7 @@ async def test_entity_migration( for domain, _, expected_unique_id_suffix in test_entities: assert entity_registry.async_get_entity_id( - domain, DOMAIN, f"{appliance.haId}-{expected_unique_id_suffix}" + domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}" ) assert config_entry_v1_1.minor_version == 2 diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 471ddf0ec54..4f8cb60d881 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -1,20 +1,24 @@ """Tests for home_connect light entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import MagicMock, call -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError import pytest from homeassistant.components.home_connect.const import ( - BSH_AMBIENT_LIGHT_BRIGHTNESS, - BSH_AMBIENT_LIGHT_COLOR, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_ENABLED, - COOKING_LIGHTING, - COOKING_LIGHTING_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_POWER, + BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -23,26 +27,15 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, - STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Hood" -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get(TEST_HC_APP) - .get("data") - .get("settings") -} - @pytest.fixture def platforms() -> list[str]: @@ -51,29 +44,31 @@ def platforms() -> list[str]: async def test_light( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize( - ("entity_id", "status", "service", "service_data", "state", "appliance"), + ( + "entity_id", + "set_settings_args", + "service", + "exprected_attributes", + "state", + "appliance_ha_id", + ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, @@ -83,58 +78,18 @@ async def test_light( ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 200}, + {"brightness": 199}, STATE_ON, "Hood", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_OFF, - {}, - STATE_OFF, - "Hood", - ), - ( - "light.hood_functional_light", - { - COOKING_LIGHTING: { - "value": None, - }, - COOKING_LIGHTING_BRIGHTNESS: None, - }, - SERVICE_TURN_ON, - {}, - STATE_UNKNOWN, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_ON, - {"brightness": 200}, - STATE_ON, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: {"value": False}, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, @@ -144,8 +99,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80, + }, + SERVICE_TURN_ON, + {"brightness": 199}, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: False, + }, + SERVICE_TURN_OFF, + {}, + STATE_OFF, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, }, SERVICE_TURN_ON, {}, @@ -155,15 +130,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_COLOR: { - "value": "", - }, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, { - "rgb_color": [255, 255, 0], + "rgb_color": (255, 255, 0), + }, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, }, STATE_ON, "Hood", @@ -171,10 +159,7 @@ async def test_light( ( "light.fridgefreezer_external_light", { - REFRIGERATION_EXTERNAL_LIGHT_POWER: { - "value": True, - }, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS: {"value": 75}, + SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER: True, }, SERVICE_TURN_ON, {}, @@ -182,167 +167,268 @@ async def test_light( "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_light_functionality( entity_id: str, - status: dict, + set_settings_args: dict[SettingKey, Any], service: str, - service_data: dict, + exprected_attributes: dict[str, Any], state: str, - appliance: Mock, - bypass_throttle: Generator[None], + appliance_ha_id: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test light functionality.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) + service_data = exprected_attributes.copy() service_data["entity_id"] = entity_id await hass.services.async_call( LIGHT_DOMAIN, service, - service_data, - blocking=True, + {key: value for key, value in service_data.items() if value is not None}, ) - assert hass.states.is_state(entity_id, state) + await hass.async_block_till_done() + client.set_setting.assert_has_calls( + [ + call(appliance_ha_id, setting_key=setting_key, value=value) + for setting_key, value in set_settings_args.items() + ] + ) + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == state + for key, value in exprected_attributes.items(): + assert entity_state.attributes[key] == value @pytest.mark.parametrize( ( "entity_id", - "status", + "events", + "appliance_ha_id", + ), + [ + ( + "light.hood_ambient_light", + { + EventKey.BSH_COMMON_SETTING_AMBIENT_LIGHT_COLOR: "BSH.Common.EnumType.AmbientLightColor.Color1", + }, + "Hood", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_light_color_different_than_custom( + entity_id: str, + events: dict[EventKey, Any], + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that light color attributes are not set if color is different than custom.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + "rgb_color": (255, 255, 0), + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is not None + assert entity_state.attributes["hs_color"] is not None + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + for event_key, value in events.items() + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is None + assert entity_state.attributes["hs_color"] is None + + +@pytest.mark.parametrize( + ( + "entity_id", + "setting", "service", "service_data", - "mock_attr", "attr_side_effect", - "problematic_appliance", "exception_match", ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": False, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*off.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, None, HomeConnectError], - "Hood", + r"Error.*set.*brightness.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, HomeConnectError], + r"Error.*select.*custom color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, None, HomeConnectError], + r"Error.*set.*color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, + }, + [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", ), ], - indirect=["problematic_appliance"], ) -async def test_switch_exception_handling( +async def test_light_exception_handling( entity_id: str, - status: dict, + setting: dict[SettingKey, dict[str, Any]], service: str, service_data: dict, - mock_attr: str, - attr_side_effect: list, - problematic_appliance: Mock, + attr_side_effect: list[type[HomeConnectError] | None], exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test light exception handling.""" - problematic_appliance.status.update(SETTINGS_STATUS) - problematic_appliance.set_setting.side_effect = attr_side_effect - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=value, + ) + for setting_key, value in setting.items() + ] + ) + client_with_exception.set_setting.side_effect = [ + exception() if exception else None for exception in attr_side_effect + ] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await client_with_exception.set_setting() - problematic_appliance.status.update(status) service_data["entity_id"] = entity_id with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( LIGHT_DOMAIN, service, service_data, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == len(attr_side_effect) + assert client_with_exception.set_setting.call_count == len(attr_side_effect) diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index bce19161cf8..371aed928dd 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -1,22 +1,17 @@ """Tests for home_connect number entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable import random -from unittest.mock import MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.setting import SettingConstraints import pytest -from homeassistant.components.home_connect.const import ( - ATTR_CONSTRAINTS, - ATTR_STEPSIZE, - ATTR_UNIT, - ATTR_VALUE, -) from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, ATTR_VALUE as SERVICE_ATTR_VALUE, + DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -26,8 +21,6 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - from tests.common import MockConfigEntry @@ -38,25 +31,24 @@ def platforms() -> list[str]: async def test_number( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test number entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Refrigerator"], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize( ( "entity_id", "setting_key", + "type", + "expected_state", "min_value", "max_value", "step_size", @@ -64,102 +56,132 @@ async def test_number( ), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, 7, 15, 0.1, "°C", ), + ( + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, + 7, + 15, + 5, + "°C", + ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, + type: str, + expected_state: int, min_value: int, max_value: int, step_size: float, unit_of_measurement: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test number entity functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_MIN: min_value, - ATTR_MAX: max_value, - ATTR_STEPSIZE: step_size, - }, - ATTR_UNIT: unit_of_measurement, - } - ] - get_appliances.return_value = [appliance] - current_value = min_value - appliance.status.update({setting_key: {ATTR_VALUE: current_value}}) + client.get_setting.side_effect = None + client.get_setting = AsyncMock( + return_value=GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # This should not change the value + unit=unit_of_measurement, + type=type, + constraints=SettingConstraints( + min=min_value, + max=max_value, + step_size=step_size if isinstance(step_size, int) else None, + ), + ) + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, str(current_value)) - state = hass.states.get(entity_id) - assert state.attributes["min"] == min_value - assert state.attributes["max"] == max_value - assert state.attributes["step"] == step_size - assert state.attributes["unit_of_measurement"] == unit_of_measurement + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.state == str(expected_state) + attributes = entity_state.attributes + assert attributes["min"] == min_value + assert attributes["max"] == max_value + assert attributes["step"] == step_size + assert attributes["unit_of_measurement"] == unit_of_measurement - new_value = random.randint(min_value + 1, max_value) + value = random.choice( + [num for num in range(min_value, max_value + 1) if num != expected_state] + ) await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - SERVICE_ATTR_VALUE: new_value, + SERVICE_ATTR_VALUE: value, }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(float(value))) -@pytest.mark.parametrize("problematic_appliance", ["Refrigerator"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test number entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=DEFAULT_MIN_VALUE, + constraints=SettingConstraints( + min=int(DEFAULT_MIN_VALUE), + max=int(DEFAULT_MAX_VALUE), + step_size=1, + ), + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -173,4 +195,4 @@ async def test_number_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index af975979196..6ebd37266cd 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -1,39 +1,38 @@ """Tests for home_connect select entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ( + ArrayOfAvailablePrograms, + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + ProgramKey, +) +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.program import EnumerateAvailableProgram import pytest -from homeassistant.components.home_connect.const import ( - BSH_ACTIVE_PROGRAM, - BSH_SELECTED_PROGRAM, -) from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_SELECT_OPTION, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Washer") - .get("data") - .get("settings") -} - -PROGRAM = "Dishcare.Dishwasher.Program.Eco50" +from tests.common import MockConfigEntry @pytest.fixture @@ -43,119 +42,148 @@ def platforms() -> list[str]: async def test_select( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test select entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED async def test_filter_unknown_programs( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, - appliance: Mock, + client: MagicMock, entity_registry: er.EntityRegistry, ) -> None: - """Test select that programs that are not part of the official Home Connect API specification are filtered out. - - We use two programs to ensure that programs are iterated over a copy of the list, - and it does not raise problems when removing an element from the original list. - """ - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [ - PROGRAM, - "NonOfficialProgram", - "AntotherNonOfficialProgram", - ] - get_appliances.return_value = [appliance] + """Test select that only known programs are shown.""" + client.get_available_programs.side_effect = None + client.get_available_programs.return_value = ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ), + EnumerateAvailableProgram( + key=ProgramKey.UNKNOWN, + raw_key="an unknown program", + ), + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - entity = entity_registry.async_get("select.washer_selected_program") + entity = entity_registry.async_get("select.dishwasher_selected_program") assert entity - assert entity.capabilities.get(ATTR_OPTIONS) == [ - "dishcare_dishwasher_program_eco_50" - ] + assert entity.capabilities + assert entity.capabilities[ATTR_OPTIONS] == ["dishcare_dishwasher_program_eco_50"] @pytest.mark.parametrize( - ("entity_id", "status", "program_to_set"), + ( + "appliance_ha_id", + "entity_id", + "mock_method", + "program_key", + "program_to_set", + "event_key", + ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_selected_program", + "set_selected_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_active_program", + "start_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, ), ], + indirect=["appliance_ha_id"], ) -async def test_select_functionality( +async def test_select_program_functionality( + appliance_ha_id: str, entity_id: str, - status: dict, + mock_method: str, + program_key: ProgramKey, program_to_set: str, - bypass_throttle: Generator[None], + event_key: EventKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test select functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - appliance.status.update(status) + assert hass.states.is_state(entity_id, "unknown") await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, - blocking=True, + ) + await hass.async_block_till_done() + getattr(client, mock_method).assert_awaited_once_with( + appliance_ha_id, program_key=program_key ) assert hass.states.is_state(entity_id, program_to_set) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value="A not known program", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + @pytest.mark.parametrize( ( "entity_id", - "status", "program_to_set", "mock_attr", "exception_match", ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_selected_program", "dishcare_dishwasher_program_eco_50", - "select_program", + "set_selected_program", r"Error.*select.*program.*", ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_active_program", "dishcare_dishwasher_program_eco_50", "start_program", r"Error.*start.*program.*", @@ -164,32 +192,36 @@ async def test_select_functionality( ) async def test_select_exception_handling( entity_id: str, - status: dict, program_to_set: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_available_programs.side_effect = None + client_with_exception.get_available_programs.return_value = ( + ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() - problematic_appliance.status.update(status) with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SELECT_DOMAIN, @@ -197,4 +229,4 @@ async def test_select_exception_handling( {"entity_id": entity_id, "option": program_to_set}, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index f2ee3b13922..ce06a841bbb 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,75 +1,77 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock +from aiohomeconnect.model import ( + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) from freezegun.api import FrozenDateTimeFactory -from homeconnect.api import HomeConnectAPI import pytest from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_CONFIRMED, BSH_EVENT_PRESENT_STATE_OFF, BSH_EVENT_PRESENT_STATE_PRESENT, - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Dishwasher" EVENT_PROG_DELAYED_START = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" - }, -} - -EVENT_PROG_REMAIN_NO_VALUE = { - "BSH.Common.Option.RemainingProgramTime": {}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.DelayedStart", }, } EVENT_PROG_RUN = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "60"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", + }, + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 60, }, } - EVENT_PROG_UPDATE_1 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "80"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 80, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_UPDATE_2 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "20"}, - "BSH.Common.Option.ProgramProgress": {"value": "99"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 20, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 99, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_END = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Ready" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Ready", }, } @@ -80,22 +82,19 @@ def platforms() -> list[str]: return [Platform.SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -# Appliance program sequence with a delayed start. +# Appliance_ha_id program sequence with a delayed start. PROGRAM_SEQUENCE_EVENTS = ( EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, @@ -130,7 +129,7 @@ ENTITY_ID_STATES = { } -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("states", "event_run"), list( @@ -141,17 +140,16 @@ ENTITY_ID_STATES = { ) ), ) -@pytest.mark.usefixtures("bypass_throttle") async def test_event_sensors( - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, states: tuple, - event_run: dict, + event_run: dict[EventType, dict[EventKey, str | int]], freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, ) -> None: """Test sequence for sensors that are only available after an event happens.""" entity_ids = ENTITY_ID_STATES.keys() @@ -159,24 +157,48 @@ async def test_event_sensors( time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_DELAYED_START) - assert await integration_setup() + client.get_status.return_value.status.extend( + Status( + key=StatusKey(event_key.value), + raw_key=event_key.value, + value=value, + ) + for event_key, value in EVENT_PROG_DELAYED_START[EventType.STATUS].items() + ) + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(event_run) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event_run.items() + for event_key, value in events.items() + ] + ) + await hass.async_block_till_done() for entity_id, state in zip(entity_ids, states, strict=False): - await async_update_entity(hass, entity_id) - await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) # Program sequence for SensorDeviceClass.TIMESTAMP edge cases. PROGRAM_SEQUENCE_EDGE_CASE = [ - EVENT_PROG_REMAIN_NO_VALUE, + EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, EVENT_PROG_END, EVENT_PROG_END, @@ -191,60 +213,86 @@ ENTITY_ID_EDGE_CASE_STATES = [ ] -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) -@pytest.mark.usefixtures("bypass_throttle") +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) async def test_remaining_prog_time_edge_cases( - appliance: Mock, + appliance_ha_id: str, freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Run program sequence to test edge cases for the remaining_prog_time entity.""" - get_appliances.return_value = [appliance] entity_id = "sensor.dishwasher_program_finish_time" time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_REMAIN_NO_VALUE) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED for ( event, expected_state, ) in zip(PROGRAM_SEQUENCE_EDGE_CASE, ENTITY_ID_EDGE_CASE_STATES, strict=False): - appliance.status.update(event) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event.items() + for event_key, value in events.items() + ] + ) await hass.async_block_till_done() freezer.tick() assert hass.states.is_state(entity_id, expected_state) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ( + "entity_id", + "event_key", + "event_type", + "event_value_update", + "expected", + "appliance_ha_id", + ), [ ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_LOCKED, "locked", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_CLOSED, "closed", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_OPEN, "open", "Dishwasher", @@ -252,33 +300,38 @@ async def test_remaining_prog_time_edge_cases( ( "sensor.fridgefreezer_freezer_door_alarm", "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", + EventType.EVENT, "", "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "FridgeFreezer", ), ( "sensor.coffeemaker_bean_container_empty", + EventType.EVENT, "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", "", "off", @@ -286,52 +339,68 @@ async def test_remaining_prog_time_edge_cases( ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "CoffeeMaker", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors_states( entity_id: str, - status_key: str, + event_key: EventKey, + event_type: EventType, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: - """Tests for Appliance alarm sensors.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] + """Tests for Appliance_ha_id alarm sensors.""" assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ), + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 80bfcf9db96..10d393423be 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -1,24 +1,34 @@ """Tests for home_connect sensor entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfSettings, + Event, + EventKey, + EventMessage, + GetSetting, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.event import ArrayOfEvents, EventType +from aiohomeconnect.model.program import ( + ArrayOfAvailablePrograms, + EnumerateAvailableProgram, +) +from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - ATTR_ALLOWED_VALUES, - ATTR_CONSTRAINTS, - BSH_ACTIVE_PROGRAM, - BSH_CHILD_LOCK_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, - BSH_POWER_STATE, DOMAIN, - REFRIGERATION_SUPERMODEFREEZER, ) from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -36,19 +46,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Dishwasher") - .get("data") - .get("settings") -} - -PROGRAM = "LaundryCare.Dryer.Program.Mix" +from tests.common import MockConfigEntry @pytest.fixture @@ -58,231 +56,285 @@ def platforms() -> list[str]: async def test_switches( - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -@pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), - [ - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": ""}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": True}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": False}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ], - indirect=["appliance"], -) -async def test_switch_functionality( - entity_id: str, - status: dict, - service: str, - state: str, - bypass_throttle: Generator[None], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, -) -> None: - """Test switch functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True - ) - assert hass.states.is_state(entity_id, state) - - @pytest.mark.parametrize( ( "entity_id", - "status", + "service", + "settings_key_arg", + "setting_value_arg", + "state", + "appliance_ha_id", + ), + [ + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_ON, + SettingKey.BSH_COMMON_CHILD_LOCK, + True, + STATE_ON, + "Dishwasher", + ), + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_OFF, + SettingKey.BSH_COMMON_CHILD_LOCK, + False, + STATE_OFF, + "Dishwasher", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_switch_functionality( + entity_id: str, + settings_key_arg: SettingKey, + setting_value_arg: Any, + service: str, + state: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=settings_key_arg, value=setting_value_arg + ) + assert hass.states.is_state(entity_id, state) + + +@pytest.mark.parametrize( + ("entity_id", "program_key", "appliance_ha_id"), + [ + ( + "switch.dryer_program_mix", + ProgramKey.LAUNDRY_CARE_DRYER_MIX, + "Dryer", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_program_switch_functionality( + entity_id: str, + program_key: ProgramKey, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + async def mock_stop_program(ha_id: str) -> None: + """Mock stop program.""" + await client.add_events( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ), + ] + ) + + client.stop_program = AsyncMock(side_effect=mock_stop_program) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_ON) + client.start_program.assert_awaited_once_with( + appliance_ha_id, program_key=program_key + ) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_OFF) + client.stop_program.assert_awaited_once_with(appliance_ha_id) + + +@pytest.mark.parametrize( + ( + "entity_id", "service", "mock_attr", - "problematic_appliance", "exception_match", ), [ ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_ON, "start_program", - "Dishwasher", r"Error.*start.*program.*", ), ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_OFF, "stop_program", - "Dishwasher", r"Error.*stop.*program.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], ) async def test_switch_exception_handling( entity_id: str, - status: dict, service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_available_programs.side_effect = None + client_with_exception.get_available_programs.return_value = ( + ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) + ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_CHILD_LOCK, + raw_key=SettingKey.BSH_COMMON_CHILD_LOCK.value, + value=False, + ), + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=[BSH_POWER_ON, BSH_POWER_OFF] + ), + ), + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - problematic_appliance.status.update(status) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), + ("entity_id", "status", "service", "state", "appliance_ha_id"), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": True}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: True}, SERVICE_TURN_ON, STATE_ON, "FridgeFreezer", ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": False}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: False}, SERVICE_TURN_OFF, STATE_OFF, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_functionality( entity_id: str, status: dict, service: str, state: str, - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test switch functionality - entity description setup.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) @@ -292,13 +344,13 @@ async def test_ent_desc_switch_functionality( "status", "service", "mock_attr", - "problematic_appliance", + "appliance_ha_id", "exception_match", ), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_ON, "set_setting", "FridgeFreezer", @@ -306,203 +358,257 @@ async def test_ent_desc_switch_functionality( ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_OFF, "set_setting", "FridgeFreezer", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_exception_handling( entity_id: str, - status: dict, + status: dict[SettingKey, str], service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client_with_exception: MagicMock, ) -> None: """Test switch exception handling - entity description setup.""" - problematic_appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(problematic_appliance.name) - .get("data") - .get("settings") - ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=key, + raw_key=key.value, + value=value, + ) + for key, value in status.items() + ] ) - get_appliances.return_value = [problematic_appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() - - problematic_appliance.status.update(status) + await client_with_exception.set_setting() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert client_with_exception.set_setting.call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "allowed_values", "service", "power_state", "appliance"), + ( + "entity_id", + "allowed_values", + "service", + "setting_value_arg", + "power_state", + "appliance_ha_id", + ), [ ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_OFF, + BSH_POWER_OFF, STATE_OFF, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_STANDBY}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_OFF, + BSH_POWER_STANDBY, STATE_OFF, "Dishwasher", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_swtich( entity_id: str, - status: dict, - allowed_values: list[str], + allowed_values: list[str | None] | None, service: str, + setting_value_arg: str, power_state: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test power switch functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - appliance.status.update(status) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value="", + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=setting_value_arg, ) assert hass.states.is_state(entity_id, power_state) @pytest.mark.parametrize( - ("entity_id", "allowed_values", "service", "appliance", "exception_match"), + ("initial_value"), + [ + (BSH_POWER_OFF), + (BSH_POWER_STANDBY), + ], +) +async def test_power_switch_fetch_off_state_from_current_value( + initial_value: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test power switch functionality to fetch the off state from the current value.""" + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=initial_value, + ) + ] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + + +@pytest.mark.parametrize( + ("entity_id", "allowed_values", "service", "exception_match"), [ ( "switch.dishwasher_power", [BSH_POWER_ON], SERVICE_TURN_OFF, - "Dishwasher", r".*not support.*turn.*off.*", ), ( "switch.dishwasher_power", None, SERVICE_TURN_OFF, - "Dishwasher", + r".*Unable.*turn.*off.*support.*not.*determined.*", + ), + ( + "switch.dishwasher_power", + HomeConnectError(), + SERVICE_TURN_OFF, r".*Unable.*turn.*off.*support.*not.*determined.*", ), ], - indirect=["appliance"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_switch_service_validation_errors( entity_id: str, - allowed_values: list[str], + allowed_values: list[str | None] | None | HomeConnectError, service: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, exception_match: str, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test power switch functionality validation errors.""" - if allowed_values: - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + if isinstance(allowed_values, HomeConnectError): + exception = allowed_values + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + ) + ] + ) + client.get_setting = AsyncMock(side_effect=exception) + else: + setting = GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + client.get_settings.return_value = ArrayOfSettings([setting]) + client.get_setting = AsyncMock(return_value=setting) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({BSH_POWER_STATE: {"value": BSH_POWER_ON}}) - with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, + appliance_ha_id: str, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "switch.washer_program_mix" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] issue_id = f"deprecated_program_switch_{entity_id}" assert await async_setup_component( @@ -539,7 +645,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 1401e07b05a..95f9ddeba80 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -1,21 +1,19 @@ """Tests for home_connect time entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from datetime import time -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError import pytest -from homeassistant.components.home_connect.const import ATTR_VALUE from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - from tests.common import MockConfigEntry @@ -26,114 +24,98 @@ def platforms() -> list[str]: async def test_time( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test time entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) @pytest.mark.parametrize( - ("entity_id", "setting_key", "setting_value", "expected_state"), + ("entity_id", "setting_key"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: 59}, - str(time(second=59)), - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: None}, - "unknown", - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - None, - "unknown", + SettingKey.BSH_COMMON_ALARM_CLOCK, ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - setting_value: dict, - expected_state: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test time entity functionality.""" - get_appliances.return_value = [appliance] - appliance.status.update({setting_key: setting_value}) - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, expected_state) - new_value = 30 - assert hass.states.get(entity_id).state != new_value + value = 30 + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state != value await hass.services.async_call( TIME_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - ATTR_TIME: time(second=new_value), + ATTR_TIME: time(second=value), }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(time(second=value))) -@pytest.mark.parametrize("problematic_appliance", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", + SettingKey.BSH_COMMON_ALARM_CLOCK, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test time entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=30, + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -147,4 +129,4 @@ async def test_time_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2