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 <marhje52@gmail.com>

* 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 <marhje52@gmail.com>

* Simplify obtaining power settings from coordinator data

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* 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 <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Diego Rodríguez Royo 2025-01-30 02:42:41 +01:00 committed by GitHub
parent 4e3e1e91b7
commit b637129208
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 3117 additions and 2641 deletions

View File

@ -2,17 +2,16 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
import logging import logging
import re
from typing import Any, cast 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 import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID, Platform 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.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import ( from homeassistant.helpers import (
config_entry_oauth2_flow, 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.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from . import api from .api import AsyncConfigEntryAuth
from .const import ( from .const import (
ATTR_KEY, ATTR_KEY,
ATTR_PROGRAM, ATTR_PROGRAM,
ATTR_UNIT, ATTR_UNIT,
ATTR_VALUE, ATTR_VALUE,
BSH_PAUSE,
BSH_RESUME,
DOMAIN, DOMAIN,
OLD_NEW_UNIQUE_ID_SUFFIX_MAP, OLD_NEW_UNIQUE_ID_SUFFIX_MAP,
SERVICE_OPTION_ACTIVE, SERVICE_OPTION_ACTIVE,
@ -44,15 +40,11 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
SVE_TRANSLATION_PLACEHOLDER_VALUE, SVE_TRANSLATION_PLACEHOLDER_VALUE,
) )
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth] from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
RE_CAMEL_CASE = re.compile(r"(?<!^)(?=[A-Z])|(?=\d)(?<=\D)")
SCAN_INTERVAL = timedelta(minutes=1)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
SERVICE_SETTING_SCHEMA = vol.Schema( SERVICE_SETTING_SCHEMA = vol.Schema(
@ -99,17 +91,24 @@ PLATFORMS = [
] ]
def _get_appliance( async def _get_client_and_ha_id(
hass: HomeAssistant, hass: HomeAssistant, device_id: str
device_id: str | None = None, ) -> tuple[HomeConnectClient, str]:
device_entry: dr.DeviceEntry | None = None,
entry: HomeConnectConfigEntry | None = None,
) -> 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_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id) device_entry = device_registry.async_get(device_id)
assert device_entry, "Either a device id or a device entry must be provided" 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( ha_id = next(
( (
@ -119,158 +118,148 @@ def _get_appliance(
), ),
None, None,
) )
assert ha_id if ha_id is None:
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:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="appliance_not_found", translation_key="appliance_not_found",
translation_placeholders={ translation_placeholders={
"device_id": device_id, "device_id": device_id,
}, },
) from err )
return entry.runtime_data.client, ha_id
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
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Home Connect component.""" """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.""" """Execute calls to services taking a program."""
program = call.data[ATTR_PROGRAM] program = call.data[ATTR_PROGRAM]
device_id = call.data[ATTR_DEVICE_ID] client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
options = []
option_key = call.data.get(ATTR_KEY) option_key = call.data.get(ATTR_KEY)
if option_key is not None: options = (
option = {ATTR_KEY: option_key, ATTR_VALUE: call.data[ATTR_VALUE]} [
Option(
OptionKey(option_key),
call.data[ATTR_VALUE],
unit=call.data.get(ATTR_UNIT),
)
]
if option_key is not None
else None
)
option_unit = call.data.get(ATTR_UNIT) try:
if option_unit is not None: if start:
option[ATTR_UNIT] = option_unit await client.start_program(ha_id, program_key=program, options=options)
else:
options.append(option) await client.set_selected_program(
await _run_appliance_service( ha_id, program_key=program, options=options
hass, )
_get_appliance_or_raise_service_validation_error(hass, device_id), except HomeConnectError as err:
method, raise HomeAssistantError(
program, translation_domain=DOMAIN,
options, translation_key="start_program" if start else "select_program",
error_translation_key=method, translation_placeholders={
error_translation_placeholders={ **get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program,
}, },
) ) from err
async def _async_service_command(call, command): async def _async_service_set_program_options(call: ServiceCall, active: bool):
"""Execute calls to services executing a command.""" """Execute calls to services taking a program."""
device_id = call.data[ATTR_DEVICE_ID] option_key = call.data[ATTR_KEY]
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]
value = call.data[ATTR_VALUE] value = call.data[ATTR_VALUE]
unit = call.data.get(ATTR_UNIT) 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( try:
hass, if active:
_get_appliance_or_raise_service_validation_error(hass, device_id), await client.set_active_program_option(
method, ha_id,
*((key, value) if unit is None else (key, value, unit)), option_key=OptionKey(option_key),
error_translation_key=method, value=value,
error_translation_placeholders={ 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_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_set_program_options(call, True)
async def async_service_option_selected(call: ServiceCall):
"""Service for setting an option for a selected program."""
await _async_service_set_program_options(call, False)
async def async_service_setting(call: ServiceCall):
"""Service for changing a 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])
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_KEY: key,
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
}, },
) ) from err
async def async_service_option_active(call): async def async_service_pause_program(call: ServiceCall):
"""Service for setting an option for an active program."""
await _async_service_key_value(call, "set_options_active_program")
async def async_service_option_selected(call):
"""Service for setting an option for a selected program."""
await _async_service_key_value(call, "set_options_selected_program")
async def async_service_setting(call):
"""Service for changing a setting."""
await _async_service_key_value(call, "set_setting")
async def async_service_pause_program(call):
"""Service for pausing a program.""" """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.""" """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.""" """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.""" """Service for starting a program."""
await _async_service_program(call, "start_program") await _async_service_program(call, True)
hass.services.async_register( hass.services.async_register(
DOMAIN, 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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.runtime_data.start_event_listener()
return True return True
@ -339,21 +337,6 @@ async def async_unload_entry(
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 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( async def async_migrate_entry(
hass: HomeAssistant, entry: HomeConnectConfigEntry hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> bool: ) -> bool:
@ -382,25 +365,3 @@ async def async_migrate_entry(
_LOGGER.debug("Migration to version %s successful", entry.version) _LOGGER.debug("Migration to version %s successful", entry.version)
return True 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()

View File

@ -1,85 +1,28 @@
"""API for Home Connect bound to HASS OAuth.""" """API for Home Connect bound to HASS OAuth."""
from asyncio import run_coroutine_threadsafe from aiohomeconnect.client import AbstractAuth
import logging 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.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.httpx_client import get_async_client
from .const import ATTR_KEY, ATTR_VALUE, BSH_ACTIVE_PROGRAM, SIGNAL_UPDATE_ENTITIES
_LOGGER = logging.getLogger(__name__)
class ConfigEntryAuth(homeconnect.HomeConnectAPI): class AsyncConfigEntryAuth(AbstractAuth):
"""Provide Home Connect authentication tied to an OAuth2 based config entry.""" """Provide Home Connect authentication tied to an OAuth2 based config entry."""
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, oauth_session: config_entry_oauth2_flow.OAuth2Session,
implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation,
) -> None: ) -> None:
"""Initialize Home Connect Auth.""" """Initialize Home Connect Auth."""
self.hass = hass self.hass = hass
self.config_entry = config_entry super().__init__(get_async_client(hass), host=API_ENDPOINT)
self.session = config_entry_oauth2_flow.OAuth2Session( self.session = oauth_session
hass, config_entry, implementation
)
super().__init__(self.session.token)
self.devices: list[HomeConnectDevice] = []
def refresh_tokens(self) -> dict: async def async_get_access_token(self) -> str:
"""Refresh and return new Home Connect tokens using Home Assistant OAuth2 session.""" """Return a valid access token."""
run_coroutine_threadsafe( await self.session.async_ensure_token_valid()
self.session.async_ensure_token_valid(), self.hass.loop
).result()
return self.session.token return self.session.token["access_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)

View File

@ -1,10 +1,10 @@
"""Application credentials platform for Home Connect.""" """Application credentials platform for Home Connect."""
from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server.""" """Return authorization server."""

View File

@ -1,7 +1,9 @@
"""Provides a binary sensor for Home Connect.""" """Provides a binary sensor for Home Connect."""
from dataclasses import dataclass 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.automation import automations_with_entity
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
@ -19,26 +21,21 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue, async_delete_issue,
) )
from . import HomeConnectConfigEntry
from .api import HomeConnectDevice
from .const import ( from .const import (
ATTR_VALUE,
BSH_DOOR_STATE,
BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_CLOSED,
BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_LOCKED,
BSH_DOOR_STATE_OPEN, BSH_DOOR_STATE_OPEN,
BSH_REMOTE_CONTROL_ACTIVATION_STATE,
BSH_REMOTE_START_ALLOWANCE_STATE,
DOMAIN, DOMAIN,
REFRIGERATION_STATUS_DOOR_CHILLER,
REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_CLOSED,
REFRIGERATION_STATUS_DOOR_FREEZER,
REFRIGERATION_STATUS_DOOR_OPEN, REFRIGERATION_STATUS_DOOR_OPEN,
REFRIGERATION_STATUS_DOOR_REFRIGERATOR, )
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
) )
from .entity import HomeConnectEntity from .entity import HomeConnectEntity
_LOGGER = logging.getLogger(__name__)
REFRIGERATION_DOOR_BOOLEAN_MAP = { REFRIGERATION_DOOR_BOOLEAN_MAP = {
REFRIGERATION_STATUS_DOOR_CLOSED: False, REFRIGERATION_STATUS_DOOR_CLOSED: False,
REFRIGERATION_STATUS_DOOR_OPEN: True, REFRIGERATION_STATUS_DOOR_OPEN: True,
@ -54,19 +51,19 @@ class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription):
BINARY_SENSORS = ( BINARY_SENSORS = (
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key=BSH_REMOTE_CONTROL_ACTIVATION_STATE, key=StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE,
translation_key="remote_control", translation_key="remote_control",
), ),
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key=BSH_REMOTE_START_ALLOWANCE_STATE, key=StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED,
translation_key="remote_start", translation_key="remote_start",
), ),
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key="BSH.Common.Status.LocalControlActive", key=StatusKey.BSH_COMMON_LOCAL_CONTROL_ACTIVE,
translation_key="local_control", translation_key="local_control",
), ),
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key="BSH.Common.Status.BatteryChargingState", key=StatusKey.BSH_COMMON_BATTERY_CHARGING_STATE,
device_class=BinarySensorDeviceClass.BATTERY_CHARGING, device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
boolean_map={ boolean_map={
"BSH.Common.EnumType.BatteryChargingState.Charging": True, "BSH.Common.EnumType.BatteryChargingState.Charging": True,
@ -75,7 +72,7 @@ BINARY_SENSORS = (
translation_key="battery_charging_state", translation_key="battery_charging_state",
), ),
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key="BSH.Common.Status.ChargingConnection", key=StatusKey.BSH_COMMON_CHARGING_CONNECTION,
device_class=BinarySensorDeviceClass.PLUG, device_class=BinarySensorDeviceClass.PLUG,
boolean_map={ boolean_map={
"BSH.Common.EnumType.ChargingConnection.Connected": True, "BSH.Common.EnumType.ChargingConnection.Connected": True,
@ -84,31 +81,31 @@ BINARY_SENSORS = (
translation_key="charging_connection", translation_key="charging_connection",
), ),
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key="ConsumerProducts.CleaningRobot.Status.DustBoxInserted", key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED,
translation_key="dust_box_inserted", translation_key="dust_box_inserted",
), ),
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key="ConsumerProducts.CleaningRobot.Status.Lifted", key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LIFTED,
translation_key="lifted", translation_key="lifted",
), ),
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key="ConsumerProducts.CleaningRobot.Status.Lost", key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST,
translation_key="lost", translation_key="lost",
), ),
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key=REFRIGERATION_STATUS_DOOR_CHILLER, key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
device_class=BinarySensorDeviceClass.DOOR, device_class=BinarySensorDeviceClass.DOOR,
translation_key="chiller_door", translation_key="chiller_door",
), ),
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key=REFRIGERATION_STATUS_DOOR_FREEZER, key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
device_class=BinarySensorDeviceClass.DOOR, device_class=BinarySensorDeviceClass.DOOR,
translation_key="freezer_door", translation_key="freezer_door",
), ),
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key=REFRIGERATION_STATUS_DOOR_REFRIGERATOR, key=StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
device_class=BinarySensorDeviceClass.DOOR, device_class=BinarySensorDeviceClass.DOOR,
translation_key="refrigerator_door", translation_key="refrigerator_door",
@ -123,19 +120,17 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Home Connect binary sensor.""" """Set up the Home Connect binary sensor."""
def get_entities() -> list[BinarySensorEntity]:
entities: list[BinarySensorEntity] = [] entities: list[BinarySensorEntity] = []
for device in entry.runtime_data.devices: for appliance in entry.runtime_data.data.values():
entities.extend( entities.extend(
HomeConnectBinarySensor(device, description) HomeConnectBinarySensor(entry.runtime_data, appliance, description)
for description in BINARY_SENSORS for description in BINARY_SENSORS
if description.key in device.appliance.status if description.key in appliance.status
) )
if BSH_DOOR_STATE in device.appliance.status: if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status:
entities.append(HomeConnectDoorBinarySensor(device)) entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance))
return entities
async_add_entities(await hass.async_add_executor_job(get_entities), True) async_add_entities(entities)
class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
@ -143,25 +138,15 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
entity_description: HomeConnectBinarySensorEntityDescription entity_description: HomeConnectBinarySensorEntityDescription
@property def update_native_value(self) -> None:
def available(self) -> bool: """Set the native value of the binary sensor."""
"""Return true if the binary sensor is available.""" status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
return self._attr_is_on is not None if isinstance(status, bool):
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:
self._attr_is_on = status 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): class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
@ -171,13 +156,15 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
def __init__( def __init__(
self, self,
device: HomeConnectDevice, coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__( super().__init__(
device, coordinator,
appliance,
HomeConnectBinarySensorEntityDescription( HomeConnectBinarySensorEntityDescription(
key=BSH_DOOR_STATE, key=StatusKey.BSH_COMMON_DOOR_STATE,
device_class=BinarySensorDeviceClass.DOOR, device_class=BinarySensorDeviceClass.DOOR,
boolean_map={ boolean_map={
BSH_DOOR_STATE_CLOSED: False, BSH_DOOR_STATE_CLOSED: False,
@ -186,8 +173,8 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
}, },
), ),
) )
self._attr_unique_id = f"{device.appliance.haId}-Door" self._attr_unique_id = f"{appliance.info.ha_id}-Door"
self._attr_name = f"{device.appliance.name} Door" self._attr_name = f"{appliance.info.name} Door"
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass.""" """Call when entity is added to hass."""
@ -234,6 +221,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed from hass.""" """Call when entity will be removed from hass."""
await super().async_will_remove_from_hass()
async_delete_issue( async_delete_issue(
self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}"
) )

View File

@ -1,9 +1,9 @@
"""Constants for the Home Connect integration.""" """Constants for the Home Connect integration."""
from aiohomeconnect.model import EventKey, SettingKey, StatusKey
DOMAIN = "home_connect" 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 = ( APPLIANCES_WITH_PROGRAMS = (
"CleaningRobot", "CleaningRobot",
@ -17,93 +17,35 @@ APPLIANCES_WITH_PROGRAMS = (
"WasherDryer", "WasherDryer",
) )
BSH_POWER_STATE = "BSH.Common.Setting.PowerState"
BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On"
BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off"
BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" 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_PRESENT = "BSH.Common.EnumType.EventPresentState.Present"
BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confirmed" BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confirmed"
BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off" 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_RUN = "BSH.Common.EnumType.OperationState.Run"
BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause" BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause"
BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" 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_CLOSED = "Refrigeration.Common.EnumType.Door.States.Closed"
REFRIGERATION_STATUS_DOOR_OPEN = "Refrigeration.Common.EnumType.Door.States.Open" 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_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR = (
"BSH.Common.EnumType.AmbientLightColor.CustomColor" "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_CLOSED = "BSH.Common.EnumType.DoorState.Closed"
BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked" BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked"
BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open" 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_ACTIVE = "set_option_active"
SERVICE_OPTION_SELECTED = "set_option_selected" SERVICE_OPTION_SELECTED = "set_option_selected"
@ -113,51 +55,44 @@ SERVICE_SELECT_PROGRAM = "select_program"
SERVICE_SETTING = "change_setting" SERVICE_SETTING = "change_setting"
SERVICE_START_PROGRAM = "start_program" 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_KEY = "key"
ATTR_PROGRAM = "program" ATTR_PROGRAM = "program"
ATTR_SENSOR_TYPE = "sensor_type"
ATTR_SIGN = "sign"
ATTR_STEPSIZE = "stepsize"
ATTR_UNIT = "unit" ATTR_UNIT = "unit"
ATTR_VALUE = "value" 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_APPLIANCE_NAME = "appliance_name"
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id" SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id"
SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program" SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program"
SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_KEY = "key"
SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value"
OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { OLD_NEW_UNIQUE_ID_SUFFIX_MAP = {
"ChildLock": BSH_CHILD_LOCK_STATE, "ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK,
"Operation State": BSH_OPERATION_STATE, "Operation State": StatusKey.BSH_COMMON_OPERATION_STATE,
"Light": COOKING_LIGHTING, "Light": SettingKey.COOKING_COMMON_LIGHTING,
"AmbientLight": BSH_AMBIENT_LIGHT_ENABLED, "AmbientLight": SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED,
"Power": BSH_POWER_STATE, "Power": SettingKey.BSH_COMMON_POWER_STATE,
"Remaining Program Time": BSH_REMAINING_PROGRAM_TIME, "Remaining Program Time": EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME,
"Duration": BSH_COMMON_OPTION_DURATION, "Duration": EventKey.BSH_COMMON_OPTION_DURATION,
"Program Progress": BSH_COMMON_OPTION_PROGRAM_PROGRESS, "Program Progress": EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS,
"Remote Control": BSH_REMOTE_CONTROL_ACTIVATION_STATE, "Remote Control": StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE,
"Remote Start": BSH_REMOTE_START_ALLOWANCE_STATE, "Remote Start": StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED,
"Supermode Freezer": REFRIGERATION_SUPERMODEFREEZER, "Supermode Freezer": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER,
"Supermode Refrigerator": REFRIGERATION_SUPERMODEREFRIGERATOR, "Supermode Refrigerator": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR,
"Dispenser Enabled": REFRIGERATION_DISPENSER, "Dispenser Enabled": SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED,
"Internal Light": REFRIGERATION_INTERNAL_LIGHT_POWER, "Internal Light": SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER,
"External Light": REFRIGERATION_EXTERNAL_LIGHT_POWER, "External Light": SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER,
"Chiller Door": REFRIGERATION_STATUS_DOOR_CHILLER, "Chiller Door": StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER,
"Freezer Door": REFRIGERATION_STATUS_DOOR_FREEZER, "Freezer Door": StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER,
"Refrigerator Door": REFRIGERATION_STATUS_DOOR_REFRIGERATOR, "Refrigerator Door": StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR,
"Door Alarm Freezer": REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, "Door Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER,
"Door Alarm Refrigerator": REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, "Door Alarm Refrigerator": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR,
"Temperature Alarm Freezer": REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, "Temperature Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER,
"Bean Container Empty": COFFEE_EVENT_BEAN_CONTAINER_EMPTY, "Bean Container Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY,
"Water Tank Empty": COFFEE_EVENT_WATER_TANK_EMPTY, "Water Tank Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY,
"Drip Tray Full": COFFEE_EVENT_DRIP_TRAY_FULL, "Drip Tray Full": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL,
} }

View File

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

View File

@ -4,33 +4,25 @@ from __future__ import annotations
from typing import Any from typing import Any
from homeconnect.api import HomeConnectAppliance, HomeConnectError from aiohomeconnect.client import Client as HomeConnectClient
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.device_registry import DeviceEntry
from . import HomeConnectConfigEntry, _get_appliance from .const import DOMAIN
from .api import HomeConnectDevice from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]: async def _generate_appliance_diagnostics(
try: client: HomeConnectClient, appliance: HomeConnectApplianceData
programs = appliance.get_programs_available() ) -> dict[str, Any]:
except HomeConnectError:
programs = None
return { return {
"connected": appliance.connected, **appliance.info.to_dict(),
"status": appliance.status, "status": {key.value: status.value for key, status in appliance.status.items()},
"programs": programs, "settings": {
} key.value: setting.value for key, setting in appliance.settings.items()
},
"programs": [program.raw_key for program in appliance.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
} }
@ -38,14 +30,21 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: HomeConnectConfigEntry hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
return await hass.async_add_executor_job( return {
_generate_entry_diagnostics, entry.runtime_data.devices 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( async def async_get_device_diagnostics(
hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a device.""" """Return diagnostics for a device."""
appliance = _get_appliance(hass, device_entry=device, entry=entry) ha_id = next(
return await hass.async_add_executor_job(_generate_appliance_diagnostics, appliance) (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]
)

View File

@ -1,55 +1,56 @@
"""Home Connect entity base class.""" """Home Connect entity base class."""
from abc import abstractmethod
import logging import logging
from aiohomeconnect.model import EventKey
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .api import HomeConnectDevice from .const import DOMAIN
from .const import DOMAIN, SIGNAL_UPDATE_ENTITIES from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class HomeConnectEntity(Entity): class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
"""Generic Home Connect entity (base class).""" """Generic Home Connect entity (base class)."""
_attr_should_poll = False _attr_should_poll = False
_attr_has_entity_name = True _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.""" """Initialize the entity."""
self.device = device super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key)))
self.appliance = appliance
self.entity_description = desc 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( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.appliance.haId)}, identifiers={(DOMAIN, appliance.info.ha_id)},
manufacturer=device.appliance.brand, manufacturer=appliance.info.brand,
model=device.appliance.vib, model=appliance.info.vib,
name=device.appliance.name, name=appliance.info.name,
) )
self.update_native_value()
async def async_added_to_hass(self) -> None: @abstractmethod
"""Register callbacks.""" def update_native_value(self) -> None:
self.async_on_remove( """Set the value of the entity."""
async_dispatcher_connect(
self.hass, SIGNAL_UPDATE_ENTITIES, self._update_callback
)
)
@callback @callback
def _update_callback(self, ha_id: str) -> None: def _handle_coordinator_update(self) -> None:
"""Update data.""" """Handle updated data from the coordinator."""
if ha_id == self.device.appliance.haId: self.update_native_value()
self.async_entity_update() self.async_write_ha_state()
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
@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)
@property @property
def bsh_key(self) -> str: def bsh_key(self) -> str:

View File

@ -2,10 +2,10 @@
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from math import ceil from typing import Any, cast
from typing import Any
from homeconnect.api import HomeConnectError from aiohomeconnect.model import EventKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
@ -20,25 +20,18 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import color as color_util from homeassistant.util import color as color_util
from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
from .api import HomeConnectDevice
from .const import ( from .const import (
ATTR_VALUE,
BSH_AMBIENT_LIGHT_BRIGHTNESS,
BSH_AMBIENT_LIGHT_COLOR,
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
BSH_AMBIENT_LIGHT_CUSTOM_COLOR,
BSH_AMBIENT_LIGHT_ENABLED,
COOKING_LIGHTING,
COOKING_LIGHTING_BRIGHTNESS,
DOMAIN, DOMAIN,
REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS,
REFRIGERATION_EXTERNAL_LIGHT_POWER,
REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS,
REFRIGERATION_INTERNAL_LIGHT_POWER,
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
) )
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .entity import HomeConnectEntity from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -47,38 +40,38 @@ _LOGGER = logging.getLogger(__name__)
class HomeConnectLightEntityDescription(LightEntityDescription): class HomeConnectLightEntityDescription(LightEntityDescription):
"""Light entity description.""" """Light entity description."""
brightness_key: str | None = None brightness_key: SettingKey | None = None
color_key: str | None = None color_key: SettingKey | None = None
enable_custom_color_value_key: str | 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) brightness_scale: tuple[float, float] = (0.0, 100.0)
LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = ( LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = (
HomeConnectLightEntityDescription( HomeConnectLightEntityDescription(
key=REFRIGERATION_INTERNAL_LIGHT_POWER, key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER,
brightness_key=REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_BRIGHTNESS,
brightness_scale=(1.0, 100.0), brightness_scale=(1.0, 100.0),
translation_key="internal_light", translation_key="internal_light",
), ),
HomeConnectLightEntityDescription( HomeConnectLightEntityDescription(
key=REFRIGERATION_EXTERNAL_LIGHT_POWER, key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER,
brightness_key=REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_BRIGHTNESS,
brightness_scale=(1.0, 100.0), brightness_scale=(1.0, 100.0),
translation_key="external_light", translation_key="external_light",
), ),
HomeConnectLightEntityDescription( HomeConnectLightEntityDescription(
key=COOKING_LIGHTING, key=SettingKey.COOKING_COMMON_LIGHTING,
brightness_key=COOKING_LIGHTING_BRIGHTNESS, brightness_key=SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS,
brightness_scale=(10.0, 100.0), brightness_scale=(10.0, 100.0),
translation_key="cooking_lighting", translation_key="cooking_lighting",
), ),
HomeConnectLightEntityDescription( HomeConnectLightEntityDescription(
key=BSH_AMBIENT_LIGHT_ENABLED, key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED,
brightness_key=BSH_AMBIENT_LIGHT_BRIGHTNESS, brightness_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS,
color_key=BSH_AMBIENT_LIGHT_COLOR, color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR,
enable_custom_color_value_key=BSH_AMBIENT_LIGHT_COLOR_CUSTOM_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), brightness_scale=(10.0, 100.0),
translation_key="ambient_light", translation_key="ambient_light",
), ),
@ -92,16 +85,14 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Home Connect light.""" """Set up the Home Connect light."""
def get_entities() -> list[LightEntity]: async_add_entities(
"""Get a list of entities.""" [
return [ HomeConnectLight(entry.runtime_data, appliance, description)
HomeConnectLight(device, description)
for description in LIGHTS for description in LIGHTS
for device in entry.runtime_data.devices for appliance in entry.runtime_data.data.values()
if description.key in device.appliance.status if description.key in appliance.settings
] ],
)
async_add_entities(await hass.async_add_executor_job(get_entities), True)
class HomeConnectLight(HomeConnectEntity, LightEntity): class HomeConnectLight(HomeConnectEntity, LightEntity):
@ -110,13 +101,17 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
entity_description: LightEntityDescription entity_description: LightEntityDescription
def __init__( def __init__(
self, device: HomeConnectDevice, desc: HomeConnectLightEntityDescription self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
desc: HomeConnectLightEntityDescription,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(device, desc)
def get_setting_key_if_setting_exists(setting_key: str | None) -> str | None: def get_setting_key_if_setting_exists(
if setting_key and setting_key in device.appliance.status: setting_key: SettingKey | None,
) -> SettingKey | None:
if setting_key and setting_key in appliance.settings:
return setting_key return setting_key
return None return None
@ -131,6 +126,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
) )
self._brightness_scale = desc.brightness_scale self._brightness_scale = desc.brightness_scale
super().__init__(coordinator, appliance, desc)
match (self._brightness_key, self._custom_color_key): match (self._brightness_key, self._custom_color_key):
case (None, None): case (None, None):
self._attr_color_mode = ColorMode.ONOFF self._attr_color_mode = ColorMode.ONOFF
@ -144,10 +141,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Switch the light on, change brightness, change color.""" """Switch the light on, change brightness, change color."""
_LOGGER.debug("Switching light on for: %s", self.name)
try: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, self.bsh_key, True self.appliance.info.ha_id,
setting_key=SettingKey(self.bsh_key),
value=True,
) )
except HomeConnectError as err: except HomeConnectError as err:
raise HomeAssistantError( raise HomeAssistantError(
@ -158,15 +156,15 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
}, },
) from err ) from err
if self._custom_color_key: if self._color_key and self._custom_color_key:
if ( if (
ATTR_RGB_COLOR in kwargs or ATTR_HS_COLOR in kwargs ATTR_RGB_COLOR in kwargs or ATTR_HS_COLOR in kwargs
) and self._enable_custom_color_value_key: ) and self._enable_custom_color_value_key:
try: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, self.appliance.info.ha_id,
self._color_key, setting_key=self._color_key,
self._enable_custom_color_value_key, value=self._enable_custom_color_value_key,
) )
except HomeConnectError as err: except HomeConnectError as err:
raise HomeAssistantError( raise HomeAssistantError(
@ -181,10 +179,10 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
if ATTR_RGB_COLOR in kwargs: if ATTR_RGB_COLOR in kwargs:
hex_val = color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]) hex_val = color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR])
try: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, self.appliance.info.ha_id,
self._custom_color_key, setting_key=self._custom_color_key,
f"#{hex_val}", value=f"#{hex_val}",
) )
except HomeConnectError as err: except HomeConnectError as err:
raise HomeAssistantError( raise HomeAssistantError(
@ -195,10 +193,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
}, },
) from err ) from err
elif (ATTR_BRIGHTNESS in kwargs or ATTR_HS_COLOR in kwargs) and ( return
self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs 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( color_util.brightness_to_value(
self._brightness_scale, self._brightness_scale,
kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness), kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness),
@ -207,16 +206,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) 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) hex_val = color_util.color_rgb_to_hex(*rgb)
try: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, self.appliance.info.ha_id,
self._custom_color_key, setting_key=self._custom_color_key,
f"#{hex_val}", value=f"#{hex_val}",
) )
except HomeConnectError as err: except HomeConnectError as err:
raise HomeAssistantError( raise HomeAssistantError(
@ -227,21 +223,19 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
}, },
) from err ) from err
return
elif self._brightness_key and ATTR_BRIGHTNESS in kwargs: if self._brightness_key and ATTR_BRIGHTNESS in kwargs:
_LOGGER.debug( brightness = round(
"Changing brightness for: %s, to: %s",
self.name,
kwargs[ATTR_BRIGHTNESS],
)
brightness = ceil(
color_util.brightness_to_value( color_util.brightness_to_value(
self._brightness_scale, kwargs[ATTR_BRIGHTNESS] self._brightness_scale, kwargs[ATTR_BRIGHTNESS]
) )
) )
try: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, self._brightness_key, brightness self.appliance.info.ha_id,
setting_key=self._brightness_key,
value=brightness,
) )
except HomeConnectError as err: except HomeConnectError as err:
raise HomeAssistantError( raise HomeAssistantError(
@ -253,14 +247,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
}, },
) from err ) from err
self.async_entity_update()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Switch the light off.""" """Switch the light off."""
_LOGGER.debug("Switching light off for: %s", self.name)
try: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, self.bsh_key, False self.appliance.info.ha_id,
setting_key=SettingKey(self.bsh_key),
value=False,
) )
except HomeConnectError as err: except HomeConnectError as err:
raise HomeAssistantError( raise HomeAssistantError(
@ -271,30 +264,50 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
}, },
) from err ) 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.""" """Update the light's status."""
if self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is True: self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value
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
_LOGGER.debug("Updated, new light state: %s", self._attr_is_on) if self._brightness_key:
brightness = cast(
if self._custom_color_key: float, self.appliance.settings[self._brightness_key].value
color = self.device.appliance.status.get(self._custom_color_key, {}) )
self._attr_brightness = color_util.value_to_brightness(
if not color: 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_rgb_color = None
self._attr_hs_color = None self._attr_hs_color = None
self._attr_brightness = None
else: 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) rgb = color_util.rgb_hex_to_rgb_list(color_value)
self._attr_rgb_color = (rgb[0], rgb[1], rgb[2]) self._attr_rgb_color = (rgb[0], rgb[1], rgb[2])
hsv = color_util.color_RGB_to_hsv(*rgb) hsv = color_util.color_RGB_to_hsv(*rgb)
@ -303,16 +316,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
self._brightness_scale, hsv[2] self._brightness_scale, hsv[2]
) )
_LOGGER.debug( _LOGGER.debug(
"Updated, new color (%s) and new brightness (%s) ", "Updated %s, new color (%s) and new brightness (%s) ",
self.entity_id,
color_value, color_value,
self._attr_brightness, 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)

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/home_connect", "documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["homeconnect"], "loggers": ["homeconnect"],
"requirements": ["homeconnect==0.8.0"] "requirements": ["aiohomeconnect==0.12.1"]
} }

View File

@ -1,12 +1,12 @@
"""Provides number enties for Home Connect.""" """Provides number enties for Home Connect."""
import logging 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 ( from homeassistant.components.number import (
ATTR_MAX,
ATTR_MIN,
NumberDeviceClass, NumberDeviceClass,
NumberEntity, NumberEntity,
NumberEntityDescription, NumberEntityDescription,
@ -15,66 +15,63 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
from .const import ( from .const import (
ATTR_CONSTRAINTS,
ATTR_STEPSIZE,
ATTR_UNIT,
ATTR_VALUE,
DOMAIN, DOMAIN,
SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_KEY_SET_SETTING,
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE, SVE_TRANSLATION_PLACEHOLDER_VALUE,
) )
from .coordinator import HomeConnectConfigEntry
from .entity import HomeConnectEntity from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
NUMBERS = ( NUMBERS = (
NumberEntityDescription( NumberEntityDescription(
key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
translation_key="refrigerator_setpoint_temperature", translation_key="refrigerator_setpoint_temperature",
), ),
NumberEntityDescription( NumberEntityDescription(
key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureFreezer", key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_FREEZER,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
translation_key="freezer_setpoint_temperature", translation_key="freezer_setpoint_temperature",
), ),
NumberEntityDescription( NumberEntityDescription(
key="Refrigeration.Common.Setting.BottleCooler.SetpointTemperature", key=SettingKey.REFRIGERATION_COMMON_BOTTLE_COOLER_SETPOINT_TEMPERATURE,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
translation_key="bottle_cooler_setpoint_temperature", translation_key="bottle_cooler_setpoint_temperature",
), ),
NumberEntityDescription( NumberEntityDescription(
key="Refrigeration.Common.Setting.ChillerLeft.SetpointTemperature", key=SettingKey.REFRIGERATION_COMMON_CHILLER_LEFT_SETPOINT_TEMPERATURE,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
translation_key="chiller_left_setpoint_temperature", translation_key="chiller_left_setpoint_temperature",
), ),
NumberEntityDescription( NumberEntityDescription(
key="Refrigeration.Common.Setting.ChillerCommon.SetpointTemperature", key=SettingKey.REFRIGERATION_COMMON_CHILLER_COMMON_SETPOINT_TEMPERATURE,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
translation_key="chiller_setpoint_temperature", translation_key="chiller_setpoint_temperature",
), ),
NumberEntityDescription( NumberEntityDescription(
key="Refrigeration.Common.Setting.ChillerRight.SetpointTemperature", key=SettingKey.REFRIGERATION_COMMON_CHILLER_RIGHT_SETPOINT_TEMPERATURE,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
translation_key="chiller_right_setpoint_temperature", translation_key="chiller_right_setpoint_temperature",
), ),
NumberEntityDescription( NumberEntityDescription(
key="Refrigeration.Common.Setting.WineCompartment.SetpointTemperature", key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_SETPOINT_TEMPERATURE,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
translation_key="wine_compartment_setpoint_temperature", translation_key="wine_compartment_setpoint_temperature",
), ),
NumberEntityDescription( NumberEntityDescription(
key="Refrigeration.Common.Setting.WineCompartment2.SetpointTemperature", key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_2_SETPOINT_TEMPERATURE,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
translation_key="wine_compartment_2_setpoint_temperature", translation_key="wine_compartment_2_setpoint_temperature",
), ),
NumberEntityDescription( NumberEntityDescription(
key="Refrigeration.Common.Setting.WineCompartment3.SetpointTemperature", key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_3_SETPOINT_TEMPERATURE,
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
translation_key="wine_compartment_3_setpoint_temperature", translation_key="wine_compartment_3_setpoint_temperature",
), ),
@ -87,17 +84,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Home Connect number.""" """Set up the Home Connect number."""
async_add_entities(
def get_entities() -> list[HomeConnectNumberEntity]: [
"""Get a list of entities.""" HomeConnectNumberEntity(entry.runtime_data, appliance, description)
return [
HomeConnectNumberEntity(device, description)
for description in NUMBERS for description in NUMBERS
for device in entry.runtime_data.devices for appliance in entry.runtime_data.data.values()
if description.key in device.appliance.status if description.key in appliance.settings
] ],
)
async_add_entities(await hass.async_add_executor_job(get_entities), True)
class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
@ -112,10 +106,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
self.entity_id, self.entity_id,
) )
try: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, self.appliance.info.ha_id,
self.bsh_key, setting_key=SettingKey(self.bsh_key),
value, value=value,
) )
except HomeConnectError as err: except HomeConnectError as err:
raise HomeAssistantError( raise HomeAssistantError(
@ -132,34 +126,41 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
async def async_fetch_constraints(self) -> None: async def async_fetch_constraints(self) -> None:
"""Fetch the max and min values and step for the number entity.""" """Fetch the max and min values and step for the number entity."""
try: try:
data = await self.hass.async_add_executor_job( data = await self.coordinator.client.get_setting(
self.device.appliance.get, f"/settings/{self.bsh_key}" self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key)
) )
except HomeConnectError as err: except HomeConnectError as err:
_LOGGER.error("An error occurred: %s", err) _LOGGER.error("An error occurred: %s", err)
return else:
if not data or not (constraints := data.get(ATTR_CONSTRAINTS)): self.set_constraints(data)
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)
async def async_update(self) -> None: def set_constraints(self, setting: GetSetting) -> None:
"""Update the number setting status.""" """Set constraints for the number entity."""
if not (data := self.device.appliance.status.get(self.bsh_key)): if not (constraints := setting.constraints):
_LOGGER.error("No value for %s", self.bsh_key)
self._attr_native_value = None
return return
self._attr_native_value = data.get(ATTR_VALUE, None) if constraints.max:
_LOGGER.debug("Updated, new value: %s", self._attr_native_value) 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 ( if (
not hasattr(self, "_attr_native_min_value") not hasattr(self, "_attr_native_min_value")
or self._attr_native_min_value is None
or not hasattr(self, "_attr_native_max_value") or not hasattr(self, "_attr_native_max_value")
or self._attr_native_max_value is None
or not hasattr(self, "_attr_native_step") or not hasattr(self, "_attr_native_step")
or self._attr_native_step is None
): ):
await self.async_fetch_constraints() await self.async_fetch_constraints()

View File

@ -1,191 +1,28 @@
"""Provides a select platform for Home Connect.""" """Provides a select platform for Home Connect."""
import contextlib from typing import cast
import logging
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.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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, HomeConnectConfigEntry,
bsh_key_to_translation_key, HomeConnectCoordinator,
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,
) )
from .entity import HomeConnectEntity from .entity import HomeConnectEntity
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
TRANSLATION_KEYS_PROGRAMS_MAP = { TRANSLATION_KEYS_PROGRAMS_MAP = {
bsh_key_to_translation_key(program): program bsh_key_to_translation_key(program.value): cast(ProgramKey, program)
for program in ( for program in ProgramKey
"ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll", if program != ProgramKey.UNKNOWN
"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",
)
} }
PROGRAMS_TRANSLATION_KEYS_MAP = { PROGRAMS_TRANSLATION_KEYS_MAP = {
@ -194,11 +31,11 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
SelectEntityDescription( SelectEntityDescription(
key=BSH_ACTIVE_PROGRAM, key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
translation_key="active_program", translation_key="active_program",
), ),
SelectEntityDescription( SelectEntityDescription(
key=BSH_SELECTED_PROGRAM, key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
translation_key="selected_program", translation_key="selected_program",
), ),
) )
@ -211,31 +48,12 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Home Connect select entities.""" """Set up the Home Connect select entities."""
def get_entities() -> list[HomeConnectProgramSelectEntity]: async_add_entities(
"""Get a list of entities.""" HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
entities: list[HomeConnectProgramSelectEntity] = [] for appliance in entry.runtime_data.data.values()
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 for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
) )
return entities
async_add_entities(await hass.async_add_executor_job(get_entities), True)
class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
@ -243,48 +61,45 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
def __init__( def __init__(
self, self,
device: HomeConnectDevice, coordinator: HomeConnectCoordinator,
programs: list[str], appliance: HomeConnectApplianceData,
desc: SelectEntityDescription, desc: SelectEntityDescription,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__( super().__init__(
device, coordinator,
appliance,
desc, desc,
) )
self._attr_options = [ 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: def update_native_value(self) -> None:
"""Update the program selection status.""" """Set the program value."""
program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) event = self.appliance.events.get(cast(EventKey, self.bsh_key))
if not program: self._attr_current_option = (
program_translation_key = None PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value))
elif not ( if event
program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program) else None
):
_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)
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
"""Select new program.""" """Select new program."""
bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] program_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
try: 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: except HomeConnectError as err:
if self.start_on_select: if self.start_on_select:
translation_key = "start_program" translation_key = "start_program"
@ -295,7 +110,6 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
translation_key=translation_key, translation_key=translation_key,
translation_placeholders={ translation_placeholders={
**get_dict_from_home_connect_error(err), **get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key, SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value,
}, },
) from err ) from err
self.async_entity_update()

View File

@ -1,10 +1,11 @@
"""Provides a sensor for Home Connect.""" """Provides a sensor for Home Connect."""
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import timedelta
import logging
from typing import cast from typing import cast
from aiohomeconnect.model import EventKey, StatusKey
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
@ -12,38 +13,26 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume 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.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util, slugify from homeassistant.util import dt as dt_util, slugify
from . import HomeConnectConfigEntry
from .const import ( from .const import (
APPLIANCES_WITH_PROGRAMS, APPLIANCES_WITH_PROGRAMS,
ATTR_VALUE,
BSH_DOOR_STATE,
BSH_OPERATION_STATE,
BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_FINISHED,
BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_PAUSE,
BSH_OPERATION_STATE_RUN, 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 from .entity import HomeConnectEntity
_LOGGER = logging.getLogger(__name__)
EVENT_OPTIONS = ["confirmed", "off", "present"] EVENT_OPTIONS = ["confirmed", "off", "present"]
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class HomeConnectSensorEntityDescription(SensorEntityDescription): class HomeConnectSensorEntityDescription(
SensorEntityDescription,
):
"""Entity Description class for sensors.""" """Entity Description class for sensors."""
default_value: str | None = None default_value: str | None = None
@ -52,7 +41,7 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription):
BSH_PROGRAM_SENSORS = ( BSH_PROGRAM_SENSORS = (
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="BSH.Common.Option.RemainingProgramTime", key=EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME,
device_class=SensorDeviceClass.TIMESTAMP, device_class=SensorDeviceClass.TIMESTAMP,
translation_key="program_finish_time", translation_key="program_finish_time",
appliance_types=( appliance_types=(
@ -67,13 +56,13 @@ BSH_PROGRAM_SENSORS = (
), ),
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="BSH.Common.Option.Duration", key=EventKey.BSH_COMMON_OPTION_DURATION,
device_class=SensorDeviceClass.DURATION, device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS, native_unit_of_measurement=UnitOfTime.SECONDS,
appliance_types=("Oven",), appliance_types=("Oven",),
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="BSH.Common.Option.ProgramProgress", key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS,
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
translation_key="program_progress", translation_key="program_progress",
appliance_types=APPLIANCES_WITH_PROGRAMS, appliance_types=APPLIANCES_WITH_PROGRAMS,
@ -82,7 +71,7 @@ BSH_PROGRAM_SENSORS = (
SENSORS = ( SENSORS = (
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key=BSH_OPERATION_STATE, key=StatusKey.BSH_COMMON_OPERATION_STATE,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=[ options=[
"inactive", "inactive",
@ -98,7 +87,7 @@ SENSORS = (
translation_key="operation_state", translation_key="operation_state",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key=BSH_DOOR_STATE, key=StatusKey.BSH_COMMON_DOOR_STATE,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=[ options=[
"closed", "closed",
@ -108,59 +97,59 @@ SENSORS = (
translation_key="door", translation_key="door",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffee", key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="coffee_counter", translation_key="coffee_counter",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterPowderCoffee", key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_POWDER_COFFEE,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="powder_coffee_counter", translation_key="powder_coffee_counter",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWater", key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER,
native_unit_of_measurement=UnitOfVolume.MILLILITERS, native_unit_of_measurement=UnitOfVolume.MILLILITERS,
device_class=SensorDeviceClass.VOLUME, device_class=SensorDeviceClass.VOLUME,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="hot_water_counter", translation_key="hot_water_counter",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWaterCups", key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER_CUPS,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="hot_water_cups_counter", translation_key="hot_water_cups_counter",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotMilk", key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_MILK,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="hot_milk_counter", translation_key="hot_milk_counter",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterFrothyMilk", key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_FROTHY_MILK,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="frothy_milk_counter", translation_key="frothy_milk_counter",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterMilk", key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_MILK,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="milk_counter", translation_key="milk_counter",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffeeAndMilk", key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE_AND_MILK,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="coffee_and_milk_counter", translation_key="coffee_and_milk_counter",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterRistrettoEspresso", key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_RISTRETTO_ESPRESSO,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="ristretto_espresso_counter", translation_key="ristretto_espresso_counter",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="BSH.Common.Status.BatteryLevel", key=StatusKey.BSH_COMMON_BATTERY_LEVEL,
device_class=SensorDeviceClass.BATTERY, device_class=SensorDeviceClass.BATTERY,
translation_key="battery_level", translation_key="battery_level",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="BSH.Common.Status.Video.CameraState", key=StatusKey.BSH_COMMON_VIDEO_CAMERA_STATE,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=[ options=[
"disabled", "disabled",
@ -174,7 +163,7 @@ SENSORS = (
translation_key="camera_state", translation_key="camera_state",
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key="ConsumerProducts.CleaningRobot.Status.LastSelectedMap", key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LAST_SELECTED_MAP,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=[ options=[
"tempmap", "tempmap",
@ -188,7 +177,7 @@ SENSORS = (
EVENT_SENSORS = ( EVENT_SENSORS = (
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key=REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off", default_value="off",
@ -196,7 +185,7 @@ EVENT_SENSORS = (
appliance_types=("FridgeFreezer", "Freezer"), appliance_types=("FridgeFreezer", "Freezer"),
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key=REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off", default_value="off",
@ -204,7 +193,7 @@ EVENT_SENSORS = (
appliance_types=("FridgeFreezer", "Refrigerator"), appliance_types=("FridgeFreezer", "Refrigerator"),
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key=REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off", default_value="off",
@ -212,7 +201,7 @@ EVENT_SENSORS = (
appliance_types=("FridgeFreezer", "Freezer"), appliance_types=("FridgeFreezer", "Freezer"),
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key=COFFEE_EVENT_BEAN_CONTAINER_EMPTY, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off", default_value="off",
@ -220,7 +209,7 @@ EVENT_SENSORS = (
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key=COFFEE_EVENT_WATER_TANK_EMPTY, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off", default_value="off",
@ -228,7 +217,7 @@ EVENT_SENSORS = (
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key=COFFEE_EVENT_DRIP_TRAY_FULL, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off", default_value="off",
@ -236,7 +225,7 @@ EVENT_SENSORS = (
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key=DISHWASHER_EVENT_SALT_NEARLY_EMPTY, key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off", default_value="off",
@ -244,7 +233,7 @@ EVENT_SENSORS = (
appliance_types=("Dishwasher",), appliance_types=("Dishwasher",),
), ),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key=DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off", default_value="off",
@ -261,33 +250,30 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Home Connect sensor.""" """Set up the Home Connect sensor."""
def get_entities() -> list[SensorEntity]:
"""Get a list of entities."""
entities: list[SensorEntity] = [] entities: list[SensorEntity] = []
for device in entry.runtime_data.devices: for appliance in entry.runtime_data.data.values():
entities.extend( entities.extend(
HomeConnectSensor( HomeConnectEventSensor(
device, entry.runtime_data,
appliance,
description, description,
) )
for description in EVENT_SENSORS for description in EVENT_SENSORS
if description.appliance_types if description.appliance_types
and device.appliance.type in description.appliance_types and appliance.info.type in description.appliance_types
) )
entities.extend( entities.extend(
HomeConnectProgramSensor(device, desc) HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
for desc in BSH_PROGRAM_SENSORS for desc in BSH_PROGRAM_SENSORS
if desc.appliance_types if desc.appliance_types and appliance.info.type in desc.appliance_types
and device.appliance.type in desc.appliance_types
) )
entities.extend( entities.extend(
HomeConnectSensor(device, description) HomeConnectSensor(entry.runtime_data, appliance, description)
for description in SENSORS for description in SENSORS
if description.key in device.appliance.status if description.key in appliance.status
) )
return entities
async_add_entities(await hass.async_add_executor_job(get_entities), True) async_add_entities(entities)
class HomeConnectSensor(HomeConnectEntity, SensorEntity): class HomeConnectSensor(HomeConnectEntity, SensorEntity):
@ -295,44 +281,25 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
entity_description: HomeConnectSensorEntityDescription entity_description: HomeConnectSensorEntityDescription
async def async_update(self) -> None: def update_native_value(self) -> None:
"""Update the sensor's status.""" """Set the value of the sensor."""
appliance_status = self.device.appliance.status status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
if ( self._update_native_value(status)
self.bsh_key not in appliance_status
or ATTR_VALUE not in appliance_status[self.bsh_key] def _update_native_value(self, status: str | float) -> None:
): """Set the value of the sensor based on the given value."""
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]
match self.device_class: match self.device_class:
case SensorDeviceClass.TIMESTAMP: 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( self._attr_native_value = dt_util.utcnow() + timedelta(
seconds=seconds seconds=cast(float, status)
) )
case SensorDeviceClass.ENUM: case SensorDeviceClass.ENUM:
# Value comes back as an enum, we only really care about the # Value comes back as an enum, we only really care about the
# last part, so split it off # last part, so split it off
# https://developer.home-connect.com/docs/status/operation_state # https://developer.home-connect.com/docs/status/operation_state
self._attr_native_value = slugify( self._attr_native_value = slugify(cast(str, status).split(".")[-1])
cast(str, status.get(ATTR_VALUE)).split(".")[-1]
)
case _: case _:
self._attr_native_value = status.get(ATTR_VALUE) self._attr_native_value = status
_LOGGER.debug("Updated, new state: %s", self._attr_native_value)
class HomeConnectProgramSensor(HomeConnectSensor): class HomeConnectProgramSensor(HomeConnectSensor):
@ -340,6 +307,31 @@ class HomeConnectProgramSensor(HomeConnectSensor):
program_running: bool = False 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 @property
def available(self) -> bool: def available(self) -> bool:
"""Return true if the sensor is available.""" """Return true if the sensor is available."""
@ -347,20 +339,20 @@ class HomeConnectProgramSensor(HomeConnectSensor):
# Otherwise, some sensors report erroneous values. # Otherwise, some sensors report erroneous values.
return super().available and self.program_running 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.""" """Update the sensor's status."""
self.program_running = ( event = self.appliance.events.get(cast(EventKey, self.bsh_key))
BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status) if event:
and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] self._update_native_value(event.value)
and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] elif not self._attr_native_value:
in [ self._attr_native_value = self.entity_description.default_value
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

View File

@ -26,64 +26,67 @@
"message": "Appliance for device ID {device_id} not found" "message": "Appliance for device ID {device_id} not found"
}, },
"turn_on_light": { "turn_on_light": {
"message": "Error turning on {entity_id}: {description}" "message": "Error turning on {entity_id}: {error}"
}, },
"turn_off_light": { "turn_off_light": {
"message": "Error turning off {entity_id}: {description}" "message": "Error turning off {entity_id}: {error}"
}, },
"set_light_brightness": { "set_light_brightness": {
"message": "Error setting brightness of {entity_id}: {description}" "message": "Error setting brightness of {entity_id}: {error}"
}, },
"select_light_custom_color": { "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": { "set_light_color": {
"message": "Error setting color of {entity_id}: {description}" "message": "Error setting color of {entity_id}: {error}"
}, },
"set_setting_entity": { "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": { "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": { "turn_on": {
"message": "Error turning on {entity_id} ({key}): {description}" "message": "Error turning on {entity_id} ({key}): {error}"
}, },
"turn_off": { "turn_off": {
"message": "Error turning off {entity_id} ({key}): {description}" "message": "Error turning off {entity_id} ({key}): {error}"
}, },
"select_program": { "select_program": {
"message": "Error selecting program {program}: {description}" "message": "Error selecting program {program}: {error}"
}, },
"start_program": { "start_program": {
"message": "Error starting program {program}: {description}" "message": "Error starting program {program}: {error}"
}, },
"pause_program": { "pause_program": {
"message": "Error pausing program: {description}" "message": "Error pausing program: {error}"
}, },
"stop_program": { "stop_program": {
"message": "Error stopping program: {description}" "message": "Error stopping program: {error}"
}, },
"set_options_active_program": { "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": { "set_options_selected_program": {
"message": "Error setting options for the selected program: {description}" "message": "Error setting options for the selected program: {error}"
}, },
"execute_command": { "execute_command": {
"message": "Error executing command {command}: {description}" "message": "Error executing command {command}: {error}"
}, },
"power_on": { "power_on": {
"message": "Error turning on {appliance_name}: {description}" "message": "Error turning on {appliance_name}: {error}"
}, },
"power_off": { "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": { "turn_off_not_supported": {
"message": "{appliance_name} does not support turning off or entering standby mode." "message": "{appliance_name} does not support turning off or entering standby mode."
}, },
"unable_to_retrieve_turn_off": { "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." "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": { "issues": {

View File

@ -1,10 +1,11 @@
"""Provides a switch for Home Connect.""" """Provides a switch for Home Connect."""
import contextlib
import logging 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.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity from homeassistant.components.script import scripts_with_entity
@ -18,87 +19,83 @@ from homeassistant.helpers.issue_registry import (
async_create_issue, async_create_issue,
async_delete_issue, async_delete_issue,
) )
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
from .const import ( 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_OFF,
BSH_POWER_ON, BSH_POWER_ON,
BSH_POWER_STANDBY, BSH_POWER_STANDBY,
BSH_POWER_STATE,
DOMAIN, DOMAIN,
REFRIGERATION_DISPENSER,
REFRIGERATION_SUPERMODEFREEZER,
REFRIGERATION_SUPERMODEREFRIGERATOR,
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME,
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE, 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__) _LOGGER = logging.getLogger(__name__)
SWITCHES = ( SWITCHES = (
SwitchEntityDescription( SwitchEntityDescription(
key=BSH_CHILD_LOCK_STATE, key=SettingKey.BSH_COMMON_CHILD_LOCK,
translation_key="child_lock", translation_key="child_lock",
), ),
SwitchEntityDescription( SwitchEntityDescription(
key="ConsumerProducts.CoffeeMaker.Setting.CupWarmer", key=SettingKey.CONSUMER_PRODUCTS_COFFEE_MAKER_CUP_WARMER,
translation_key="cup_warmer", translation_key="cup_warmer",
), ),
SwitchEntityDescription( SwitchEntityDescription(
key=REFRIGERATION_SUPERMODEFREEZER, key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER,
translation_key="freezer_super_mode", translation_key="freezer_super_mode",
), ),
SwitchEntityDescription( SwitchEntityDescription(
key=REFRIGERATION_SUPERMODEREFRIGERATOR, key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR,
translation_key="refrigerator_super_mode", translation_key="refrigerator_super_mode",
), ),
SwitchEntityDescription( SwitchEntityDescription(
key="Refrigeration.Common.Setting.EcoMode", key=SettingKey.REFRIGERATION_COMMON_ECO_MODE,
translation_key="eco_mode", translation_key="eco_mode",
), ),
SwitchEntityDescription( SwitchEntityDescription(
key="Cooking.Oven.Setting.SabbathMode", key=SettingKey.COOKING_OVEN_SABBATH_MODE,
translation_key="sabbath_mode", translation_key="sabbath_mode",
), ),
SwitchEntityDescription( SwitchEntityDescription(
key="Refrigeration.Common.Setting.SabbathMode", key=SettingKey.REFRIGERATION_COMMON_SABBATH_MODE,
translation_key="sabbath_mode", translation_key="sabbath_mode",
), ),
SwitchEntityDescription( SwitchEntityDescription(
key="Refrigeration.Common.Setting.VacationMode", key=SettingKey.REFRIGERATION_COMMON_VACATION_MODE,
translation_key="vacation_mode", translation_key="vacation_mode",
), ),
SwitchEntityDescription( SwitchEntityDescription(
key="Refrigeration.Common.Setting.FreshMode", key=SettingKey.REFRIGERATION_COMMON_FRESH_MODE,
translation_key="fresh_mode", translation_key="fresh_mode",
), ),
SwitchEntityDescription( SwitchEntityDescription(
key=REFRIGERATION_DISPENSER, key=SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED,
translation_key="dispenser_enabled", translation_key="dispenser_enabled",
), ),
SwitchEntityDescription( SwitchEntityDescription(
key="Refrigeration.Common.Setting.Door.AssistantFridge", key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FRIDGE,
translation_key="door_assistant_fridge", translation_key="door_assistant_fridge",
), ),
SwitchEntityDescription( SwitchEntityDescription(
key="Refrigeration.Common.Setting.Door.AssistantFreezer", key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FREEZER,
translation_key="door_assistant_freezer", translation_key="door_assistant_freezer",
), ),
) )
POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( POWER_SWITCH_DESCRIPTION = SwitchEntityDescription(
key=BSH_POWER_STATE, key=SettingKey.BSH_COMMON_POWER_STATE,
translation_key="power", translation_key="power",
) )
@ -110,29 +107,26 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Home Connect switch.""" """Set up the Home Connect switch."""
def get_entities() -> list[SwitchEntity]:
"""Get a list of entities."""
entities: list[SwitchEntity] = [] entities: list[SwitchEntity] = []
for device in entry.runtime_data.devices: for appliance in entry.runtime_data.data.values():
if device.appliance.type in APPLIANCES_WITH_PROGRAMS:
with contextlib.suppress(HomeConnectError):
programs = device.appliance.get_programs_available()
if programs:
entities.extend( entities.extend(
HomeConnectProgramSwitch(device, program) HomeConnectProgramSwitch(entry.runtime_data, appliance, program)
for program in programs 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
)
) )
if BSH_POWER_STATE in device.appliance.status:
entities.append(HomeConnectPowerSwitch(device))
entities.extend( entities.extend(
HomeConnectSwitch(device, description) HomeConnectSwitch(entry.runtime_data, appliance, description)
for description in SWITCHES for description in SWITCHES
if description.key in device.appliance.status if description.key in appliance.settings
) )
return entities async_add_entities(entities)
async_add_entities(await hass.async_add_executor_job(get_entities), True)
class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
@ -140,11 +134,11 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on setting.""" """Turn on setting."""
_LOGGER.debug("Turning on %s", self.entity_description.key)
try: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, self.entity_description.key, True self.appliance.info.ha_id,
setting_key=SettingKey(self.bsh_key),
value=True,
) )
except HomeConnectError as err: except HomeConnectError as err:
self._attr_available = False self._attr_available = False
@ -158,19 +152,15 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
}, },
) from err ) from err
self._attr_available = True
self.async_entity_update()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off setting.""" """Turn off setting."""
_LOGGER.debug("Turning off %s", self.entity_description.key)
try: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, self.entity_description.key, False self.appliance.info.ha_id,
setting_key=SettingKey(self.bsh_key),
value=False,
) )
except HomeConnectError as err: except HomeConnectError as err:
_LOGGER.error("Error while trying to turn off: %s", err)
self._attr_available = False self._attr_available = False
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
@ -182,38 +172,35 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
}, },
) from err ) from err
self._attr_available = True def update_native_value(self) -> None:
self.async_entity_update()
async def async_update(self) -> None:
"""Update the switch's status.""" """Update the switch's status."""
self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value
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,
)
class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
"""Switch class for Home Connect.""" """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.""" """Initialize the entity."""
desc = " ".join(["Program", program_name.split(".")[-1]]) desc = " ".join(["Program", program.key.split(".")[-1]])
if device.appliance.type == "WasherDryer": if appliance.info.type == "WasherDryer":
desc = " ".join( 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)) super().__init__(
self._attr_name = f"{device.appliance.name} {desc}" coordinator,
self._attr_unique_id = f"{device.appliance.haId}-{desc}" 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._attr_has_entity_name = False
self.program_name = program_name self.program = program
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass.""" """Call when entity is added to hass."""
@ -266,10 +253,9 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Start the program.""" """Start the program."""
_LOGGER.debug("Tried to turn on program %s", self.program_name)
try: try:
await self.hass.async_add_executor_job( await self.coordinator.client.start_program(
self.device.appliance.start_program, self.program_name self.appliance.info.ha_id, program_key=self.program.key
) )
except HomeConnectError as err: except HomeConnectError as err:
raise HomeAssistantError( raise HomeAssistantError(
@ -277,16 +263,14 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
translation_key="start_program", translation_key="start_program",
translation_placeholders={ translation_placeholders={
**get_dict_from_home_connect_error(err), **get_dict_from_home_connect_error(err),
"program": self.program_name, "program": self.program.key,
}, },
) from err ) from err
self.async_entity_update()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Stop the program.""" """Stop the program."""
_LOGGER.debug("Tried to stop program %s", self.program_name)
try: 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: except HomeConnectError as err:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
@ -295,48 +279,25 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
**get_dict_from_home_connect_error(err), **get_dict_from_home_connect_error(err),
}, },
) from err ) from err
self.async_entity_update()
async def async_update(self) -> None: def update_native_value(self) -> None:
"""Update the switch's status.""" """Update the switch's status based on if the program related to this entity is currently active."""
state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {}) event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM)
if state.get(ATTR_VALUE) == self.program_name: self._attr_is_on = bool(event and event.value == self.program.key)
self._attr_is_on = True
else:
self._attr_is_on = False
_LOGGER.debug("Updated, new state: %s", self._attr_is_on)
class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
"""Power switch class for Home Connect.""" """Power switch class for Home Connect."""
power_off_state: str | None power_off_state: str | None | UndefinedType = UNDEFINED
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()
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Switch the device on.""" """Switch the device on."""
_LOGGER.debug("Tried to switch on %s", self.name)
try: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, BSH_POWER_STATE, BSH_POWER_ON self.appliance.info.ha_id,
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
value=BSH_POWER_ON,
) )
except HomeConnectError as err: except HomeConnectError as err:
self._attr_is_on = False self._attr_is_on = False
@ -345,19 +306,20 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
translation_key="power_on", translation_key="power_on",
translation_placeholders={ translation_placeholders={
**get_dict_from_home_connect_error(err), **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 ) from err
self.async_entity_update()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Switch the device off.""" """Switch the device off."""
if not hasattr(self, "power_off_state"): if self.power_off_state is UNDEFINED:
await self.async_fetch_power_off_state()
if self.power_off_state is UNDEFINED:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="unable_to_retrieve_turn_off", translation_key="unable_to_retrieve_turn_off",
translation_placeholders={ translation_placeholders={
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name
}, },
) )
@ -366,15 +328,14 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="turn_off_not_supported", translation_key="turn_off_not_supported",
translation_placeholders={ 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: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, self.appliance.info.ha_id,
BSH_POWER_STATE, setting_key=SettingKey.BSH_COMMON_POWER_STATE,
self.power_off_state, value=self.power_off_state,
) )
except HomeConnectError as err: except HomeConnectError as err:
self._attr_is_on = True self._attr_is_on = True
@ -383,46 +344,51 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
translation_key="power_off", translation_key="power_off",
translation_placeholders={ translation_placeholders={
**get_dict_from_home_connect_error(err), **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, SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state,
}, },
) from err ) from err
self.async_entity_update()
async def async_update(self) -> None: def update_native_value(self) -> None:
"""Update the switch's status.""" """Set the value of the entity."""
if ( power_state = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE]
self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) value = cast(str, power_state.value)
== BSH_POWER_ON if value == BSH_POWER_ON:
):
self._attr_is_on = True self._attr_is_on = True
elif ( elif (
hasattr(self, "power_off_state") isinstance(self.power_off_state, str)
and self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) and self.power_off_state
== self.power_off_state and value == self.power_off_state
): ):
self._attr_is_on = False 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: else:
self._attr_is_on = None self._attr_is_on = None
_LOGGER.debug("Updated, new state: %s", self._attr_is_on)
async def async_fetch_power_off_state(self) -> None: async def async_fetch_power_off_state(self) -> None:
"""Fetch the power off state.""" """Fetch the power off state."""
data = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE]
if not data.constraints or not data.constraints.allowed_values:
try: try:
data = await self.hass.async_add_executor_job( data = await self.coordinator.client.get_setting(
self.device.appliance.get, f"/settings/{self.bsh_key}" self.appliance.info.ha_id,
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
) )
except HomeConnectError as err: except HomeConnectError as err:
_LOGGER.error("An error occurred: %s", err) _LOGGER.error("An error occurred fetching the power settings: %s", err)
return return
if not data or not ( if not data.constraints or not data.constraints.allowed_values:
allowed_values := data.get(ATTR_CONSTRAINTS, {}).get(ATTR_ALLOWED_VALUES)
):
return return
if BSH_POWER_OFF in allowed_values: if BSH_POWER_OFF in data.constraints.allowed_values:
self.power_off_state = BSH_POWER_OFF 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 self.power_off_state = BSH_POWER_STANDBY
else: else:
self.power_off_state = None self.power_off_state = None

View File

@ -1,32 +1,30 @@
"""Provides time enties for Home Connect.""" """Provides time enties for Home Connect."""
from datetime import time 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.components.time import TimeEntity, TimeEntityDescription
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
from .const import ( from .const import (
ATTR_VALUE,
DOMAIN, DOMAIN,
SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_KEY_SET_SETTING,
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE, SVE_TRANSLATION_PLACEHOLDER_VALUE,
) )
from .coordinator import HomeConnectConfigEntry
from .entity import HomeConnectEntity from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
TIME_ENTITIES = ( TIME_ENTITIES = (
TimeEntityDescription( TimeEntityDescription(
key="BSH.Common.Setting.AlarmClock", key=SettingKey.BSH_COMMON_ALARM_CLOCK,
translation_key="alarm_clock", translation_key="alarm_clock",
), ),
) )
@ -39,16 +37,14 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Home Connect switch.""" """Set up the Home Connect switch."""
def get_entities() -> list[HomeConnectTimeEntity]: async_add_entities(
"""Get a list of entities.""" [
return [ HomeConnectTimeEntity(entry.runtime_data, appliance, description)
HomeConnectTimeEntity(device, description)
for description in TIME_ENTITIES for description in TIME_ENTITIES
for device in entry.runtime_data.devices for appliance in entry.runtime_data.data.values()
if description.key in device.appliance.status if description.key in appliance.settings
] ],
)
async_add_entities(await hass.async_add_executor_job(get_entities), True)
def seconds_to_time(seconds: int) -> time: def seconds_to_time(seconds: int) -> time:
@ -68,17 +64,11 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity):
async def async_set_value(self, value: time) -> None: async def async_set_value(self, value: time) -> None:
"""Set the native value of the entity.""" """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: try:
await self.hass.async_add_executor_job( await self.coordinator.client.set_setting(
self.device.appliance.set_setting, self.appliance.info.ha_id,
self.bsh_key, setting_key=SettingKey(self.bsh_key),
time_to_seconds(value), value=time_to_seconds(value),
) )
except HomeConnectError as err: except HomeConnectError as err:
raise HomeAssistantError( raise HomeAssistantError(
@ -92,16 +82,7 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity):
}, },
) from err ) from err
async def async_update(self) -> None: def update_native_value(self) -> None:
"""Update the Time setting status.""" """Set the value of the entity."""
data = self.device.appliance.status.get(self.bsh_key) data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
if data is None: self._attr_native_value = seconds_to_time(data.value)
_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)

View File

@ -0,0 +1,29 @@
"""Utility functions for Home Connect."""
import re
from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError
RE_CAMEL_CASE = re.compile(r"(?<!^)(?=[A-Z])|(?=\d)(?<=\D)")
def get_dict_from_home_connect_error(
err: HomeConnectError,
) -> 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()

6
requirements_all.txt generated
View File

@ -263,6 +263,9 @@ aioharmony==0.4.1
# homeassistant.components.hassio # homeassistant.components.hassio
aiohasupervisor==0.2.2b6 aiohasupervisor==0.2.2b6
# homeassistant.components.home_connect
aiohomeconnect==0.12.1
# homeassistant.components.homekit_controller # homeassistant.components.homekit_controller
aiohomekit==3.2.7 aiohomekit==3.2.7
@ -1148,9 +1151,6 @@ home-assistant-frontend==20250129.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.1.28 home-assistant-intents==2025.1.28
# homeassistant.components.home_connect
homeconnect==0.8.0
# homeassistant.components.homematicip_cloud # homeassistant.components.homematicip_cloud
homematicip==1.1.7 homematicip==1.1.7

View File

@ -248,6 +248,9 @@ aioharmony==0.4.1
# homeassistant.components.hassio # homeassistant.components.hassio
aiohasupervisor==0.2.2b6 aiohasupervisor==0.2.2b6
# homeassistant.components.home_connect
aiohomeconnect==0.12.1
# homeassistant.components.homekit_controller # homeassistant.components.homekit_controller
aiohomekit==3.2.7 aiohomekit==3.2.7
@ -977,9 +980,6 @@ home-assistant-frontend==20250129.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2025.1.28 home-assistant-intents==2025.1.28
# homeassistant.components.home_connect
homeconnect==0.8.0
# homeassistant.components.homematicip_cloud # homeassistant.components.homematicip_cloud
homematicip==1.1.7 homematicip==1.1.7

View File

@ -1,18 +1,32 @@
"""Test fixtures for home_connect.""" """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 import time
from typing import Any from typing import Any, cast
from unittest.mock import MagicMock, Mock, PropertyMock, patch 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 import pytest
from homeassistant.components.application_credentials import ( from homeassistant.components.application_credentials import (
ClientCredential, ClientCredential,
async_import_client_credential, async_import_client_credential,
) )
from homeassistant.components.home_connect import update_all_devices
from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.home_connect.const import DOMAIN
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant 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 from tests.common import MockConfigEntry, load_json_object_fixture
MOCK_APPLIANCES_PROPERTIES = { MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict(
x["name"]: x load_json_object_fixture("home_connect/appliances.json")["data"]
for x in load_json_object_fixture("home_connect/appliances.json")["data"][ )
"homeappliances" 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_ID = "1234"
CLIENT_SECRET = "5678" CLIENT_SECRET = "5678"
@ -102,32 +121,23 @@ def platforms() -> list[Platform]:
return [] 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") @pytest.fixture(name="integration_setup")
async def mock_integration_setup( async def mock_integration_setup(
hass: HomeAssistant, hass: HomeAssistant,
platforms: list[Platform], platforms: list[Platform],
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
) -> Callable[[], Awaitable[bool]]: ) -> Callable[[MagicMock], Awaitable[bool]]:
"""Fixture to set up the integration.""" """Fixture to set up the integration."""
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
async def run() -> bool: async def run(client: MagicMock) -> bool:
with patch("homeassistant.components.home_connect.PLATFORMS", platforms): 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) result = await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
return result return result
@ -135,125 +145,205 @@ async def mock_integration_setup(
return run return run
@pytest.fixture(name="get_appliances") def _get_set_program_side_effect(
def mock_get_appliances() -> Generator[MagicMock]: event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey
"""Mock ConfigEntryAuth parent (HomeAssistantAPI) method.""" ):
with patch( """Set program side effect."""
"homeassistant.components.home_connect.api.ConfigEntryAuth.get_appliances",
) as mock: async def set_program_side_effect(ha_id: str, *_, **kwargs) -> None:
yield mock 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 _get_set_key_value_side_effect(
def mock_appliance(request: pytest.FixtureRequest) -> MagicMock: 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.""" """Fixture to mock Appliance."""
app = "Washer" app = "Washer"
if hasattr(request, "param") and request.param: if hasattr(request, "param") and request.param:
app = request.param app = request.param
for appliance in MOCK_APPLIANCES.homeappliances:
mock = MagicMock( if appliance.type == app:
autospec=HomeConnectAppliance, return appliance.ha_id
**MOCK_APPLIANCES_PROPERTIES.get(app), raise ValueError(f"Appliance {app} not found")
)
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())

View File

@ -2,6 +2,11 @@
"Dishwasher": { "Dishwasher": {
"data": { "data": {
"settings": [ "settings": [
{
"key": "BSH.Common.Setting.ChildLock",
"value": false,
"type": "Boolean"
},
{ {
"key": "BSH.Common.Setting.AmbientLightEnabled", "key": "BSH.Common.Setting.AmbientLightEnabled",
"value": true, "value": true,
@ -26,7 +31,13 @@
{ {
"key": "BSH.Common.Setting.PowerState", "key": "BSH.Common.Setting.PowerState",
"value": "BSH.Common.EnumType.PowerState.On", "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", "key": "BSH.Common.Setting.ChildLock",
@ -92,6 +103,11 @@
"key": "BSH.Common.Setting.PowerState", "key": "BSH.Common.Setting.PowerState",
"value": "BSH.Common.EnumType.PowerState.On", "value": "BSH.Common.EnumType.PowerState.On",
"type": "BSH.Common.EnumType.PowerState" "type": "BSH.Common.EnumType.PowerState"
},
{
"key": "BSH.Common.Setting.AlarmClock",
"value": 0,
"type": "Integer"
} }
] ]
} }
@ -154,6 +170,12 @@
"max": 100, "max": 100,
"access": "readWrite" "access": "readWrite"
} }
},
{
"key": "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator",
"value": 8,
"unit": "°C",
"type": "Double"
} }
] ]
} }

View File

@ -2,255 +2,209 @@
# name: test_async_get_config_entry_diagnostics # name: test_async_get_config_entry_diagnostics
dict({ dict({
'BOSCH-000000000-000000000000': dict({ 'BOSCH-000000000-000000000000': dict({
'brand': 'BOSCH',
'connected': True, 'connected': True,
'e_number': 'HCS000000/00',
'ha_id': 'BOSCH-000000000-000000000000',
'name': 'DNE',
'programs': list([ 'programs': list([
]), ]),
'settings': dict({
}),
'status': dict({ 'status': dict({
'BSH.Common.Status.DoorState': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'value': 'BSH.Common.EnumType.DoorState.Closed', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
}), 'BSH.Common.Status.RemoteControlActive': True,
'BSH.Common.Status.OperationState': dict({ 'BSH.Common.Status.RemoteControlStartAllowed': True,
'value': 'BSH.Common.EnumType.OperationState.Ready', 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
}),
'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',
}),
}), }),
'type': 'DNE',
'vib': 'HCS000000',
}), }),
'BOSCH-HCS000000-D00000000001': dict({ 'BOSCH-HCS000000-D00000000001': dict({
'brand': 'BOSCH',
'connected': True, 'connected': True,
'e_number': 'HCS000000/01',
'ha_id': 'BOSCH-HCS000000-D00000000001',
'name': 'WasherDryer',
'programs': list([ 'programs': list([
'LaundryCare.WasherDryer.Program.Mix', 'LaundryCare.WasherDryer.Program.Mix',
'LaundryCare.Washer.Option.Temperature', 'LaundryCare.Washer.Option.Temperature',
]), ]),
'settings': dict({
}),
'status': dict({ 'status': dict({
'BSH.Common.Status.DoorState': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'value': 'BSH.Common.EnumType.DoorState.Closed', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
}), 'BSH.Common.Status.RemoteControlActive': True,
'BSH.Common.Status.OperationState': dict({ 'BSH.Common.Status.RemoteControlStartAllowed': True,
'value': 'BSH.Common.EnumType.OperationState.Ready', 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
}),
'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',
}),
}), }),
'type': 'WasherDryer',
'vib': 'HCS000001',
}), }),
'BOSCH-HCS000000-D00000000002': dict({ 'BOSCH-HCS000000-D00000000002': dict({
'brand': 'BOSCH',
'connected': True, 'connected': True,
'e_number': 'HCS000000/02',
'ha_id': 'BOSCH-HCS000000-D00000000002',
'name': 'Refrigerator',
'programs': list([ 'programs': list([
]), ]),
'settings': dict({
}),
'status': dict({ 'status': dict({
'BSH.Common.Status.DoorState': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'value': 'BSH.Common.EnumType.DoorState.Closed', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
}), 'BSH.Common.Status.RemoteControlActive': True,
'BSH.Common.Status.OperationState': dict({ 'BSH.Common.Status.RemoteControlStartAllowed': True,
'value': 'BSH.Common.EnumType.OperationState.Ready', 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
}),
'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',
}),
}), }),
'type': 'Refrigerator',
'vib': 'HCS000002',
}), }),
'BOSCH-HCS000000-D00000000003': dict({ 'BOSCH-HCS000000-D00000000003': dict({
'brand': 'BOSCH',
'connected': True, 'connected': True,
'e_number': 'HCS000000/03',
'ha_id': 'BOSCH-HCS000000-D00000000003',
'name': 'Freezer',
'programs': list([ 'programs': list([
]), ]),
'settings': dict({
}),
'status': dict({ 'status': dict({
'BSH.Common.Status.DoorState': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'value': 'BSH.Common.EnumType.DoorState.Closed', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
}), 'BSH.Common.Status.RemoteControlActive': True,
'BSH.Common.Status.OperationState': dict({ 'BSH.Common.Status.RemoteControlStartAllowed': True,
'value': 'BSH.Common.EnumType.OperationState.Ready', 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
}),
'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',
}),
}), }),
'type': 'Freezer',
'vib': 'HCS000003',
}), }),
'BOSCH-HCS000000-D00000000004': dict({ 'BOSCH-HCS000000-D00000000004': dict({
'brand': 'BOSCH',
'connected': True, 'connected': True,
'e_number': 'HCS000000/04',
'ha_id': 'BOSCH-HCS000000-D00000000004',
'name': 'Hood',
'programs': list([ 'programs': list([
]), ]),
'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({ 'status': dict({
'BSH.Common.Setting.AmbientLightBrightness': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'type': 'Double', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
'unit': '%', 'BSH.Common.Status.RemoteControlActive': True,
'value': 70, 'BSH.Common.Status.RemoteControlStartAllowed': True,
}), 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
'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',
}),
}), }),
'type': 'Hood',
'vib': 'HCS000004',
}), }),
'BOSCH-HCS000000-D00000000005': dict({ 'BOSCH-HCS000000-D00000000005': dict({
'brand': 'BOSCH',
'connected': True, 'connected': True,
'e_number': 'HCS000000/05',
'ha_id': 'BOSCH-HCS000000-D00000000005',
'name': 'Hob',
'programs': list([ 'programs': list([
]), ]),
'settings': dict({
}),
'status': dict({ 'status': dict({
'BSH.Common.Status.DoorState': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'value': 'BSH.Common.EnumType.DoorState.Closed', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
}), 'BSH.Common.Status.RemoteControlActive': True,
'BSH.Common.Status.OperationState': dict({ 'BSH.Common.Status.RemoteControlStartAllowed': True,
'value': 'BSH.Common.EnumType.OperationState.Ready', 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
}),
'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',
}),
}), }),
'type': 'Hob',
'vib': 'HCS000005',
}), }),
'BOSCH-HCS000000-D00000000006': dict({ 'BOSCH-HCS000000-D00000000006': dict({
'brand': 'BOSCH',
'connected': True, 'connected': True,
'e_number': 'HCS000000/06',
'ha_id': 'BOSCH-HCS000000-D00000000006',
'name': 'CookProcessor',
'programs': list([ 'programs': list([
]), ]),
'settings': dict({
}),
'status': dict({ 'status': dict({
'BSH.Common.Status.DoorState': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'value': 'BSH.Common.EnumType.DoorState.Closed', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
}), 'BSH.Common.Status.RemoteControlActive': True,
'BSH.Common.Status.OperationState': dict({ 'BSH.Common.Status.RemoteControlStartAllowed': True,
'value': 'BSH.Common.EnumType.OperationState.Ready', 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
}),
'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',
}),
}), }),
'type': 'CookProcessor',
'vib': 'HCS000006',
}), }),
'BOSCH-HCS01OVN1-43E0065FE245': dict({ 'BOSCH-HCS01OVN1-43E0065FE245': dict({
'brand': 'BOSCH',
'connected': True, 'connected': True,
'e_number': 'HCS01OVN1/03',
'ha_id': 'BOSCH-HCS01OVN1-43E0065FE245',
'name': 'Oven',
'programs': list([ 'programs': list([
'Cooking.Oven.Program.HeatingMode.HotAir', 'Cooking.Oven.Program.HeatingMode.HotAir',
'Cooking.Oven.Program.HeatingMode.TopBottomHeating', 'Cooking.Oven.Program.HeatingMode.TopBottomHeating',
'Cooking.Oven.Program.HeatingMode.PizzaSetting', 'Cooking.Oven.Program.HeatingMode.PizzaSetting',
]), ]),
'settings': dict({
'BSH.Common.Setting.AlarmClock': 0,
'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On',
}),
'status': dict({ 'status': dict({
'BSH.Common.Root.ActiveProgram': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'value': 'Cooking.Oven.Program.HeatingMode.HotAir', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
}), 'BSH.Common.Status.RemoteControlActive': True,
'BSH.Common.Setting.PowerState': dict({ 'BSH.Common.Status.RemoteControlStartAllowed': True,
'type': 'BSH.Common.EnumType.PowerState', 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
'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',
}),
}), }),
'type': 'Oven',
'vib': 'HCS01OVN1',
}), }),
'BOSCH-HCS04DYR1-831694AE3C5A': dict({ 'BOSCH-HCS04DYR1-831694AE3C5A': dict({
'brand': 'BOSCH',
'connected': True, 'connected': True,
'e_number': 'HCS04DYR1/03',
'ha_id': 'BOSCH-HCS04DYR1-831694AE3C5A',
'name': 'Dryer',
'programs': list([ 'programs': list([
'LaundryCare.Dryer.Program.Cotton', 'LaundryCare.Dryer.Program.Cotton',
'LaundryCare.Dryer.Program.Synthetic', 'LaundryCare.Dryer.Program.Synthetic',
'LaundryCare.Dryer.Program.Mix', 'LaundryCare.Dryer.Program.Mix',
]), ]),
'settings': dict({
}),
'status': dict({ 'status': dict({
'BSH.Common.Status.DoorState': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'value': 'BSH.Common.EnumType.DoorState.Closed', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
}), 'BSH.Common.Status.RemoteControlActive': True,
'BSH.Common.Status.OperationState': dict({ 'BSH.Common.Status.RemoteControlStartAllowed': True,
'value': 'BSH.Common.EnumType.OperationState.Ready', 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
}),
'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',
}),
}), }),
'type': 'Dryer',
'vib': 'HCS04DYR1',
}), }),
'BOSCH-HCS06COM1-D70390681C2C': dict({ 'BOSCH-HCS06COM1-D70390681C2C': dict({
'brand': 'BOSCH',
'connected': True, 'connected': True,
'e_number': 'HCS06COM1/03',
'ha_id': 'BOSCH-HCS06COM1-D70390681C2C',
'name': 'CoffeeMaker',
'programs': list([ 'programs': list([
'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso', 'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso',
'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato', 'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato',
@ -259,26 +213,24 @@
'ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato', 'ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato',
'ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte', 'ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte',
]), ]),
'settings': dict({
}),
'status': dict({ 'status': dict({
'BSH.Common.Status.DoorState': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'value': 'BSH.Common.EnumType.DoorState.Closed', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
}), 'BSH.Common.Status.RemoteControlActive': True,
'BSH.Common.Status.OperationState': dict({ 'BSH.Common.Status.RemoteControlStartAllowed': True,
'value': 'BSH.Common.EnumType.OperationState.Ready', 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
}),
'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',
}),
}), }),
'type': 'CoffeeMaker',
'vib': 'HCS06COM1',
}), }),
'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({ 'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({
'brand': 'SIEMENS',
'connected': True, 'connected': True,
'e_number': 'HCS02DWH1/03',
'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1',
'name': 'Dishwasher',
'programs': list([ 'programs': list([
'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto1',
'Dishcare.Dishwasher.Program.Auto2', 'Dishcare.Dishwasher.Program.Auto2',
@ -286,51 +238,30 @@
'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Eco50',
'Dishcare.Dishwasher.Program.Quick45', 'Dishcare.Dishwasher.Program.Quick45',
]), ]),
'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({ 'status': dict({
'BSH.Common.Setting.AmbientLightBrightness': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'type': 'Double', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
'unit': '%', 'BSH.Common.Status.RemoteControlActive': True,
'value': 70, 'BSH.Common.Status.RemoteControlStartAllowed': True,
}), 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
'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',
}),
}), }),
'type': 'Dishwasher',
'vib': 'HCS02DWH1',
}), }),
'SIEMENS-HCS03WCH1-7BC6383CF794': dict({ 'SIEMENS-HCS03WCH1-7BC6383CF794': dict({
'brand': 'SIEMENS',
'connected': True, 'connected': True,
'e_number': 'HCS03WCH1/03',
'ha_id': 'SIEMENS-HCS03WCH1-7BC6383CF794',
'name': 'Washer',
'programs': list([ 'programs': list([
'LaundryCare.Washer.Program.Cotton', 'LaundryCare.Washer.Program.Cotton',
'LaundryCare.Washer.Program.EasyCare', 'LaundryCare.Washer.Program.EasyCare',
@ -338,97 +269,55 @@
'LaundryCare.Washer.Program.DelicatesSilk', 'LaundryCare.Washer.Program.DelicatesSilk',
'LaundryCare.Washer.Program.Wool', 'LaundryCare.Washer.Program.Wool',
]), ]),
'settings': dict({
'BSH.Common.Setting.ChildLock': False,
'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On',
}),
'status': dict({ 'status': dict({
'BSH.Common.Root.ActiveProgram': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'value': 'BSH.Common.Root.ActiveProgram', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
}), 'BSH.Common.Status.RemoteControlActive': True,
'BSH.Common.Setting.ChildLock': dict({ 'BSH.Common.Status.RemoteControlStartAllowed': True,
'type': 'Boolean', 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
'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',
}),
}), }),
'type': 'Washer',
'vib': 'HCS03WCH1',
}), }),
'SIEMENS-HCS05FRF1-304F4F9E541D': dict({ 'SIEMENS-HCS05FRF1-304F4F9E541D': dict({
'brand': 'SIEMENS',
'connected': True, 'connected': True,
'e_number': 'HCS05FRF1/03',
'ha_id': 'SIEMENS-HCS05FRF1-304F4F9E541D',
'name': 'FridgeFreezer',
'programs': list([ 'programs': list([
]), ]),
'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({ 'status': dict({
'BSH.Common.Status.DoorState': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'value': 'BSH.Common.EnumType.DoorState.Closed', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
}), 'BSH.Common.Status.RemoteControlActive': True,
'BSH.Common.Status.OperationState': dict({ 'BSH.Common.Status.RemoteControlStartAllowed': True,
'value': 'BSH.Common.EnumType.OperationState.Ready', 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
}),
'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,
}),
}), }),
'type': 'FridgeFreezer',
'vib': 'HCS05FRF1',
}), }),
}) })
# --- # ---
# name: test_async_get_device_diagnostics # name: test_async_get_device_diagnostics
dict({ dict({
'brand': 'SIEMENS',
'connected': True, 'connected': True,
'e_number': 'HCS02DWH1/03',
'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1',
'name': 'Dishwasher',
'programs': list([ 'programs': list([
'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto1',
'Dishcare.Dishwasher.Program.Auto2', 'Dishcare.Dishwasher.Program.Auto2',
@ -436,47 +325,22 @@
'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Eco50',
'Dishcare.Dishwasher.Program.Quick45', 'Dishcare.Dishwasher.Program.Quick45',
]), ]),
'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({ 'status': dict({
'BSH.Common.Setting.AmbientLightBrightness': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
'type': 'Double', 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
'unit': '%', 'BSH.Common.Status.RemoteControlActive': True,
'value': 70, 'BSH.Common.Status.RemoteControlStartAllowed': True,
}), 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
'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',
}),
}), }),
'type': 'Dishwasher',
'vib': 'HCS02DWH1',
}) })
# --- # ---

View File

@ -1,32 +1,29 @@
"""Tests for home_connect binary_sensor entities.""" """Tests for home_connect binary_sensor entities."""
from collections.abc import Awaitable, Callable 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 import pytest
from homeassistant.components import automation, script from homeassistant.components import automation, script
from homeassistant.components.automation import automations_with_entity from homeassistant.components.automation import automations_with_entity
from homeassistant.components.home_connect.const import ( from homeassistant.components.home_connect.const import (
BSH_DOOR_STATE,
BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_CLOSED,
BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_LOCKED,
BSH_DOOR_STATE_OPEN, BSH_DOOR_STATE_OPEN,
DOMAIN, DOMAIN,
REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_CLOSED,
REFRIGERATION_STATUS_DOOR_OPEN, REFRIGERATION_STATUS_DOOR_OPEN,
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
) )
from homeassistant.components.script import scripts_with_entity from homeassistant.components.script import scripts_with_entity
from homeassistant.config_entries import ConfigEntryState 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.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.issue_registry as ir
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_json_object_fixture from tests.common import MockConfigEntry
@pytest.fixture @pytest.fixture
@ -35,123 +32,166 @@ def platforms() -> list[str]:
return [Platform.BINARY_SENSOR] return [Platform.BINARY_SENSOR]
@pytest.mark.usefixtures("bypass_throttle")
async def test_binary_sensors( async def test_binary_sensors(
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
appliance: Mock,
) -> None: ) -> None:
"""Test binary sensor entities.""" """Test binary sensor entities."""
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
@pytest.mark.parametrize( @pytest.mark.parametrize(
("state", "expected"), ("value", "expected"),
[ [
(BSH_DOOR_STATE_CLOSED, "off"), (BSH_DOOR_STATE_CLOSED, "off"),
(BSH_DOOR_STATE_LOCKED, "off"), (BSH_DOOR_STATE_LOCKED, "off"),
(BSH_DOOR_STATE_OPEN, "on"), (BSH_DOOR_STATE_OPEN, "on"),
("", "unavailable"), ("", STATE_UNKNOWN),
], ],
) )
@pytest.mark.usefixtures("bypass_throttle")
async def test_binary_sensors_door_states( async def test_binary_sensors_door_states(
appliance_ha_id: str,
expected: str, expected: str,
state: str, value: str,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
appliance: Mock,
) -> None: ) -> None:
"""Tests for Appliance door states.""" """Tests for Appliance door states."""
entity_id = "binary_sensor.washer_door" entity_id = "binary_sensor.washer_door"
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
appliance.status.update({BSH_DOOR_STATE: {"value": state}}) assert await integration_setup(client)
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED 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() await hass.async_block_till_done()
assert hass.states.is_state(entity_id, expected) assert hass.states.is_state(entity_id, expected)
@pytest.mark.parametrize( @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", "binary_sensor.fridgefreezer_refrigerator_door",
REFRIGERATION_STATUS_DOOR_REFRIGERATOR, EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR,
REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_CLOSED,
STATE_OFF, STATE_OFF,
"FridgeFreezer", "FridgeFreezer",
), ),
( (
"binary_sensor.fridgefreezer_refrigerator_door", "binary_sensor.fridgefreezer_refrigerator_door",
REFRIGERATION_STATUS_DOOR_REFRIGERATOR, EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR,
REFRIGERATION_STATUS_DOOR_OPEN, REFRIGERATION_STATUS_DOOR_OPEN,
STATE_ON, STATE_ON,
"FridgeFreezer", "FridgeFreezer",
), ),
( (
"binary_sensor.fridgefreezer_refrigerator_door", "binary_sensor.fridgefreezer_refrigerator_door",
REFRIGERATION_STATUS_DOOR_REFRIGERATOR, EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR,
"", "",
STATE_UNAVAILABLE, STATE_UNKNOWN,
"FridgeFreezer", "FridgeFreezer",
), ),
], ],
indirect=["appliance"], indirect=["appliance_ha_id"],
) )
@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors_functionality(
async def test_bianry_sensors_fridge_door_states(
entity_id: str, entity_id: str,
status_key: str, event_key: EventKey,
event_value_update: str, event_value_update: str,
appliance: Mock, appliance_ha_id: str,
expected: str, expected: str,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
) -> None: ) -> None:
"""Tests for Home Connect Fridge appliance door states.""" """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 config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update({status_key: {"value": event_value_update}}) await client.add_events(
await async_update_entity(hass, entity_id) [
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() await hass.async_block_till_done()
assert hass.states.is_state(entity_id, expected) assert hass.states.is_state(entity_id, expected)
@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.usefixtures("bypass_throttle")
async def test_create_issue( async def test_create_issue(
hass: HomeAssistant, hass: HomeAssistant,
appliance: Mock,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
issue_registry: ir.IssueRegistry, issue_registry: ir.IssueRegistry,
) -> None: ) -> None:
"""Test we create an issue when an automation or script is using a deprecated entity.""" """Test we create an issue when an automation or script is using a deprecated entity."""
entity_id = "binary_sensor.washer_door" entity_id = "binary_sensor.washer_door"
get_appliances.return_value = [appliance]
issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" issue_id = f"deprecated_binary_common_door_sensor_{entity_id}"
assert await async_setup_component( assert await async_setup_component(
@ -189,8 +229,7 @@ async def test_create_issue(
) )
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
appliance.status.update({BSH_DOOR_STATE: {"value": BSH_DOOR_STATE_OPEN}}) assert await integration_setup(client)
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
assert automations_with_entity(hass, entity_id)[0] == "automation.test" assert automations_with_entity(hass, entity_id)[0] == "automation.test"

View File

@ -3,6 +3,7 @@
from http import HTTPStatus from http import HTTPStatus
from unittest.mock import patch from unittest.mock import patch
from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
import pytest import pytest
from homeassistant import config_entries, setup from homeassistant import config_entries, setup
@ -10,11 +11,7 @@ from homeassistant.components.application_credentials import (
ClientCredential, ClientCredential,
async_import_client_credential, async_import_client_credential,
) )
from homeassistant.components.home_connect.const import ( from homeassistant.components.home_connect.const import DOMAIN
DOMAIN,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow

View File

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

View File

@ -1,11 +1,9 @@
"""Test diagnostics for Home Connect.""" """Test diagnostics for Home Connect."""
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from unittest.mock import MagicMock, Mock from unittest.mock import MagicMock
from homeconnect.api import HomeConnectError from syrupy.assertion import SnapshotAssertion
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.home_connect.const import DOMAIN
from homeassistant.components.home_connect.diagnostics import ( from homeassistant.components.home_connect.diagnostics import (
@ -16,43 +14,37 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from .conftest import get_all_appliances
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.mark.usefixtures("bypass_throttle")
async def test_async_get_config_entry_diagnostics( async def test_async_get_config_entry_diagnostics(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test config entry diagnostics.""" """Test config entry diagnostics."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot
@pytest.mark.usefixtures("bypass_throttle")
async def test_async_get_device_diagnostics( async def test_async_get_device_diagnostics(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
device_registry: dr.DeviceRegistry, device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test device config entry diagnostics.""" """Test device config entry diagnostics."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
device = device_registry.async_get_or_create( 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 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

View File

@ -2,27 +2,18 @@
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any 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 import pytest
from requests import HTTPError
import requests_mock import requests_mock
import respx
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.home_connect import ( from homeassistant.components.home_connect.const import DOMAIN
SCAN_INTERVAL, from homeassistant.components.home_connect.utils import bsh_key_to_translation_key
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.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
@ -39,7 +30,6 @@ from .conftest import (
FAKE_ACCESS_TOKEN, FAKE_ACCESS_TOKEN,
FAKE_REFRESH_TOKEN, FAKE_REFRESH_TOKEN,
SERVER_ACCESS_TOKEN, SERVER_ACCESS_TOKEN,
get_all_appliances,
) )
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -126,28 +116,26 @@ SERVICE_PROGRAM_CALL_PARAMS = [
] ]
SERVICE_APPLIANCE_METHOD_MAPPING = { SERVICE_APPLIANCE_METHOD_MAPPING = {
"set_option_active": "set_options_active_program", "set_option_active": "set_active_program_option",
"set_option_selected": "set_options_selected_program", "set_option_selected": "set_selected_program_option",
"change_setting": "set_setting", "change_setting": "set_setting",
"pause_program": "execute_command", "pause_program": "put_command",
"resume_program": "execute_command", "resume_program": "put_command",
"select_program": "select_program", "select_program": "set_selected_program",
"start_program": "start_program", "start_program": "start_program",
} }
@pytest.mark.usefixtures("bypass_throttle") async def test_entry_setup(
async def test_api_setup(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
) -> None: ) -> None:
"""Test setup and unload.""" """Test setup and unload."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(config_entry.entry_id) 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 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( async def test_exception_handling(
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client_with_exception: MagicMock,
problematic_appliance: Mock,
) -> None: ) -> None:
"""Test exception handling.""" """Test exception handling."""
get_appliances.return_value = [problematic_appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED 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 config_entry.state == ConfigEntryState.LOADED
@pytest.mark.parametrize("token_expiration_time", [12345]) @pytest.mark.parametrize("token_expiration_time", [12345])
@pytest.mark.usefixtures("bypass_throttle") @respx.mock
async def test_token_refresh_success( 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, config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
requests_mock: requests_mock.Mocker, requests_mock: requests_mock.Mocker,
setup_credentials: None, setup_credentials: None,
client: MagicMock,
) -> None: ) -> None:
"""Test where token is expired and the refresh attempt succeeds.""" """Test where token is expired and the refresh attempt succeeds."""
assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN
requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN) requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN)
requests_mock.get("/api/homeappliances", json={"data": {"homeappliances": []}})
aioclient_mock.post( aioclient_mock.post(
OAUTH2_TOKEN, OAUTH2_TOKEN,
json=SERVER_ACCESS_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 assert config_entry.state == ConfigEntryState.LOADED
# Verify token request # Verify token request
@ -240,45 +216,43 @@ async def test_token_refresh_success(
) )
@pytest.mark.usefixtures("bypass_throttle") async def test_client_error(
async def test_http_error(
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client_with_exception: MagicMock,
) -> None: ) -> None:
"""Test HTTP errors during setup integration.""" """Test client errors during setup integration."""
get_appliances.side_effect = HTTPError(response=MagicMock()) 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 config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert not await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.SETUP_RETRY
assert get_appliances.call_count == 1 assert client_with_exception.get_home_appliances.call_count == 1
@pytest.mark.parametrize( @pytest.mark.parametrize(
"service_call", "service_call",
SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
) )
@pytest.mark.usefixtures("bypass_throttle")
async def test_services( async def test_services(
service_call: list[dict[str, Any]], service_call: dict[str, Any],
hass: HomeAssistant, hass: HomeAssistant,
device_registry: dr.DeviceRegistry, device_registry: dr.DeviceRegistry,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
appliance: Mock, appliance_ha_id: str,
) -> None: ) -> None:
"""Create and test services.""" """Create and test services."""
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
device_entry = device_registry.async_get_or_create( device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, appliance.haId)}, identifiers={(DOMAIN, appliance_ha_id)},
) )
service_name = service_call["service"] service_name = service_call["service"]
@ -286,8 +260,7 @@ async def test_services(
await hass.services.async_call(**service_call) await hass.services.async_call(**service_call)
await hass.async_block_till_done() await hass.async_block_till_done()
assert ( assert (
getattr(appliance, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1
== 1
) )
@ -295,26 +268,24 @@ async def test_services(
"service_call", "service_call",
SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
) )
@pytest.mark.usefixtures("bypass_throttle")
async def test_services_exception( async def test_services_exception(
service_call: list[dict[str, Any]], service_call: dict[str, Any],
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client_with_exception: MagicMock,
problematic_appliance: Mock, appliance_ha_id: str,
device_registry: dr.DeviceRegistry, device_registry: dr.DeviceRegistry,
) -> None: ) -> None:
"""Raise a HomeAssistantError when there is an API error.""" """Raise a HomeAssistantError when there is an API error."""
get_appliances.return_value = [problematic_appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED 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 config_entry.state == ConfigEntryState.LOADED
device_entry = device_registry.async_get_or_create( device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, 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 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) await hass.services.async_call(**service_call)
@pytest.mark.usefixtures("bypass_throttle")
async def test_services_appliance_not_found( async def test_services_appliance_not_found(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
appliance: Mock, device_registry: dr.DeviceRegistry,
) -> None: ) -> None:
"""Raise a ServiceValidationError when device id does not match.""" """Raise a ServiceValidationError when device id does not match."""
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
service_call = SERVICE_KV_CALL_PARAMS[0] service_call = SERVICE_KV_CALL_PARAMS[0]
service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" 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"): with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"):
await hass.services.async_call(**service_call) await hass.services.async_call(**service_call)
@ -351,7 +344,7 @@ async def test_entity_migration(
device_registry: dr.DeviceRegistry, device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
config_entry_v1_1: MockConfigEntry, config_entry_v1_1: MockConfigEntry,
appliance: Mock, appliance_ha_id: str,
platforms: list[Platform], platforms: list[Platform],
) -> None: ) -> None:
"""Test entity migration.""" """Test entity migration."""
@ -360,34 +353,39 @@ async def test_entity_migration(
device_entry = device_registry.async_get_or_create( device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry_v1_1.entry_id, config_entry_id=config_entry_v1_1.entry_id,
identifiers={(DOMAIN, appliance.haId)}, identifiers={(DOMAIN, appliance_ha_id)},
) )
test_entities = [ test_entities = [
( (
SENSOR_DOMAIN, SENSOR_DOMAIN,
"Operation State", "Operation State",
BSH_OPERATION_STATE, StatusKey.BSH_COMMON_OPERATION_STATE,
), ),
( (
SWITCH_DOMAIN, SWITCH_DOMAIN,
"ChildLock", "ChildLock",
BSH_CHILD_LOCK_STATE, SettingKey.BSH_COMMON_CHILD_LOCK,
), ),
( (
SWITCH_DOMAIN, SWITCH_DOMAIN,
"Power", "Power",
BSH_POWER_STATE, SettingKey.BSH_COMMON_POWER_STATE,
), ),
( (
BINARY_SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN,
"Remote Start", "Remote Start",
BSH_REMOTE_START_ALLOWANCE_STATE, StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED,
), ),
( (
LIGHT_DOMAIN, LIGHT_DOMAIN,
"Light", "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( entity_registry.async_get_or_create(
domain, domain,
DOMAIN, DOMAIN,
f"{appliance.haId}-{old_unique_id_suffix}", f"{appliance_ha_id}-{old_unique_id_suffix}",
device_id=device_entry.id, device_id=device_entry.id,
config_entry=config_entry_v1_1, config_entry=config_entry_v1_1,
) )
@ -406,7 +404,7 @@ async def test_entity_migration(
for domain, _, expected_unique_id_suffix in test_entities: for domain, _, expected_unique_id_suffix in test_entities:
assert entity_registry.async_get_entity_id( 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 assert config_entry_v1_1.minor_version == 2

View File

@ -1,20 +1,24 @@
"""Tests for home_connect light entities.""" """Tests for home_connect light entities."""
from collections.abc import Awaitable, Callable, Generator from collections.abc import Awaitable, Callable
from unittest.mock import MagicMock, Mock 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 import pytest
from homeassistant.components.home_connect.const import ( from homeassistant.components.home_connect.const import (
BSH_AMBIENT_LIGHT_BRIGHTNESS, BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
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,
) )
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
@ -23,26 +27,15 @@ from homeassistant.const import (
SERVICE_TURN_ON, SERVICE_TURN_ON,
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
STATE_UNKNOWN,
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from .conftest import get_all_appliances from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, load_json_object_fixture
TEST_HC_APP = "Hood" 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 @pytest.fixture
def platforms() -> list[str]: def platforms() -> list[str]:
@ -51,29 +44,31 @@ def platforms() -> list[str]:
async def test_light( async def test_light(
bypass_throttle: Generator[None],
hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: Mock, client: MagicMock,
) -> None: ) -> None:
"""Test switch entities.""" """Test switch entities."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
@pytest.mark.parametrize( @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", "light.hood_functional_light",
{ {
COOKING_LIGHTING: { SettingKey.COOKING_COMMON_LIGHTING: True,
"value": True,
},
}, },
SERVICE_TURN_ON, SERVICE_TURN_ON,
{}, {},
@ -83,58 +78,18 @@ async def test_light(
( (
"light.hood_functional_light", "light.hood_functional_light",
{ {
COOKING_LIGHTING: { SettingKey.COOKING_COMMON_LIGHTING: True,
"value": True, SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80,
},
COOKING_LIGHTING_BRIGHTNESS: {"value": 70},
}, },
SERVICE_TURN_ON, SERVICE_TURN_ON,
{"brightness": 200}, {"brightness": 199},
STATE_ON, STATE_ON,
"Hood", "Hood",
), ),
( (
"light.hood_functional_light", "light.hood_functional_light",
{ {
COOKING_LIGHTING: {"value": False}, SettingKey.COOKING_COMMON_LIGHTING: 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},
}, },
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
{}, {},
@ -144,8 +99,28 @@ async def test_light(
( (
"light.hood_ambient_light", "light.hood_ambient_light",
{ {
BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, 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, SERVICE_TURN_ON,
{}, {},
@ -155,15 +130,28 @@ async def test_light(
( (
"light.hood_ambient_light", "light.hood_ambient_light",
{ {
BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
BSH_AMBIENT_LIGHT_COLOR: { SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
"value": "", SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00",
},
BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {},
}, },
SERVICE_TURN_ON, 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, STATE_ON,
"Hood", "Hood",
@ -171,10 +159,7 @@ async def test_light(
( (
"light.fridgefreezer_external_light", "light.fridgefreezer_external_light",
{ {
REFRIGERATION_EXTERNAL_LIGHT_POWER: { SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER: True,
"value": True,
},
REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS: {"value": 75},
}, },
SERVICE_TURN_ON, SERVICE_TURN_ON,
{}, {},
@ -182,167 +167,268 @@ async def test_light(
"FridgeFreezer", "FridgeFreezer",
), ),
], ],
indirect=["appliance"], indirect=["appliance_ha_id"],
) )
async def test_light_functionality( async def test_light_functionality(
entity_id: str, entity_id: str,
status: dict, set_settings_args: dict[SettingKey, Any],
service: str, service: str,
service_data: dict, exprected_attributes: dict[str, Any],
state: str, state: str,
appliance: Mock, appliance_ha_id: str,
bypass_throttle: Generator[None],
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
) -> None: ) -> None:
"""Test light functionality.""" """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 config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update(status) service_data = exprected_attributes.copy()
service_data["entity_id"] = entity_id service_data["entity_id"] = entity_id
await hass.services.async_call( await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_DOMAIN,
service, service,
service_data, {key: value for key, value in service_data.items() if value is not None},
blocking=True,
) )
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( @pytest.mark.parametrize(
( (
"entity_id", "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",
"service_data", "service_data",
"mock_attr",
"attr_side_effect", "attr_side_effect",
"problematic_appliance",
"exception_match", "exception_match",
), ),
[ [
( (
"light.hood_functional_light", "light.hood_functional_light",
{ {
COOKING_LIGHTING: { SettingKey.COOKING_COMMON_LIGHTING: True,
"value": False,
},
}, },
SERVICE_TURN_ON, SERVICE_TURN_ON,
{}, {},
"set_setting",
[HomeConnectError, HomeConnectError], [HomeConnectError, HomeConnectError],
"Hood",
r"Error.*turn.*on.*", r"Error.*turn.*on.*",
), ),
( (
"light.hood_functional_light", "light.hood_functional_light",
{ {
COOKING_LIGHTING: { SettingKey.COOKING_COMMON_LIGHTING: True,
"value": True, SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70,
},
COOKING_LIGHTING_BRIGHTNESS: {"value": 70},
}, },
SERVICE_TURN_ON, SERVICE_TURN_ON,
{"brightness": 200}, {"brightness": 200},
"set_setting",
[HomeConnectError, HomeConnectError], [HomeConnectError, HomeConnectError],
"Hood",
r"Error.*turn.*on.*", r"Error.*turn.*on.*",
), ),
( (
"light.hood_functional_light", "light.hood_functional_light",
{ {
COOKING_LIGHTING: {"value": False}, SettingKey.COOKING_COMMON_LIGHTING: False,
}, },
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
{}, {},
"set_setting",
[HomeConnectError, HomeConnectError], [HomeConnectError, HomeConnectError],
"Hood",
r"Error.*turn.*off.*", r"Error.*turn.*off.*",
), ),
( (
"light.hood_ambient_light", "light.hood_ambient_light",
{ {
BSH_AMBIENT_LIGHT_ENABLED: { SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
"value": True, SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70,
},
BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70},
}, },
SERVICE_TURN_ON, SERVICE_TURN_ON,
{}, {},
"set_setting",
[HomeConnectError, HomeConnectError], [HomeConnectError, HomeConnectError],
"Hood",
r"Error.*turn.*on.*", r"Error.*turn.*on.*",
), ),
( (
"light.hood_ambient_light", "light.hood_ambient_light",
{ {
BSH_AMBIENT_LIGHT_ENABLED: { SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
"value": True, SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70,
},
BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70},
}, },
SERVICE_TURN_ON, SERVICE_TURN_ON,
{"brightness": 200}, {"brightness": 200},
"set_setting",
[HomeConnectError, None, HomeConnectError], [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.*", r"Error.*set.*color.*",
), ),
], ],
indirect=["problematic_appliance"],
) )
async def test_switch_exception_handling( async def test_light_exception_handling(
entity_id: str, entity_id: str,
status: dict, setting: dict[SettingKey, dict[str, Any]],
service: str, service: str,
service_data: dict, service_data: dict,
mock_attr: str, attr_side_effect: list[type[HomeConnectError] | None],
attr_side_effect: list,
problematic_appliance: Mock,
exception_match: str, exception_match: str,
bypass_throttle: Generator[None],
hass: HomeAssistant, hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client_with_exception: MagicMock,
) -> None: ) -> None:
"""Test light exception handling.""" """Test light exception handling."""
problematic_appliance.status.update(SETTINGS_STATUS) client_with_exception.get_settings.side_effect = None
problematic_appliance.set_setting.side_effect = attr_side_effect client_with_exception.get_settings.return_value = ArrayOfSettings(
get_appliances.return_value = [problematic_appliance] [
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 config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
# Assert that an exception is called. # Assert that an exception is called.
with pytest.raises(HomeConnectError): 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 service_data["entity_id"] = entity_id
with pytest.raises(HomeAssistantError, match=exception_match): with pytest.raises(HomeAssistantError, match=exception_match):
await hass.services.async_call( await hass.services.async_call(
LIGHT_DOMAIN, service, service_data, blocking=True 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)

View File

@ -1,22 +1,17 @@
"""Tests for home_connect number entities.""" """Tests for home_connect number entities."""
from collections.abc import Awaitable, Callable, Generator from collections.abc import Awaitable, Callable
import random 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 import pytest
from homeassistant.components.home_connect.const import (
ATTR_CONSTRAINTS,
ATTR_STEPSIZE,
ATTR_UNIT,
ATTR_VALUE,
)
from homeassistant.components.number import ( from homeassistant.components.number import (
ATTR_MAX,
ATTR_MIN,
ATTR_VALUE as SERVICE_ATTR_VALUE, ATTR_VALUE as SERVICE_ATTR_VALUE,
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE, DEFAULT_MIN_VALUE,
DOMAIN as NUMBER_DOMAIN, DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE, SERVICE_SET_VALUE,
@ -26,8 +21,6 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from .conftest import get_all_appliances
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -38,25 +31,24 @@ def platforms() -> list[str]:
async def test_number( async def test_number(
bypass_throttle: Generator[None],
hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: Mock, client: MagicMock,
) -> None: ) -> None:
"""Test number entity.""" """Test number entity."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state is ConfigEntryState.NOT_LOADED 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 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( @pytest.mark.parametrize(
( (
"entity_id", "entity_id",
"setting_key", "setting_key",
"type",
"expected_state",
"min_value", "min_value",
"max_value", "max_value",
"step_size", "step_size",
@ -64,102 +56,132 @@ async def test_number(
), ),
[ [
( (
f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature",
"Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,
"Double",
8,
7, 7,
15, 15,
0.1, 0.1,
"°C", "°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( async def test_number_entity_functionality(
appliance: Mock, appliance_ha_id: str,
entity_id: str, entity_id: str,
setting_key: str, setting_key: SettingKey,
bypass_throttle: Generator[None], type: str,
expected_state: int,
min_value: int, min_value: int,
max_value: int, max_value: int,
step_size: float, step_size: float,
unit_of_measurement: str, unit_of_measurement: str,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
) -> None: ) -> None:
"""Test number entity functionality.""" """Test number entity functionality."""
appliance.get.side_effect = [ client.get_setting.side_effect = None
{ client.get_setting = AsyncMock(
ATTR_CONSTRAINTS: { return_value=GetSetting(
ATTR_MIN: min_value, key=setting_key,
ATTR_MAX: max_value, raw_key=setting_key.value,
ATTR_STEPSIZE: step_size, value="", # This should not change the value
}, unit=unit_of_measurement,
ATTR_UNIT: unit_of_measurement, type=type,
} constraints=SettingConstraints(
] min=min_value,
get_appliances.return_value = [appliance] max=max_value,
current_value = min_value step_size=step_size if isinstance(step_size, int) else None,
appliance.status.update({setting_key: {ATTR_VALUE: current_value}}) ),
)
)
assert config_entry.state is ConfigEntryState.NOT_LOADED 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 config_entry.state is ConfigEntryState.LOADED
assert hass.states.is_state(entity_id, str(current_value)) entity_state = hass.states.get(entity_id)
state = hass.states.get(entity_id) assert entity_state
assert state.attributes["min"] == min_value assert entity_state.state == str(expected_state)
assert state.attributes["max"] == max_value attributes = entity_state.attributes
assert state.attributes["step"] == step_size assert attributes["min"] == min_value
assert state.attributes["unit_of_measurement"] == unit_of_measurement 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( await hass.services.async_call(
NUMBER_DOMAIN, NUMBER_DOMAIN,
SERVICE_SET_VALUE, SERVICE_SET_VALUE,
{ {
ATTR_ENTITY_ID: entity_id, 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( @pytest.mark.parametrize(
("entity_id", "setting_key", "mock_attr"), ("entity_id", "setting_key", "mock_attr"),
[ [
( (
f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature",
"Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,
"set_setting", "set_setting",
), ),
], ],
) )
@pytest.mark.usefixtures("bypass_throttle")
async def test_number_entity_error( async def test_number_entity_error(
problematic_appliance: Mock,
entity_id: str, entity_id: str,
setting_key: str, setting_key: SettingKey,
mock_attr: str, mock_attr: str,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client_with_exception: MagicMock,
) -> None: ) -> None:
"""Test number entity error.""" """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 assert config_entry.state is ConfigEntryState.NOT_LOADED
problematic_appliance.status.update({setting_key: {}}) assert await integration_setup(client_with_exception)
assert await integration_setup()
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
with pytest.raises(HomeConnectError): with pytest.raises(HomeConnectError):
getattr(problematic_appliance, mock_attr)() await getattr(client_with_exception, mock_attr)()
with pytest.raises( with pytest.raises(
HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*"
@ -173,4 +195,4 @@ async def test_number_entity_error(
}, },
blocking=True, blocking=True,
) )
assert getattr(problematic_appliance, mock_attr).call_count == 2 assert getattr(client_with_exception, mock_attr).call_count == 2

View File

@ -1,39 +1,38 @@
"""Tests for home_connect select entities.""" """Tests for home_connect select entities."""
from collections.abc import Awaitable, Callable, Generator from collections.abc import Awaitable, Callable
from unittest.mock import MagicMock, Mock 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 import pytest
from homeassistant.components.home_connect.const import (
BSH_ACTIVE_PROGRAM,
BSH_SELECTED_PROGRAM,
)
from homeassistant.components.select import ( from homeassistant.components.select import (
ATTR_OPTION, ATTR_OPTION,
ATTR_OPTIONS, ATTR_OPTIONS,
DOMAIN as SELECT_DOMAIN, DOMAIN as SELECT_DOMAIN,
) )
from homeassistant.config_entries import ConfigEntryState 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.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from .conftest import get_all_appliances from tests.common import MockConfigEntry
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"
@pytest.fixture @pytest.fixture
@ -43,119 +42,148 @@ def platforms() -> list[str]:
async def test_select( async def test_select(
bypass_throttle: Generator[None],
hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: Mock, client: MagicMock,
) -> None: ) -> None:
"""Test select entity.""" """Test select entity."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state is ConfigEntryState.NOT_LOADED 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 config_entry.state is ConfigEntryState.LOADED
async def test_filter_unknown_programs( async def test_filter_unknown_programs(
bypass_throttle: Generator[None],
hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: Mock, client: MagicMock,
appliance: Mock,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test select that programs that are not part of the official Home Connect API specification are filtered out. """Test select that only known programs are shown."""
client.get_available_programs.side_effect = None
We use two programs to ensure that programs are iterated over a copy of the list, client.get_available_programs.return_value = ArrayOfAvailablePrograms(
and it does not raise problems when removing an element from the original list. [
""" EnumerateAvailableProgram(
appliance.status.update(SETTINGS_STATUS) key=ProgramKey.DISHCARE_DISHWASHER_ECO_50,
appliance.get_programs_available.return_value = [ raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value,
PROGRAM, ),
"NonOfficialProgram", EnumerateAvailableProgram(
"AntotherNonOfficialProgram", key=ProgramKey.UNKNOWN,
raw_key="an unknown program",
),
] ]
get_appliances.return_value = [appliance] )
assert config_entry.state is ConfigEntryState.NOT_LOADED 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 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
assert entity.capabilities.get(ATTR_OPTIONS) == [ assert entity.capabilities
"dishcare_dishwasher_program_eco_50" assert entity.capabilities[ATTR_OPTIONS] == ["dishcare_dishwasher_program_eco_50"]
]
@pytest.mark.parametrize( @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", "Dishwasher",
{BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, "select.dishwasher_selected_program",
"set_selected_program",
ProgramKey.DISHCARE_DISHWASHER_ECO_50,
"dishcare_dishwasher_program_eco_50", "dishcare_dishwasher_program_eco_50",
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
), ),
( (
"select.washer_active_program", "Dishwasher",
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, "select.dishwasher_active_program",
"start_program",
ProgramKey.DISHCARE_DISHWASHER_ECO_50,
"dishcare_dishwasher_program_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, entity_id: str,
status: dict, mock_method: str,
program_key: ProgramKey,
program_to_set: str, program_to_set: str,
bypass_throttle: Generator[None], event_key: EventKey,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
appliance: Mock, client: MagicMock,
get_appliances: MagicMock,
) -> None: ) -> None:
"""Test select functionality.""" """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 config_entry.state is ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
appliance.status.update(status) assert hass.states.is_state(entity_id, "unknown")
await hass.services.async_call( await hass.services.async_call(
SELECT_DOMAIN, SELECT_DOMAIN,
SERVICE_SELECT_OPTION, SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, {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) 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( @pytest.mark.parametrize(
( (
"entity_id", "entity_id",
"status",
"program_to_set", "program_to_set",
"mock_attr", "mock_attr",
"exception_match", "exception_match",
), ),
[ [
( (
"select.washer_selected_program", "select.dishwasher_selected_program",
{BSH_SELECTED_PROGRAM: {"value": PROGRAM}},
"dishcare_dishwasher_program_eco_50", "dishcare_dishwasher_program_eco_50",
"select_program", "set_selected_program",
r"Error.*select.*program.*", r"Error.*select.*program.*",
), ),
( (
"select.washer_active_program", "select.dishwasher_active_program",
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}},
"dishcare_dishwasher_program_eco_50", "dishcare_dishwasher_program_eco_50",
"start_program", "start_program",
r"Error.*start.*program.*", r"Error.*start.*program.*",
@ -164,32 +192,36 @@ async def test_select_functionality(
) )
async def test_select_exception_handling( async def test_select_exception_handling(
entity_id: str, entity_id: str,
status: dict,
program_to_set: str, program_to_set: str,
mock_attr: str, mock_attr: str,
exception_match: str, exception_match: str,
bypass_throttle: Generator[None],
hass: HomeAssistant, hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
setup_credentials: None, setup_credentials: None,
problematic_appliance: Mock, client_with_exception: MagicMock,
get_appliances: MagicMock,
) -> None: ) -> None:
"""Test exception handling.""" """Test exception handling."""
problematic_appliance.get_programs_available.side_effect = None client_with_exception.get_available_programs.side_effect = None
problematic_appliance.get_programs_available.return_value = [PROGRAM] client_with_exception.get_available_programs.return_value = (
get_appliances.return_value = [problematic_appliance] 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 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 config_entry.state is ConfigEntryState.LOADED
# Assert that an exception is called. # Assert that an exception is called.
with pytest.raises(HomeConnectError): 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): with pytest.raises(HomeAssistantError, match=exception_match):
await hass.services.async_call( await hass.services.async_call(
SELECT_DOMAIN, SELECT_DOMAIN,
@ -197,4 +229,4 @@ async def test_select_exception_handling(
{"entity_id": entity_id, "option": program_to_set}, {"entity_id": entity_id, "option": program_to_set},
blocking=True, blocking=True,
) )
assert getattr(problematic_appliance, mock_attr).call_count == 2 assert getattr(client_with_exception, mock_attr).call_count == 2

View File

@ -1,75 +1,77 @@
"""Tests for home_connect sensor entities.""" """Tests for home_connect sensor entities."""
from collections.abc import Awaitable, Callable 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 freezegun.api import FrozenDateTimeFactory
from homeconnect.api import HomeConnectAPI
import pytest import pytest
from homeassistant.components.home_connect.const import ( from homeassistant.components.home_connect.const import (
BSH_DOOR_STATE,
BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_CLOSED,
BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_LOCKED,
BSH_DOOR_STATE_OPEN, BSH_DOOR_STATE_OPEN,
BSH_EVENT_PRESENT_STATE_CONFIRMED, BSH_EVENT_PRESENT_STATE_CONFIRMED,
BSH_EVENT_PRESENT_STATE_OFF, BSH_EVENT_PRESENT_STATE_OFF,
BSH_EVENT_PRESENT_STATE_PRESENT, BSH_EVENT_PRESENT_STATE_PRESENT,
COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
) )
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant 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" TEST_HC_APP = "Dishwasher"
EVENT_PROG_DELAYED_START = { EVENT_PROG_DELAYED_START = {
"BSH.Common.Status.OperationState": { EventType.STATUS: {
"value": "BSH.Common.EnumType.OperationState.DelayedStart" EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.DelayedStart",
},
}
EVENT_PROG_REMAIN_NO_VALUE = {
"BSH.Common.Option.RemainingProgramTime": {},
"BSH.Common.Status.OperationState": {
"value": "BSH.Common.EnumType.OperationState.DelayedStart"
}, },
} }
EVENT_PROG_RUN = { EVENT_PROG_RUN = {
"BSH.Common.Option.RemainingProgramTime": {"value": "0"}, EventType.STATUS: {
"BSH.Common.Option.ProgramProgress": {"value": "60"}, EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run",
"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: 60,
}, },
} }
EVENT_PROG_UPDATE_1 = { EVENT_PROG_UPDATE_1 = {
"BSH.Common.Option.RemainingProgramTime": {"value": "0"}, EventType.EVENT: {
"BSH.Common.Option.ProgramProgress": {"value": "80"}, EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0,
"BSH.Common.Status.OperationState": { EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 80,
"value": "BSH.Common.EnumType.OperationState.Run" },
EventType.STATUS: {
EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run",
}, },
} }
EVENT_PROG_UPDATE_2 = { EVENT_PROG_UPDATE_2 = {
"BSH.Common.Option.RemainingProgramTime": {"value": "20"}, EventType.EVENT: {
"BSH.Common.Option.ProgramProgress": {"value": "99"}, EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 20,
"BSH.Common.Status.OperationState": { EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 99,
"value": "BSH.Common.EnumType.OperationState.Run" },
EventType.STATUS: {
EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run",
}, },
} }
EVENT_PROG_END = { EVENT_PROG_END = {
"BSH.Common.Status.OperationState": { EventType.STATUS: {
"value": "BSH.Common.EnumType.OperationState.Ready" EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Ready",
}, },
} }
@ -80,22 +82,19 @@ def platforms() -> list[str]:
return [Platform.SENSOR] return [Platform.SENSOR]
@pytest.mark.usefixtures("bypass_throttle")
async def test_sensors( async def test_sensors(
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
appliance: Mock,
) -> None: ) -> None:
"""Test sensor entities.""" """Test sensor entities."""
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED 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 = ( PROGRAM_SEQUENCE_EVENTS = (
EVENT_PROG_DELAYED_START, EVENT_PROG_DELAYED_START,
EVENT_PROG_RUN, 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( @pytest.mark.parametrize(
("states", "event_run"), ("states", "event_run"),
list( list(
@ -141,17 +140,16 @@ ENTITY_ID_STATES = {
) )
), ),
) )
@pytest.mark.usefixtures("bypass_throttle")
async def test_event_sensors( async def test_event_sensors(
appliance: Mock, client: MagicMock,
appliance_ha_id: str,
states: tuple, states: tuple,
event_run: dict, event_run: dict[EventType, dict[EventKey, str | int]],
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock,
) -> None: ) -> None:
"""Test sequence for sensors that are only available after an event happens.""" """Test sequence for sensors that are only available after an event happens."""
entity_ids = ENTITY_ID_STATES.keys() 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" time_to_freeze = "2021-01-09 12:00:00+00:00"
freezer.move_to(time_to_freeze) freezer.move_to(time_to_freeze)
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) client.get_status.return_value.status.extend(
appliance.status.update(EVENT_PROG_DELAYED_START) Status(
assert await integration_setup() 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 assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update(event_run) await client.add_events(
for entity_id, state in zip(entity_ids, states, strict=False): [
await async_update_entity(hass, entity_id) 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() await hass.async_block_till_done()
for entity_id, state in zip(entity_ids, states, strict=False):
assert hass.states.is_state(entity_id, state) assert hass.states.is_state(entity_id, state)
# Program sequence for SensorDeviceClass.TIMESTAMP edge cases. # Program sequence for SensorDeviceClass.TIMESTAMP edge cases.
PROGRAM_SEQUENCE_EDGE_CASE = [ PROGRAM_SEQUENCE_EDGE_CASE = [
EVENT_PROG_REMAIN_NO_VALUE, EVENT_PROG_DELAYED_START,
EVENT_PROG_RUN, EVENT_PROG_RUN,
EVENT_PROG_END, EVENT_PROG_END,
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.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True)
@pytest.mark.usefixtures("bypass_throttle")
async def test_remaining_prog_time_edge_cases( async def test_remaining_prog_time_edge_cases(
appliance: Mock, appliance_ha_id: str,
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
) -> None: ) -> None:
"""Run program sequence to test edge cases for the remaining_prog_time entity.""" """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" entity_id = "sensor.dishwasher_program_finish_time"
time_to_freeze = "2021-01-09 12:00:00+00:00" time_to_freeze = "2021-01-09 12:00:00+00:00"
freezer.move_to(time_to_freeze) freezer.move_to(time_to_freeze)
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) assert await integration_setup(client)
appliance.status.update(EVENT_PROG_REMAIN_NO_VALUE)
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
for ( for (
event, event,
expected_state, expected_state,
) in zip(PROGRAM_SEQUENCE_EDGE_CASE, ENTITY_ID_EDGE_CASE_STATES, strict=False): ) in zip(PROGRAM_SEQUENCE_EDGE_CASE, ENTITY_ID_EDGE_CASE_STATES, strict=False):
appliance.status.update(event) await client.add_events(
await async_update_entity(hass, entity_id) [
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() await hass.async_block_till_done()
freezer.tick() freezer.tick()
assert hass.states.is_state(entity_id, expected_state) assert hass.states.is_state(entity_id, expected_state)
@pytest.mark.parametrize( @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", "sensor.dishwasher_door",
BSH_DOOR_STATE, EventKey.BSH_COMMON_STATUS_DOOR_STATE,
EventType.STATUS,
BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_LOCKED,
"locked", "locked",
"Dishwasher", "Dishwasher",
), ),
( (
"sensor.dishwasher_door", "sensor.dishwasher_door",
BSH_DOOR_STATE, EventKey.BSH_COMMON_STATUS_DOOR_STATE,
EventType.STATUS,
BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_CLOSED,
"closed", "closed",
"Dishwasher", "Dishwasher",
), ),
( (
"sensor.dishwasher_door", "sensor.dishwasher_door",
BSH_DOOR_STATE, EventKey.BSH_COMMON_STATUS_DOOR_STATE,
EventType.STATUS,
BSH_DOOR_STATE_OPEN, BSH_DOOR_STATE_OPEN,
"open", "open",
"Dishwasher", "Dishwasher",
@ -252,33 +300,38 @@ async def test_remaining_prog_time_edge_cases(
( (
"sensor.fridgefreezer_freezer_door_alarm", "sensor.fridgefreezer_freezer_door_alarm",
"EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF",
EventType.EVENT,
"", "",
"off", "off",
"FridgeFreezer", "FridgeFreezer",
), ),
( (
"sensor.fridgefreezer_freezer_door_alarm", "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, BSH_EVENT_PRESENT_STATE_OFF,
"off", "off",
"FridgeFreezer", "FridgeFreezer",
), ),
( (
"sensor.fridgefreezer_freezer_door_alarm", "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, BSH_EVENT_PRESENT_STATE_PRESENT,
"present", "present",
"FridgeFreezer", "FridgeFreezer",
), ),
( (
"sensor.fridgefreezer_freezer_door_alarm", "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, BSH_EVENT_PRESENT_STATE_CONFIRMED,
"confirmed", "confirmed",
"FridgeFreezer", "FridgeFreezer",
), ),
( (
"sensor.coffeemaker_bean_container_empty", "sensor.coffeemaker_bean_container_empty",
EventType.EVENT,
"EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF",
"", "",
"off", "off",
@ -286,52 +339,68 @@ async def test_remaining_prog_time_edge_cases(
), ),
( (
"sensor.coffeemaker_bean_container_empty", "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, BSH_EVENT_PRESENT_STATE_OFF,
"off", "off",
"CoffeeMaker", "CoffeeMaker",
), ),
( (
"sensor.coffeemaker_bean_container_empty", "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, BSH_EVENT_PRESENT_STATE_PRESENT,
"present", "present",
"CoffeeMaker", "CoffeeMaker",
), ),
( (
"sensor.coffeemaker_bean_container_empty", "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, BSH_EVENT_PRESENT_STATE_CONFIRMED,
"confirmed", "confirmed",
"CoffeeMaker", "CoffeeMaker",
), ),
], ],
indirect=["appliance"], indirect=["appliance_ha_id"],
) )
@pytest.mark.usefixtures("bypass_throttle")
async def test_sensors_states( async def test_sensors_states(
entity_id: str, entity_id: str,
status_key: str, event_key: EventKey,
event_type: EventType,
event_value_update: str, event_value_update: str,
appliance: Mock, appliance_ha_id: str,
expected: str, expected: str,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
) -> None: ) -> None:
"""Tests for Appliance alarm sensors.""" """Tests for Appliance_ha_id alarm sensors."""
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 config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED 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() await hass.async_block_till_done()
assert hass.states.is_state(entity_id, expected) assert hass.states.is_state(entity_id, expected)

View File

@ -1,24 +1,34 @@
"""Tests for home_connect sensor entities.""" """Tests for home_connect sensor entities."""
from collections.abc import Awaitable, Callable, Generator from collections.abc import Awaitable, Callable
from unittest.mock import MagicMock, Mock 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 import pytest
from homeassistant.components import automation, script from homeassistant.components import automation, script
from homeassistant.components.automation import automations_with_entity from homeassistant.components.automation import automations_with_entity
from homeassistant.components.home_connect.const import ( from homeassistant.components.home_connect.const import (
ATTR_ALLOWED_VALUES,
ATTR_CONSTRAINTS,
BSH_ACTIVE_PROGRAM,
BSH_CHILD_LOCK_STATE,
BSH_POWER_OFF, BSH_POWER_OFF,
BSH_POWER_ON, BSH_POWER_ON,
BSH_POWER_STANDBY, BSH_POWER_STANDBY,
BSH_POWER_STATE,
DOMAIN, DOMAIN,
REFRIGERATION_SUPERMODEFREEZER,
) )
from homeassistant.components.script import scripts_with_entity from homeassistant.components.script import scripts_with_entity
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN 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.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .conftest import get_all_appliances from tests.common import MockConfigEntry
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"
@pytest.fixture @pytest.fixture
@ -58,231 +56,285 @@ def platforms() -> list[str]:
async def test_switches( async def test_switches(
bypass_throttle: Generator[None],
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: Mock, client: MagicMock,
) -> None: ) -> None:
"""Test switch entities.""" """Test switch entities."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED 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( @pytest.mark.parametrize(
( (
"entity_id", "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", "service",
"mock_attr", "mock_attr",
"problematic_appliance",
"exception_match", "exception_match",
), ),
[ [
( (
"switch.dishwasher_program_mix", "switch.dishwasher_program_eco50",
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}},
SERVICE_TURN_ON, SERVICE_TURN_ON,
"start_program", "start_program",
"Dishwasher",
r"Error.*start.*program.*", r"Error.*start.*program.*",
), ),
( (
"switch.dishwasher_program_mix", "switch.dishwasher_program_eco50",
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}},
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
"stop_program", "stop_program",
"Dishwasher",
r"Error.*stop.*program.*", r"Error.*stop.*program.*",
), ),
( (
"switch.dishwasher_power", "switch.dishwasher_power",
{BSH_POWER_STATE: {"value": BSH_POWER_OFF}},
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
"set_setting", "set_setting",
"Dishwasher",
r"Error.*turn.*off.*", r"Error.*turn.*off.*",
), ),
( (
"switch.dishwasher_power", "switch.dishwasher_power",
{BSH_POWER_STATE: {"value": ""}},
SERVICE_TURN_ON, SERVICE_TURN_ON,
"set_setting", "set_setting",
"Dishwasher",
r"Error.*turn.*on.*", r"Error.*turn.*on.*",
), ),
( (
"switch.dishwasher_child_lock", "switch.dishwasher_child_lock",
{BSH_CHILD_LOCK_STATE: {"value": ""}},
SERVICE_TURN_ON, SERVICE_TURN_ON,
"set_setting", "set_setting",
"Dishwasher",
r"Error.*turn.*on.*", r"Error.*turn.*on.*",
), ),
( (
"switch.dishwasher_child_lock", "switch.dishwasher_child_lock",
{BSH_CHILD_LOCK_STATE: {"value": ""}},
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
"set_setting", "set_setting",
"Dishwasher",
r"Error.*turn.*off.*", r"Error.*turn.*off.*",
), ),
], ],
indirect=["problematic_appliance"],
) )
async def test_switch_exception_handling( async def test_switch_exception_handling(
entity_id: str, entity_id: str,
status: dict,
service: str, service: str,
mock_attr: str, mock_attr: str,
exception_match: str, exception_match: str,
bypass_throttle: Generator[None],
hass: HomeAssistant, hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
setup_credentials: None, setup_credentials: None,
problematic_appliance: Mock, client_with_exception: MagicMock,
get_appliances: MagicMock,
) -> None: ) -> None:
"""Test exception handling.""" """Test exception handling."""
problematic_appliance.get_programs_available.side_effect = None client_with_exception.get_available_programs.side_effect = None
problematic_appliance.get_programs_available.return_value = [PROGRAM] client_with_exception.get_available_programs.return_value = (
get_appliances.return_value = [problematic_appliance] 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 assert config_entry.state == ConfigEntryState.NOT_LOADED
problematic_appliance.status.update(status) assert await integration_setup(client_with_exception)
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
# Assert that an exception is called. # Assert that an exception is called.
with pytest.raises(HomeConnectError): with pytest.raises(HomeConnectError):
getattr(problematic_appliance, mock_attr)() await getattr(client_with_exception, mock_attr)()
with pytest.raises(HomeAssistantError, match=exception_match): with pytest.raises(HomeAssistantError, match=exception_match):
await hass.services.async_call( 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( @pytest.mark.parametrize(
("entity_id", "status", "service", "state", "appliance"), ("entity_id", "status", "service", "state", "appliance_ha_id"),
[ [
( (
"switch.fridgefreezer_freezer_super_mode", "switch.fridgefreezer_freezer_super_mode",
{REFRIGERATION_SUPERMODEFREEZER: {"value": True}}, {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: True},
SERVICE_TURN_ON, SERVICE_TURN_ON,
STATE_ON, STATE_ON,
"FridgeFreezer", "FridgeFreezer",
), ),
( (
"switch.fridgefreezer_freezer_super_mode", "switch.fridgefreezer_freezer_super_mode",
{REFRIGERATION_SUPERMODEFREEZER: {"value": False}}, {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: False},
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
STATE_OFF, STATE_OFF,
"FridgeFreezer", "FridgeFreezer",
), ),
], ],
indirect=["appliance"], indirect=["appliance_ha_id"],
) )
async def test_ent_desc_switch_functionality( async def test_ent_desc_switch_functionality(
entity_id: str, entity_id: str,
status: dict, status: dict,
service: str, service: str,
state: str, state: str,
bypass_throttle: Generator[None],
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
appliance: Mock, appliance_ha_id: str,
get_appliances: MagicMock, client: MagicMock,
) -> None: ) -> None:
"""Test switch functionality - entity description setup.""" """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 config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update(status) await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id})
await hass.services.async_call( await hass.async_block_till_done()
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert hass.states.is_state(entity_id, state) assert hass.states.is_state(entity_id, state)
@ -292,13 +344,13 @@ async def test_ent_desc_switch_functionality(
"status", "status",
"service", "service",
"mock_attr", "mock_attr",
"problematic_appliance", "appliance_ha_id",
"exception_match", "exception_match",
), ),
[ [
( (
"switch.fridgefreezer_freezer_super_mode", "switch.fridgefreezer_freezer_super_mode",
{REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""},
SERVICE_TURN_ON, SERVICE_TURN_ON,
"set_setting", "set_setting",
"FridgeFreezer", "FridgeFreezer",
@ -306,203 +358,257 @@ async def test_ent_desc_switch_functionality(
), ),
( (
"switch.fridgefreezer_freezer_super_mode", "switch.fridgefreezer_freezer_super_mode",
{REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""},
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
"set_setting", "set_setting",
"FridgeFreezer", "FridgeFreezer",
r"Error.*turn.*off.*", r"Error.*turn.*off.*",
), ),
], ],
indirect=["problematic_appliance"], indirect=["appliance_ha_id"],
) )
async def test_ent_desc_switch_exception_handling( async def test_ent_desc_switch_exception_handling(
entity_id: str, entity_id: str,
status: dict, status: dict[SettingKey, str],
service: str, service: str,
mock_attr: str, mock_attr: str,
exception_match: str, exception_match: str,
bypass_throttle: Generator[None],
hass: HomeAssistant, hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
setup_credentials: None, setup_credentials: None,
problematic_appliance: Mock, appliance_ha_id: str,
get_appliances: MagicMock, client_with_exception: MagicMock,
) -> None: ) -> None:
"""Test switch exception handling - entity description setup.""" """Test switch exception handling - entity description setup."""
problematic_appliance.status.update( client_with_exception.get_settings.side_effect = None
HomeConnectAppliance.json2dict( client_with_exception.get_settings.return_value = ArrayOfSettings(
load_json_object_fixture("home_connect/settings.json") [
.get(problematic_appliance.name) GetSetting(
.get("data") key=key,
.get("settings") 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 config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
# Assert that an exception is called. # Assert that an exception is called.
with pytest.raises(HomeConnectError): with pytest.raises(HomeConnectError):
getattr(problematic_appliance, mock_attr)() await client_with_exception.set_setting()
problematic_appliance.status.update(status)
with pytest.raises(HomeAssistantError, match=exception_match): with pytest.raises(HomeAssistantError, match=exception_match):
await hass.services.async_call( await hass.services.async_call(
SWITCH_DOMAIN, service, {ATTR_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 client_with_exception.set_setting.call_count == 2
@pytest.mark.parametrize( @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", "switch.dishwasher_power",
{BSH_POWER_STATE: {"value": BSH_POWER_ON}},
[BSH_POWER_ON, BSH_POWER_OFF], [BSH_POWER_ON, BSH_POWER_OFF],
SERVICE_TURN_ON, SERVICE_TURN_ON,
BSH_POWER_ON,
STATE_ON, STATE_ON,
"Dishwasher", "Dishwasher",
), ),
( (
"switch.dishwasher_power", "switch.dishwasher_power",
{BSH_POWER_STATE: {"value": BSH_POWER_OFF}},
[BSH_POWER_ON, BSH_POWER_OFF], [BSH_POWER_ON, BSH_POWER_OFF],
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
BSH_POWER_OFF,
STATE_OFF, STATE_OFF,
"Dishwasher", "Dishwasher",
), ),
( (
"switch.dishwasher_power", "switch.dishwasher_power",
{BSH_POWER_STATE: {"value": BSH_POWER_ON}},
[BSH_POWER_ON, BSH_POWER_STANDBY], [BSH_POWER_ON, BSH_POWER_STANDBY],
SERVICE_TURN_ON, SERVICE_TURN_ON,
BSH_POWER_ON,
STATE_ON, STATE_ON,
"Dishwasher", "Dishwasher",
), ),
( (
"switch.dishwasher_power", "switch.dishwasher_power",
{BSH_POWER_STATE: {"value": BSH_POWER_STANDBY}},
[BSH_POWER_ON, BSH_POWER_STANDBY], [BSH_POWER_ON, BSH_POWER_STANDBY],
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
BSH_POWER_STANDBY,
STATE_OFF, STATE_OFF,
"Dishwasher", "Dishwasher",
), ),
], ],
indirect=["appliance"], indirect=["appliance_ha_id"],
) )
@pytest.mark.usefixtures("bypass_throttle")
async def test_power_swtich( async def test_power_swtich(
entity_id: str, entity_id: str,
status: dict, allowed_values: list[str | None] | None,
allowed_values: list[str],
service: str, service: str,
setting_value_arg: str,
power_state: str, power_state: str,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
appliance: Mock, appliance_ha_id: str,
get_appliances: MagicMock, client: MagicMock,
) -> None: ) -> None:
"""Test power switch functionality.""" """Test power switch functionality."""
appliance.get.side_effect = [ client.get_settings.side_effect = None
{ client.get_settings.return_value = ArrayOfSettings(
ATTR_CONSTRAINTS: { [
ATTR_ALLOWED_VALUES: allowed_values, GetSetting(
}, key=SettingKey.BSH_COMMON_POWER_STATE,
} raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
value="",
constraints=SettingConstraints(
allowed_values=allowed_values,
),
)
] ]
appliance.status.update(SETTINGS_STATUS) )
appliance.status.update(status)
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
await hass.services.async_call( await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id})
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True 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) assert hass.states.is_state(entity_id, power_state)
@pytest.mark.parametrize( @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", "switch.dishwasher_power",
[BSH_POWER_ON], [BSH_POWER_ON],
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
"Dishwasher",
r".*not support.*turn.*off.*", r".*not support.*turn.*off.*",
), ),
( (
"switch.dishwasher_power", "switch.dishwasher_power",
None, None,
SERVICE_TURN_OFF, 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.*", r".*Unable.*turn.*off.*support.*not.*determined.*",
), ),
], ],
indirect=["appliance"],
) )
@pytest.mark.usefixtures("bypass_throttle")
async def test_power_switch_service_validation_errors( async def test_power_switch_service_validation_errors(
entity_id: str, entity_id: str,
allowed_values: list[str], allowed_values: list[str | None] | None | HomeConnectError,
service: str, service: str,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
appliance: Mock,
exception_match: str, exception_match: str,
get_appliances: MagicMock, client: MagicMock,
) -> None: ) -> None:
"""Test power switch functionality validation errors.""" """Test power switch functionality validation errors."""
if allowed_values: client.get_settings.side_effect = None
appliance.get.side_effect = [ if isinstance(allowed_values, HomeConnectError):
{ exception = allowed_values
ATTR_CONSTRAINTS: { client.get_settings.return_value = ArrayOfSettings(
ATTR_ALLOWED_VALUES: allowed_values, [
}, GetSetting(
} key=SettingKey.BSH_COMMON_POWER_STATE,
raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
value=BSH_POWER_ON,
)
] ]
appliance.status.update(SETTINGS_STATUS) )
get_appliances.return_value = [appliance] 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 config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update({BSH_POWER_STATE: {"value": BSH_POWER_ON}})
with pytest.raises(HomeAssistantError, match=exception_match): with pytest.raises(HomeAssistantError, match=exception_match):
await hass.services.async_call( 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("entity_registry_enabled_by_default")
@pytest.mark.usefixtures("bypass_throttle")
async def test_create_issue( async def test_create_issue(
hass: HomeAssistant, hass: HomeAssistant,
appliance: Mock, appliance_ha_id: str,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
issue_registry: ir.IssueRegistry, issue_registry: ir.IssueRegistry,
) -> None: ) -> None:
"""Test we create an issue when an automation or script is using a deprecated entity.""" """Test we create an issue when an automation or script is using a deprecated entity."""
entity_id = "switch.washer_program_mix" 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}" issue_id = f"deprecated_program_switch_{entity_id}"
assert await async_setup_component( assert await async_setup_component(
@ -539,7 +645,7 @@ async def test_create_issue(
) )
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
assert automations_with_entity(hass, entity_id)[0] == "automation.test" assert automations_with_entity(hass, entity_id)[0] == "automation.test"

View File

@ -1,21 +1,19 @@
"""Tests for home_connect time entities.""" """Tests for home_connect time entities."""
from collections.abc import Awaitable, Callable, Generator from collections.abc import Awaitable, Callable
from datetime import time 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 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.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from .conftest import get_all_appliances
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -26,114 +24,98 @@ def platforms() -> list[str]:
async def test_time( async def test_time(
bypass_throttle: Generator[None],
hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: Mock, client: MagicMock,
) -> None: ) -> None:
"""Test time entity.""" """Test time entity."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state is ConfigEntryState.NOT_LOADED 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 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( @pytest.mark.parametrize(
("entity_id", "setting_key", "setting_value", "expected_state"), ("entity_id", "setting_key"),
[ [
( (
f"{TIME_DOMAIN}.oven_alarm_clock", f"{TIME_DOMAIN}.oven_alarm_clock",
"BSH.Common.Setting.AlarmClock", SettingKey.BSH_COMMON_ALARM_CLOCK,
{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",
), ),
], ],
) )
@pytest.mark.usefixtures("bypass_throttle")
async def test_time_entity_functionality( async def test_time_entity_functionality(
appliance: Mock, appliance_ha_id: str,
entity_id: str, entity_id: str,
setting_key: str, setting_key: SettingKey,
setting_value: dict,
expected_state: str,
bypass_throttle: Generator[None],
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client: MagicMock,
) -> None: ) -> None:
"""Test time entity functionality.""" """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 config_entry.state is ConfigEntryState.NOT_LOADED
assert await integration_setup() assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
assert hass.states.is_state(entity_id, expected_state)
new_value = 30 value = 30
assert hass.states.get(entity_id).state != new_value entity_state = hass.states.get(entity_id)
assert entity_state is not None
assert entity_state.state != value
await hass.services.async_call( await hass.services.async_call(
TIME_DOMAIN, TIME_DOMAIN,
SERVICE_SET_VALUE, SERVICE_SET_VALUE,
{ {
ATTR_ENTITY_ID: entity_id, 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( @pytest.mark.parametrize(
("entity_id", "setting_key", "mock_attr"), ("entity_id", "setting_key", "mock_attr"),
[ [
( (
f"{TIME_DOMAIN}.oven_alarm_clock", f"{TIME_DOMAIN}.oven_alarm_clock",
"BSH.Common.Setting.AlarmClock", SettingKey.BSH_COMMON_ALARM_CLOCK,
"set_setting", "set_setting",
), ),
], ],
) )
@pytest.mark.usefixtures("bypass_throttle")
async def test_time_entity_error( async def test_time_entity_error(
problematic_appliance: Mock,
entity_id: str, entity_id: str,
setting_key: str, setting_key: SettingKey,
mock_attr: str, mock_attr: str,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
get_appliances: MagicMock, client_with_exception: MagicMock,
) -> None: ) -> None:
"""Test time entity error.""" """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 assert config_entry.state is ConfigEntryState.NOT_LOADED
problematic_appliance.status.update({setting_key: {}}) assert await integration_setup(client_with_exception)
assert await integration_setup()
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
with pytest.raises(HomeConnectError): with pytest.raises(HomeConnectError):
getattr(problematic_appliance, mock_attr)() await getattr(client_with_exception, mock_attr)()
with pytest.raises( with pytest.raises(
HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*"
@ -147,4 +129,4 @@ async def test_time_entity_error(
}, },
blocking=True, blocking=True,
) )
assert getattr(problematic_appliance, mock_attr).call_count == 2 assert getattr(client_with_exception, mock_attr).call_count == 2