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>
pull/136949/head
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 datetime import timedelta
import logging
import re
from typing import Any, cast
from requests import HTTPError
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import CommandKey, Option, OptionKey
from aiohomeconnect.model.error import HomeConnectError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_entry_oauth2_flow,
@ -21,16 +20,13 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from . import api
from .api import AsyncConfigEntryAuth
from .const import (
ATTR_KEY,
ATTR_PROGRAM,
ATTR_UNIT,
ATTR_VALUE,
BSH_PAUSE,
BSH_RESUME,
DOMAIN,
OLD_NEW_UNIQUE_ID_SUFFIX_MAP,
SERVICE_OPTION_ACTIVE,
@ -44,15 +40,11 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth]
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
RE_CAMEL_CASE = re.compile(r"(?<!^)(?=[A-Z])|(?=\d)(?<=\D)")
SCAN_INTERVAL = timedelta(minutes=1)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
SERVICE_SETTING_SCHEMA = vol.Schema(
@ -99,17 +91,24 @@ PLATFORMS = [
]
def _get_appliance(
hass: HomeAssistant,
device_id: str | None = None,
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_entry = device_registry.async_get(device_id)
assert device_entry, "Either a device id or a device entry must be provided"
async def _get_client_and_ha_id(
hass: HomeAssistant, device_id: str
) -> tuple[HomeConnectClient, str]:
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
if device_entry is None:
raise ServiceValidationError("Device entry not found for device id")
entry: HomeConnectConfigEntry | None = None
for entry_id in device_entry.config_entries:
_entry = hass.config_entries.async_get_entry(entry_id)
assert _entry
if _entry.domain == DOMAIN:
entry = cast(HomeConnectConfigEntry, _entry)
break
if entry is None:
raise ServiceValidationError(
"Home Connect config entry not found for that device id"
)
ha_id = next(
(
@ -119,158 +118,148 @@ def _get_appliance(
),
None,
)
assert ha_id
def find_appliance(
entry: HomeConnectConfigEntry,
) -> api.HomeConnectAppliance | None:
for device in entry.runtime_data.devices:
appliance = device.appliance
if appliance.haId == ha_id:
return appliance
return None
if entry is None:
for entry_id in device_entry.config_entries:
entry = hass.config_entries.async_get_entry(entry_id)
assert entry
if entry.domain == DOMAIN:
entry = cast(HomeConnectConfigEntry, entry)
if (appliance := find_appliance(entry)) is not None:
return appliance
elif (appliance := find_appliance(entry)) is not None:
return appliance
raise ValueError(f"Appliance for device id {device_entry.id} not found")
def _get_appliance_or_raise_service_validation_error(
hass: HomeAssistant, device_id: str
) -> api.HomeConnectAppliance:
"""Return a Home Connect appliance instance or raise a service validation error."""
try:
return _get_appliance(hass, device_id)
except (ValueError, AssertionError) as err:
if ha_id is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="appliance_not_found",
translation_placeholders={
"device_id": device_id,
},
) from err
async def _run_appliance_service[*_Ts](
hass: HomeAssistant,
appliance: api.HomeConnectAppliance,
method: str,
*args: *_Ts,
error_translation_key: str,
error_translation_placeholders: dict[str, str],
) -> None:
try:
await hass.async_add_executor_job(getattr(appliance, method), *args)
except api.HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=error_translation_key,
translation_placeholders={
**get_dict_from_home_connect_error(err),
**error_translation_placeholders,
},
) from err
)
return entry.runtime_data.client, ha_id
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Home Connect component."""
async def _async_service_program(call, method):
async def _async_service_program(call: ServiceCall, start: bool):
"""Execute calls to services taking a program."""
program = call.data[ATTR_PROGRAM]
device_id = call.data[ATTR_DEVICE_ID]
options = []
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
option_key = call.data.get(ATTR_KEY)
if option_key is not None:
option = {ATTR_KEY: option_key, ATTR_VALUE: call.data[ATTR_VALUE]}
option_unit = call.data.get(ATTR_UNIT)
if option_unit is not None:
option[ATTR_UNIT] = option_unit
options.append(option)
await _run_appliance_service(
hass,
_get_appliance_or_raise_service_validation_error(hass, device_id),
method,
program,
options,
error_translation_key=method,
error_translation_placeholders={
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program,
},
options = (
[
Option(
OptionKey(option_key),
call.data[ATTR_VALUE],
unit=call.data.get(ATTR_UNIT),
)
]
if option_key is not None
else None
)
async def _async_service_command(call, command):
"""Execute calls to services executing a command."""
device_id = call.data[ATTR_DEVICE_ID]
try:
if start:
await client.start_program(ha_id, program_key=program, options=options)
else:
await client.set_selected_program(
ha_id, program_key=program, options=options
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="start_program" if start else "select_program",
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program,
},
) from err
appliance = _get_appliance_or_raise_service_validation_error(hass, device_id)
await _run_appliance_service(
hass,
appliance,
"execute_command",
command,
error_translation_key="execute_command",
error_translation_placeholders={"command": command},
)
async def _async_service_key_value(call, method):
"""Execute calls to services taking a key and value."""
key = call.data[ATTR_KEY]
async def _async_service_set_program_options(call: ServiceCall, active: bool):
"""Execute calls to services taking a program."""
option_key = call.data[ATTR_KEY]
value = call.data[ATTR_VALUE]
unit = call.data.get(ATTR_UNIT)
device_id = call.data[ATTR_DEVICE_ID]
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
await _run_appliance_service(
hass,
_get_appliance_or_raise_service_validation_error(hass, device_id),
method,
*((key, value) if unit is None else (key, value, unit)),
error_translation_key=method,
error_translation_placeholders={
SVE_TRANSLATION_PLACEHOLDER_KEY: key,
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
},
)
try:
if active:
await client.set_active_program_option(
ha_id,
option_key=OptionKey(option_key),
value=value,
unit=unit,
)
else:
await client.set_selected_program_option(
ha_id,
option_key=OptionKey(option_key),
value=value,
unit=unit,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_options_active_program"
if active
else "set_options_selected_program",
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_KEY: option_key,
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
},
) from err
async def async_service_option_active(call):
async def _async_service_command(call: ServiceCall, command_key: CommandKey):
"""Execute calls to services executing a command."""
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
try:
await client.put_command(ha_id, command_key=command_key, value=True)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="execute_command",
translation_placeholders={
**get_dict_from_home_connect_error(err),
"command": command_key.value,
},
) from err
async def async_service_option_active(call: ServiceCall):
"""Service for setting an option for an active program."""
await _async_service_key_value(call, "set_options_active_program")
await _async_service_set_program_options(call, True)
async def async_service_option_selected(call):
async def async_service_option_selected(call: ServiceCall):
"""Service for setting an option for a selected program."""
await _async_service_key_value(call, "set_options_selected_program")
await _async_service_set_program_options(call, False)
async def async_service_setting(call):
async def async_service_setting(call: ServiceCall):
"""Service for changing a setting."""
await _async_service_key_value(call, "set_setting")
key = call.data[ATTR_KEY]
value = call.data[ATTR_VALUE]
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
async def async_service_pause_program(call):
try:
await client.set_setting(ha_id, setting_key=key, value=value)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_setting",
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_KEY: key,
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
},
) from err
async def async_service_pause_program(call: ServiceCall):
"""Service for pausing a program."""
await _async_service_command(call, BSH_PAUSE)
await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM)
async def async_service_resume_program(call):
async def async_service_resume_program(call: ServiceCall):
"""Service for resuming a paused program."""
await _async_service_command(call, BSH_RESUME)
await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM)
async def async_service_select_program(call):
async def async_service_select_program(call: ServiceCall):
"""Service for selecting a program."""
await _async_service_program(call, "select_program")
await _async_service_program(call, False)
async def async_service_start_program(call):
async def async_service_start_program(call: ServiceCall):
"""Service for starting a program."""
await _async_service_program(call, "start_program")
await _async_service_program(call, True)
hass.services.async_register(
DOMAIN,
@ -323,12 +312,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
)
)
entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
await update_all_devices(hass, entry)
config_entry_auth = AsyncConfigEntryAuth(hass, session)
home_connect_client = HomeConnectClient(config_entry_auth)
coordinator = HomeConnectCoordinator(hass, entry, home_connect_client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.runtime_data.start_event_listener()
return True
@ -339,21 +337,6 @@ async def async_unload_entry(
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@Throttle(SCAN_INTERVAL)
async def update_all_devices(
hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> None:
"""Update all the devices."""
hc_api = entry.runtime_data
try:
await hass.async_add_executor_job(hc_api.get_devices)
for device in hc_api.devices:
await hass.async_add_executor_job(device.initialize)
except HTTPError as err:
_LOGGER.warning("Cannot update devices: %s", err.response.status_code)
async def async_migrate_entry(
hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> bool:
@ -382,25 +365,3 @@ async def async_migrate_entry(
_LOGGER.debug("Migration to version %s successful", entry.version)
return True
def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]:
"""Return a dict from a Home Connect error."""
return {
"description": cast(dict[str, Any], err.args[0]).get("description", "?")
if len(err.args) > 0 and isinstance(err.args[0], dict)
else err.args[0]
if len(err.args) > 0 and isinstance(err.args[0], str)
else "?",
}
def bsh_key_to_translation_key(bsh_key: str) -> str:
"""Convert a BSH key to a translation key format.
This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`,
and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`.
"""
return "_".join(
RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".")
).lower()

View File

@ -1,85 +1,28 @@
"""API for Home Connect bound to HASS OAuth."""
from asyncio import run_coroutine_threadsafe
import logging
from aiohomeconnect.client import AbstractAuth
from aiohomeconnect.const import API_ENDPOINT
import homeconnect
from homeconnect.api import HomeConnectAppliance, HomeConnectError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import ATTR_KEY, ATTR_VALUE, BSH_ACTIVE_PROGRAM, SIGNAL_UPDATE_ENTITIES
_LOGGER = logging.getLogger(__name__)
from homeassistant.helpers.httpx_client import get_async_client
class ConfigEntryAuth(homeconnect.HomeConnectAPI):
class AsyncConfigEntryAuth(AbstractAuth):
"""Provide Home Connect authentication tied to an OAuth2 based config entry."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize Home Connect Auth."""
self.hass = hass
self.config_entry = config_entry
self.session = config_entry_oauth2_flow.OAuth2Session(
hass, config_entry, implementation
)
super().__init__(self.session.token)
self.devices: list[HomeConnectDevice] = []
super().__init__(get_async_client(hass), host=API_ENDPOINT)
self.session = oauth_session
def refresh_tokens(self) -> dict:
"""Refresh and return new Home Connect tokens using Home Assistant OAuth2 session."""
run_coroutine_threadsafe(
self.session.async_ensure_token_valid(), self.hass.loop
).result()
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
await self.session.async_ensure_token_valid()
return self.session.token
def get_devices(self) -> list[HomeConnectAppliance]:
"""Get a dictionary of devices."""
appl: list[HomeConnectAppliance] = self.get_appliances()
self.devices = [HomeConnectDevice(self.hass, app) for app in appl]
return self.devices
class HomeConnectDevice:
"""Generic Home Connect device."""
def __init__(self, hass: HomeAssistant, appliance: HomeConnectAppliance) -> None:
"""Initialize the device class."""
self.hass = hass
self.appliance = appliance
def initialize(self) -> None:
"""Fetch the info needed to initialize the device."""
try:
self.appliance.get_status()
except (HomeConnectError, ValueError):
_LOGGER.debug("Unable to fetch appliance status. Probably offline")
try:
self.appliance.get_settings()
except (HomeConnectError, ValueError):
_LOGGER.debug("Unable to fetch settings. Probably offline")
try:
program_active = self.appliance.get_programs_active()
except (HomeConnectError, ValueError):
_LOGGER.debug("Unable to fetch active programs. Probably offline")
program_active = None
if program_active and ATTR_KEY in program_active:
self.appliance.status[BSH_ACTIVE_PROGRAM] = {
ATTR_VALUE: program_active[ATTR_KEY]
}
self.appliance.listen_events(callback=self.event_callback)
def event_callback(self, appliance: HomeConnectAppliance) -> None:
"""Handle event."""
_LOGGER.debug("Update triggered on %s", appliance.name)
_LOGGER.debug(self.appliance.status)
dispatcher_send(self.hass, SIGNAL_UPDATE_ENTITIES, appliance.haId)
return self.session.token["access_token"]

View File

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

View File

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

View File

@ -1,9 +1,9 @@
"""Constants for the Home Connect integration."""
from aiohomeconnect.model import EventKey, SettingKey, StatusKey
DOMAIN = "home_connect"
OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize"
OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token"
APPLIANCES_WITH_PROGRAMS = (
"CleaningRobot",
@ -17,93 +17,35 @@ APPLIANCES_WITH_PROGRAMS = (
"WasherDryer",
)
BSH_POWER_STATE = "BSH.Common.Setting.PowerState"
BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On"
BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off"
BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby"
BSH_SELECTED_PROGRAM = "BSH.Common.Root.SelectedProgram"
BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram"
BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive"
BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed"
BSH_CHILD_LOCK_STATE = "BSH.Common.Setting.ChildLock"
BSH_REMAINING_PROGRAM_TIME = "BSH.Common.Option.RemainingProgramTime"
BSH_COMMON_OPTION_DURATION = "BSH.Common.Option.Duration"
BSH_COMMON_OPTION_PROGRAM_PROGRESS = "BSH.Common.Option.ProgramProgress"
BSH_EVENT_PRESENT_STATE_PRESENT = "BSH.Common.EnumType.EventPresentState.Present"
BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confirmed"
BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off"
BSH_OPERATION_STATE = "BSH.Common.Status.OperationState"
BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run"
BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause"
BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished"
COOKING_LIGHTING = "Cooking.Common.Setting.Lighting"
COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness"
COFFEE_EVENT_BEAN_CONTAINER_EMPTY = (
"ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty"
)
COFFEE_EVENT_WATER_TANK_EMPTY = "ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty"
COFFEE_EVENT_DRIP_TRAY_FULL = "ConsumerProducts.CoffeeMaker.Event.DripTrayFull"
DISHWASHER_EVENT_SALT_NEARLY_EMPTY = "Dishcare.Dishwasher.Event.SaltNearlyEmpty"
DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY = (
"Dishcare.Dishwasher.Event.RinseAidNearlyEmpty"
)
REFRIGERATION_INTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.Internal.Power"
REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS = (
"Refrigeration.Common.Setting.Light.Internal.Brightness"
)
REFRIGERATION_EXTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.External.Power"
REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS = (
"Refrigeration.Common.Setting.Light.External.Brightness"
)
REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer"
REFRIGERATION_SUPERMODEREFRIGERATOR = (
"Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator"
)
REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled"
REFRIGERATION_STATUS_DOOR_CHILLER = "Refrigeration.Common.Status.Door.ChillerCommon"
REFRIGERATION_STATUS_DOOR_FREEZER = "Refrigeration.Common.Status.Door.Freezer"
REFRIGERATION_STATUS_DOOR_REFRIGERATOR = "Refrigeration.Common.Status.Door.Refrigerator"
REFRIGERATION_STATUS_DOOR_CLOSED = "Refrigeration.Common.EnumType.Door.States.Closed"
REFRIGERATION_STATUS_DOOR_OPEN = "Refrigeration.Common.EnumType.Door.States.Open"
REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR = (
"Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator"
)
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER = (
"Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer"
)
REFRIGERATION_EVENT_TEMP_ALARM_FREEZER = (
"Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer"
)
BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled"
BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness"
BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor"
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR = (
"BSH.Common.EnumType.AmbientLightColor.CustomColor"
)
BSH_AMBIENT_LIGHT_CUSTOM_COLOR = "BSH.Common.Setting.AmbientLightCustomColor"
BSH_DOOR_STATE = "BSH.Common.Status.DoorState"
BSH_DOOR_STATE_CLOSED = "BSH.Common.EnumType.DoorState.Closed"
BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked"
BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open"
BSH_PAUSE = "BSH.Common.Command.PauseProgram"
BSH_RESUME = "BSH.Common.Command.ResumeProgram"
SIGNAL_UPDATE_ENTITIES = "home_connect.update_entities"
SERVICE_OPTION_ACTIVE = "set_option_active"
SERVICE_OPTION_SELECTED = "set_option_selected"
@ -113,51 +55,44 @@ SERVICE_SELECT_PROGRAM = "select_program"
SERVICE_SETTING = "change_setting"
SERVICE_START_PROGRAM = "start_program"
ATTR_ALLOWED_VALUES = "allowedvalues"
ATTR_AMBIENT = "ambient"
ATTR_BSH_KEY = "bsh_key"
ATTR_CONSTRAINTS = "constraints"
ATTR_DESC = "desc"
ATTR_DEVICE = "device"
ATTR_KEY = "key"
ATTR_PROGRAM = "program"
ATTR_SENSOR_TYPE = "sensor_type"
ATTR_SIGN = "sign"
ATTR_STEPSIZE = "stepsize"
ATTR_UNIT = "unit"
ATTR_VALUE = "value"
SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity"
SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity"
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name"
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id"
SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program"
SVE_TRANSLATION_PLACEHOLDER_KEY = "key"
SVE_TRANSLATION_PLACEHOLDER_VALUE = "value"
OLD_NEW_UNIQUE_ID_SUFFIX_MAP = {
"ChildLock": BSH_CHILD_LOCK_STATE,
"Operation State": BSH_OPERATION_STATE,
"Light": COOKING_LIGHTING,
"AmbientLight": BSH_AMBIENT_LIGHT_ENABLED,
"Power": BSH_POWER_STATE,
"Remaining Program Time": BSH_REMAINING_PROGRAM_TIME,
"Duration": BSH_COMMON_OPTION_DURATION,
"Program Progress": BSH_COMMON_OPTION_PROGRAM_PROGRESS,
"Remote Control": BSH_REMOTE_CONTROL_ACTIVATION_STATE,
"Remote Start": BSH_REMOTE_START_ALLOWANCE_STATE,
"Supermode Freezer": REFRIGERATION_SUPERMODEFREEZER,
"Supermode Refrigerator": REFRIGERATION_SUPERMODEREFRIGERATOR,
"Dispenser Enabled": REFRIGERATION_DISPENSER,
"Internal Light": REFRIGERATION_INTERNAL_LIGHT_POWER,
"External Light": REFRIGERATION_EXTERNAL_LIGHT_POWER,
"Chiller Door": REFRIGERATION_STATUS_DOOR_CHILLER,
"Freezer Door": REFRIGERATION_STATUS_DOOR_FREEZER,
"Refrigerator Door": REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
"Door Alarm Freezer": REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
"Door Alarm Refrigerator": REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR,
"Temperature Alarm Freezer": REFRIGERATION_EVENT_TEMP_ALARM_FREEZER,
"Bean Container Empty": COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
"Water Tank Empty": COFFEE_EVENT_WATER_TANK_EMPTY,
"Drip Tray Full": COFFEE_EVENT_DRIP_TRAY_FULL,
"ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK,
"Operation State": StatusKey.BSH_COMMON_OPERATION_STATE,
"Light": SettingKey.COOKING_COMMON_LIGHTING,
"AmbientLight": SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED,
"Power": SettingKey.BSH_COMMON_POWER_STATE,
"Remaining Program Time": EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME,
"Duration": EventKey.BSH_COMMON_OPTION_DURATION,
"Program Progress": EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS,
"Remote Control": StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE,
"Remote Start": StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED,
"Supermode Freezer": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER,
"Supermode Refrigerator": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR,
"Dispenser Enabled": SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED,
"Internal Light": SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER,
"External Light": SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER,
"Chiller Door": StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER,
"Freezer Door": StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER,
"Refrigerator Door": StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR,
"Door Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER,
"Door Alarm Refrigerator": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR,
"Temperature Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER,
"Bean Container Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY,
"Water Tank Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY,
"Drip Tray Full": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL,
}

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,191 +1,28 @@
"""Provides a select platform for Home Connect."""
import contextlib
import logging
from typing import cast
from homeconnect.api import HomeConnectError
from aiohomeconnect.model import EventKey, ProgramKey
from aiohomeconnect.model.error import HomeConnectError
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import (
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
bsh_key_to_translation_key,
get_dict_from_home_connect_error,
)
from .api import HomeConnectDevice
from .const import (
APPLIANCES_WITH_PROGRAMS,
ATTR_VALUE,
BSH_ACTIVE_PROGRAM,
BSH_SELECTED_PROGRAM,
DOMAIN,
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
HomeConnectCoordinator,
)
from .entity import HomeConnectEntity
_LOGGER = logging.getLogger(__name__)
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
TRANSLATION_KEYS_PROGRAMS_MAP = {
bsh_key_to_translation_key(program): program
for program in (
"ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll",
"ConsumerProducts.CleaningRobot.Program.Cleaning.CleanMap",
"ConsumerProducts.CleaningRobot.Program.Basic.GoHome",
"ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto",
"ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso",
"ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio",
"ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee",
"ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee",
"ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeGrande",
"ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato",
"ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino",
"ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato",
"ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte",
"ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth",
"ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Doppio",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye",
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye",
"ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater",
"Dishcare.Dishwasher.Program.PreRinse",
"Dishcare.Dishwasher.Program.Auto1",
"Dishcare.Dishwasher.Program.Auto2",
"Dishcare.Dishwasher.Program.Auto3",
"Dishcare.Dishwasher.Program.Eco50",
"Dishcare.Dishwasher.Program.Quick45",
"Dishcare.Dishwasher.Program.Intensiv70",
"Dishcare.Dishwasher.Program.Normal65",
"Dishcare.Dishwasher.Program.Glas40",
"Dishcare.Dishwasher.Program.GlassCare",
"Dishcare.Dishwasher.Program.NightWash",
"Dishcare.Dishwasher.Program.Quick65",
"Dishcare.Dishwasher.Program.Normal45",
"Dishcare.Dishwasher.Program.Intensiv45",
"Dishcare.Dishwasher.Program.AutoHalfLoad",
"Dishcare.Dishwasher.Program.IntensivPower",
"Dishcare.Dishwasher.Program.MagicDaily",
"Dishcare.Dishwasher.Program.Super60",
"Dishcare.Dishwasher.Program.Kurz60",
"Dishcare.Dishwasher.Program.ExpressSparkle65",
"Dishcare.Dishwasher.Program.MachineCare",
"Dishcare.Dishwasher.Program.SteamFresh",
"Dishcare.Dishwasher.Program.MaximumCleaning",
"Dishcare.Dishwasher.Program.MixedLoad",
"LaundryCare.Dryer.Program.Cotton",
"LaundryCare.Dryer.Program.Synthetic",
"LaundryCare.Dryer.Program.Mix",
"LaundryCare.Dryer.Program.Blankets",
"LaundryCare.Dryer.Program.BusinessShirts",
"LaundryCare.Dryer.Program.DownFeathers",
"LaundryCare.Dryer.Program.Hygiene",
"LaundryCare.Dryer.Program.Jeans",
"LaundryCare.Dryer.Program.Outdoor",
"LaundryCare.Dryer.Program.SyntheticRefresh",
"LaundryCare.Dryer.Program.Towels",
"LaundryCare.Dryer.Program.Delicates",
"LaundryCare.Dryer.Program.Super40",
"LaundryCare.Dryer.Program.Shirts15",
"LaundryCare.Dryer.Program.Pillow",
"LaundryCare.Dryer.Program.AntiShrink",
"LaundryCare.Dryer.Program.MyTime.MyDryingTime",
"LaundryCare.Dryer.Program.TimeCold",
"LaundryCare.Dryer.Program.TimeWarm",
"LaundryCare.Dryer.Program.InBasket",
"LaundryCare.Dryer.Program.TimeColdFix.TimeCold20",
"LaundryCare.Dryer.Program.TimeColdFix.TimeCold30",
"LaundryCare.Dryer.Program.TimeColdFix.TimeCold60",
"LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm30",
"LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm40",
"LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm60",
"LaundryCare.Dryer.Program.Dessous",
"Cooking.Common.Program.Hood.Automatic",
"Cooking.Common.Program.Hood.Venting",
"Cooking.Common.Program.Hood.DelayedShutOff",
"Cooking.Oven.Program.HeatingMode.PreHeating",
"Cooking.Oven.Program.HeatingMode.HotAir",
"Cooking.Oven.Program.HeatingMode.HotAirEco",
"Cooking.Oven.Program.HeatingMode.HotAirGrilling",
"Cooking.Oven.Program.HeatingMode.TopBottomHeating",
"Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco",
"Cooking.Oven.Program.HeatingMode.BottomHeating",
"Cooking.Oven.Program.HeatingMode.PizzaSetting",
"Cooking.Oven.Program.HeatingMode.SlowCook",
"Cooking.Oven.Program.HeatingMode.IntensiveHeat",
"Cooking.Oven.Program.HeatingMode.KeepWarm",
"Cooking.Oven.Program.HeatingMode.PreheatOvenware",
"Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial",
"Cooking.Oven.Program.HeatingMode.Desiccation",
"Cooking.Oven.Program.HeatingMode.Defrost",
"Cooking.Oven.Program.HeatingMode.Proof",
"Cooking.Oven.Program.HeatingMode.HotAir30Steam",
"Cooking.Oven.Program.HeatingMode.HotAir60Steam",
"Cooking.Oven.Program.HeatingMode.HotAir80Steam",
"Cooking.Oven.Program.HeatingMode.HotAir100Steam",
"Cooking.Oven.Program.HeatingMode.SabbathProgramme",
"Cooking.Oven.Program.Microwave.90Watt",
"Cooking.Oven.Program.Microwave.180Watt",
"Cooking.Oven.Program.Microwave.360Watt",
"Cooking.Oven.Program.Microwave.600Watt",
"Cooking.Oven.Program.Microwave.900Watt",
"Cooking.Oven.Program.Microwave.1000Watt",
"Cooking.Oven.Program.Microwave.Max",
"Cooking.Oven.Program.HeatingMode.WarmingDrawer",
"LaundryCare.Washer.Program.Cotton",
"LaundryCare.Washer.Program.Cotton.CottonEco",
"LaundryCare.Washer.Program.Cotton.Eco4060",
"LaundryCare.Washer.Program.Cotton.Colour",
"LaundryCare.Washer.Program.EasyCare",
"LaundryCare.Washer.Program.Mix",
"LaundryCare.Washer.Program.Mix.NightWash",
"LaundryCare.Washer.Program.DelicatesSilk",
"LaundryCare.Washer.Program.Wool",
"LaundryCare.Washer.Program.Sensitive",
"LaundryCare.Washer.Program.Auto30",
"LaundryCare.Washer.Program.Auto40",
"LaundryCare.Washer.Program.Auto60",
"LaundryCare.Washer.Program.Chiffon",
"LaundryCare.Washer.Program.Curtains",
"LaundryCare.Washer.Program.DarkWash",
"LaundryCare.Washer.Program.Dessous",
"LaundryCare.Washer.Program.Monsoon",
"LaundryCare.Washer.Program.Outdoor",
"LaundryCare.Washer.Program.PlushToy",
"LaundryCare.Washer.Program.ShirtsBlouses",
"LaundryCare.Washer.Program.SportFitness",
"LaundryCare.Washer.Program.Towels",
"LaundryCare.Washer.Program.WaterProof",
"LaundryCare.Washer.Program.PowerSpeed59",
"LaundryCare.Washer.Program.Super153045.Super15",
"LaundryCare.Washer.Program.Super153045.Super1530",
"LaundryCare.Washer.Program.DownDuvet.Duvet",
"LaundryCare.Washer.Program.Rinse.RinseSpinDrain",
"LaundryCare.Washer.Program.DrumClean",
"LaundryCare.WasherDryer.Program.Cotton",
"LaundryCare.WasherDryer.Program.Cotton.Eco4060",
"LaundryCare.WasherDryer.Program.Mix",
"LaundryCare.WasherDryer.Program.EasyCare",
"LaundryCare.WasherDryer.Program.WashAndDry60",
"LaundryCare.WasherDryer.Program.WashAndDry90",
)
bsh_key_to_translation_key(program.value): cast(ProgramKey, program)
for program in ProgramKey
if program != ProgramKey.UNKNOWN
}
PROGRAMS_TRANSLATION_KEYS_MAP = {
@ -194,11 +31,11 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
SelectEntityDescription(
key=BSH_ACTIVE_PROGRAM,
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
translation_key="active_program",
),
SelectEntityDescription(
key=BSH_SELECTED_PROGRAM,
key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
translation_key="selected_program",
),
)
@ -211,31 +48,12 @@ async def async_setup_entry(
) -> None:
"""Set up the Home Connect select entities."""
def get_entities() -> list[HomeConnectProgramSelectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectProgramSelectEntity] = []
programs_not_found = set()
for device in entry.runtime_data.devices:
if device.appliance.type in APPLIANCES_WITH_PROGRAMS:
with contextlib.suppress(HomeConnectError):
programs = device.appliance.get_programs_available()
if programs:
for program in programs.copy():
if program not in PROGRAMS_TRANSLATION_KEYS_MAP:
programs.remove(program)
if program not in programs_not_found:
_LOGGER.info(
'The program "%s" is not part of the official Home Connect API specification',
program,
)
programs_not_found.add(program)
entities.extend(
HomeConnectProgramSelectEntity(device, programs, desc)
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
)
return entities
async_add_entities(await hass.async_add_executor_job(get_entities), True)
async_add_entities(
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
for appliance in entry.runtime_data.data.values()
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
)
class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
@ -243,48 +61,45 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
def __init__(
self,
device: HomeConnectDevice,
programs: list[str],
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
desc: SelectEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(
device,
coordinator,
appliance,
desc,
)
self._attr_options = [
PROGRAMS_TRANSLATION_KEYS_MAP[program] for program in programs
PROGRAMS_TRANSLATION_KEYS_MAP[program.key]
for program in appliance.programs
if program.key != ProgramKey.UNKNOWN
]
self.start_on_select = desc.key == BSH_ACTIVE_PROGRAM
self.start_on_select = desc.key == EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM
self._attr_current_option = None
async def async_update(self) -> None:
"""Update the program selection status."""
program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE)
if not program:
program_translation_key = None
elif not (
program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program)
):
_LOGGER.debug(
'The program "%s" is not part of the official Home Connect API specification',
program,
)
self._attr_current_option = program_translation_key
_LOGGER.debug("Updated, new program: %s", self._attr_current_option)
def update_native_value(self) -> None:
"""Set the program value."""
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
self._attr_current_option = (
PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value))
if event
else None
)
async def async_select_option(self, option: str) -> None:
"""Select new program."""
bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option]
_LOGGER.debug(
"Starting program: %s" if self.start_on_select else "Selecting program: %s",
bsh_key,
)
if self.start_on_select:
target = self.device.appliance.start_program
else:
target = self.device.appliance.select_program
program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option]
try:
await self.hass.async_add_executor_job(target, bsh_key)
if self.start_on_select:
await self.coordinator.client.start_program(
self.appliance.info.ha_id, program_key=program_key
)
else:
await self.coordinator.client.set_selected_program(
self.appliance.info.ha_id, program_key=program_key
)
except HomeConnectError as err:
if self.start_on_select:
translation_key = "start_program"
@ -295,7 +110,6 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
translation_key=translation_key,
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key,
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value,
},
) from err
self.async_entity_update()

View File

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

View File

@ -26,64 +26,67 @@
"message": "Appliance for device ID {device_id} not found"
},
"turn_on_light": {
"message": "Error turning on {entity_id}: {description}"
"message": "Error turning on {entity_id}: {error}"
},
"turn_off_light": {
"message": "Error turning off {entity_id}: {description}"
"message": "Error turning off {entity_id}: {error}"
},
"set_light_brightness": {
"message": "Error setting brightness of {entity_id}: {description}"
"message": "Error setting brightness of {entity_id}: {error}"
},
"select_light_custom_color": {
"message": "Error selecting custom color of {entity_id}: {description}"
"message": "Error selecting custom color of {entity_id}: {error}"
},
"set_light_color": {
"message": "Error setting color of {entity_id}: {description}"
"message": "Error setting color of {entity_id}: {error}"
},
"set_setting_entity": {
"message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}"
"message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {error}"
},
"set_setting": {
"message": "Error assigning the value \"{value}\" to the setting \"{key}\": {description}"
"message": "Error assigning the value \"{value}\" to the setting \"{key}\": {error}"
},
"turn_on": {
"message": "Error turning on {entity_id} ({key}): {description}"
"message": "Error turning on {entity_id} ({key}): {error}"
},
"turn_off": {
"message": "Error turning off {entity_id} ({key}): {description}"
"message": "Error turning off {entity_id} ({key}): {error}"
},
"select_program": {
"message": "Error selecting program {program}: {description}"
"message": "Error selecting program {program}: {error}"
},
"start_program": {
"message": "Error starting program {program}: {description}"
"message": "Error starting program {program}: {error}"
},
"pause_program": {
"message": "Error pausing program: {description}"
"message": "Error pausing program: {error}"
},
"stop_program": {
"message": "Error stopping program: {description}"
"message": "Error stopping program: {error}"
},
"set_options_active_program": {
"message": "Error setting options for the active program: {description}"
"message": "Error setting options for the active program: {error}"
},
"set_options_selected_program": {
"message": "Error setting options for the selected program: {description}"
"message": "Error setting options for the selected program: {error}"
},
"execute_command": {
"message": "Error executing command {command}: {description}"
"message": "Error executing command {command}: {error}"
},
"power_on": {
"message": "Error turning on {appliance_name}: {description}"
"message": "Error turning on {appliance_name}: {error}"
},
"power_off": {
"message": "Error turning off {appliance_name} with value \"{value}\": {description}"
"message": "Error turning off {appliance_name} with value \"{value}\": {error}"
},
"turn_off_not_supported": {
"message": "{appliance_name} does not support turning off or entering standby mode."
},
"unable_to_retrieve_turn_off": {
"message": "Unable to turn off {appliance_name} because its support for turning off or entering standby mode could not be determined."
},
"fetch_api_error": {
"message": "Error obtaining data from the API: {error}"
}
},
"issues": {

View File

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

View File

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

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
aiohasupervisor==0.2.2b6
# homeassistant.components.home_connect
aiohomeconnect==0.12.1
# homeassistant.components.homekit_controller
aiohomekit==3.2.7
@ -1148,9 +1151,6 @@ home-assistant-frontend==20250129.0
# homeassistant.components.conversation
home-assistant-intents==2025.1.28
# homeassistant.components.home_connect
homeconnect==0.8.0
# homeassistant.components.homematicip_cloud
homematicip==1.1.7

View File

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

View File

@ -1,18 +1,32 @@
"""Test fixtures for home_connect."""
from collections.abc import Awaitable, Callable, Generator
import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable
import copy
import time
from typing import Any
from unittest.mock import MagicMock, Mock, PropertyMock, patch
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from homeconnect.api import HomeConnectAppliance, HomeConnectError
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import (
ArrayOfAvailablePrograms,
ArrayOfEvents,
ArrayOfHomeAppliances,
ArrayOfSettings,
ArrayOfStatus,
Event,
EventKey,
EventMessage,
EventType,
Option,
)
from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError
import pytest
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.home_connect import update_all_devices
from homeassistant.components.home_connect.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@ -20,12 +34,17 @@ from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_json_object_fixture
MOCK_APPLIANCES_PROPERTIES = {
x["name"]: x
for x in load_json_object_fixture("home_connect/appliances.json")["data"][
"homeappliances"
]
}
MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict(
load_json_object_fixture("home_connect/appliances.json")["data"]
)
MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture(
"home_connect/programs-available.json"
)
MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json")
MOCK_STATUS = ArrayOfStatus.from_dict(
load_json_object_fixture("home_connect/status.json")["data"]
)
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
@ -102,32 +121,23 @@ def platforms() -> list[Platform]:
return []
async def bypass_throttle(hass: HomeAssistant, config_entry: MockConfigEntry):
"""Add kwarg to disable throttle."""
await update_all_devices(hass, config_entry, no_throttle=True)
@pytest.fixture(name="bypass_throttle")
def mock_bypass_throttle() -> Generator[None]:
"""Fixture to bypass the throttle decorator in __init__."""
with patch(
"homeassistant.components.home_connect.update_all_devices",
side_effect=bypass_throttle,
):
yield
@pytest.fixture(name="integration_setup")
async def mock_integration_setup(
hass: HomeAssistant,
platforms: list[Platform],
config_entry: MockConfigEntry,
) -> Callable[[], Awaitable[bool]]:
) -> Callable[[MagicMock], Awaitable[bool]]:
"""Fixture to set up the integration."""
config_entry.add_to_hass(hass)
async def run() -> bool:
with patch("homeassistant.components.home_connect.PLATFORMS", platforms):
async def run(client: MagicMock) -> bool:
with (
patch("homeassistant.components.home_connect.PLATFORMS", platforms),
patch(
"homeassistant.components.home_connect.HomeConnectClient"
) as client_mock,
):
client_mock.return_value = client
result = await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return result
@ -135,125 +145,205 @@ async def mock_integration_setup(
return run
@pytest.fixture(name="get_appliances")
def mock_get_appliances() -> Generator[MagicMock]:
"""Mock ConfigEntryAuth parent (HomeAssistantAPI) method."""
with patch(
"homeassistant.components.home_connect.api.ConfigEntryAuth.get_appliances",
) as mock:
yield mock
def _get_set_program_side_effect(
event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey
):
"""Set program side effect."""
async def set_program_side_effect(ha_id: str, *_, **kwargs) -> None:
await event_queue.put(
[
EventMessage(
ha_id,
EventType.NOTIFY,
ArrayOfEvents(
[
Event(
key=event_key,
raw_key=event_key.value,
timestamp=0,
level="",
handling="",
value=str(kwargs["program_key"]),
),
*[
Event(
key=(option_event := EventKey(option.key)),
raw_key=option_event.value,
timestamp=0,
level="",
handling="",
value=str(option.key),
)
for option in cast(
list[Option], kwargs.get("options", [])
)
],
]
),
),
]
)
return set_program_side_effect
@pytest.fixture(name="appliance")
def mock_appliance(request: pytest.FixtureRequest) -> MagicMock:
def _get_set_key_value_side_effect(
event_queue: asyncio.Queue[list[EventMessage]], parameter_key: str
):
"""Set program options side effect."""
async def set_key_value_side_effect(ha_id: str, *_, **kwargs) -> None:
event_key = EventKey(kwargs[parameter_key])
await event_queue.put(
[
EventMessage(
ha_id,
EventType.NOTIFY,
ArrayOfEvents(
[
Event(
key=event_key,
raw_key=event_key.value,
timestamp=0,
level="",
handling="",
value=kwargs["value"],
)
]
),
),
]
)
return set_key_value_side_effect
async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePrograms:
"""Get available programs."""
appliance_type = next(
appliance
for appliance in MOCK_APPLIANCES.homeappliances
if appliance.ha_id == ha_id
).type
if appliance_type not in MOCK_PROGRAMS:
raise HomeConnectApiError("error.key", "error description")
return ArrayOfAvailablePrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"])
async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings:
"""Get settings."""
return ArrayOfSettings.from_dict(
MOCK_SETTINGS.get(
next(
appliance
for appliance in MOCK_APPLIANCES.homeappliances
if appliance.ha_id == ha_id
).type,
{},
).get("data", {"settings": []})
)
@pytest.fixture(name="client")
def mock_client(request: pytest.FixtureRequest) -> MagicMock:
"""Fixture to mock Client from HomeConnect."""
mock = MagicMock(
autospec=HomeConnectClient,
)
event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue()
async def add_events(events: list[EventMessage]) -> None:
await event_queue.put(events)
mock.add_events = add_events
async def stream_all_events() -> AsyncGenerator[EventMessage]:
"""Mock stream_all_events."""
while True:
for event in await event_queue.get():
yield event
mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES)
mock.stream_all_events = stream_all_events
mock.start_program = AsyncMock(
side_effect=_get_set_program_side_effect(
event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM
)
)
mock.set_selected_program = AsyncMock(
side_effect=_get_set_program_side_effect(
event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM
),
)
mock.set_active_program_option = AsyncMock(
side_effect=_get_set_key_value_side_effect(event_queue, "option_key"),
)
mock.set_selected_program_option = AsyncMock(
side_effect=_get_set_key_value_side_effect(event_queue, "option_key"),
)
mock.set_setting = AsyncMock(
side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"),
)
mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect)
mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS))
mock.get_available_programs = AsyncMock(
side_effect=_get_available_programs_side_effect
)
mock.put_command = AsyncMock()
mock.side_effect = mock
return mock
@pytest.fixture(name="client_with_exception")
def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock:
"""Fixture to mock Client from HomeConnect that raise exceptions."""
mock = MagicMock(
autospec=HomeConnectClient,
)
exception = HomeConnectError()
if hasattr(request, "param") and request.param:
exception = request.param
event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue()
async def stream_all_events() -> AsyncGenerator[EventMessage]:
"""Mock stream_all_events."""
while True:
for event in await event_queue.get():
yield event
mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES)
mock.stream_all_events = stream_all_events
mock.start_program = AsyncMock(side_effect=exception)
mock.stop_program = AsyncMock(side_effect=exception)
mock.get_available_programs = AsyncMock(side_effect=exception)
mock.set_selected_program = AsyncMock(side_effect=exception)
mock.set_active_program_option = AsyncMock(side_effect=exception)
mock.set_selected_program_option = AsyncMock(side_effect=exception)
mock.set_setting = AsyncMock(side_effect=exception)
mock.get_settings = AsyncMock(side_effect=exception)
mock.get_setting = AsyncMock(side_effect=exception)
mock.get_status = AsyncMock(side_effect=exception)
mock.get_available_programs = AsyncMock(side_effect=exception)
mock.put_command = AsyncMock(side_effect=exception)
return mock
@pytest.fixture(name="appliance_ha_id")
def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str:
"""Fixture to mock Appliance."""
app = "Washer"
if hasattr(request, "param") and request.param:
app = request.param
mock = MagicMock(
autospec=HomeConnectAppliance,
**MOCK_APPLIANCES_PROPERTIES.get(app),
)
mock.name = app
type(mock).status = PropertyMock(return_value={})
mock.get.return_value = {}
mock.get_programs_available.return_value = []
mock.get_status.return_value = {}
mock.get_settings.return_value = {}
return mock
@pytest.fixture(name="problematic_appliance")
def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock:
"""Fixture to mock a problematic Appliance."""
app = "Washer"
if hasattr(request, "param") and request.param:
app = request.param
mock = Mock(
autospec=HomeConnectAppliance,
**MOCK_APPLIANCES_PROPERTIES.get(app),
)
mock.name = app
type(mock).status = PropertyMock(return_value={})
mock.get.side_effect = HomeConnectError
mock.get_programs_active.side_effect = HomeConnectError
mock.get_programs_available.side_effect = HomeConnectError
mock.start_program.side_effect = HomeConnectError
mock.select_program.side_effect = HomeConnectError
mock.pause_program.side_effect = HomeConnectError
mock.stop_program.side_effect = HomeConnectError
mock.set_options_active_program.side_effect = HomeConnectError
mock.set_options_selected_program.side_effect = HomeConnectError
mock.get_status.side_effect = HomeConnectError
mock.get_settings.side_effect = HomeConnectError
mock.set_setting.side_effect = HomeConnectError
mock.set_setting.side_effect = HomeConnectError
mock.execute_command.side_effect = HomeConnectError
return mock
def get_all_appliances():
"""Return a list of `HomeConnectAppliance` instances for all appliances."""
appliances = {}
data = load_json_object_fixture("home_connect/appliances.json").get("data")
programs_active = load_json_object_fixture("home_connect/programs-active.json")
programs_available = load_json_object_fixture(
"home_connect/programs-available.json"
)
def listen_callback(mock, callback):
callback["callback"](mock)
for home_appliance in data["homeappliances"]:
api_status = load_json_object_fixture("home_connect/status.json")
api_settings = load_json_object_fixture("home_connect/settings.json")
ha_id = home_appliance["haId"]
ha_type = home_appliance["type"]
appliance = MagicMock(spec=HomeConnectAppliance, **home_appliance)
appliance.name = home_appliance["name"]
appliance.listen_events.side_effect = (
lambda app=appliance, **x: listen_callback(app, x)
)
appliance.get_programs_active.return_value = programs_active.get(
ha_type, {}
).get("data", {})
appliance.get_programs_available.return_value = [
program["key"]
for program in programs_available.get(ha_type, {})
.get("data", {})
.get("programs", [])
]
appliance.get_status.return_value = HomeConnectAppliance.json2dict(
api_status.get("data", {}).get("status", [])
)
appliance.get_settings.return_value = HomeConnectAppliance.json2dict(
api_settings.get(ha_type, {}).get("data", {}).get("settings", [])
)
setattr(appliance, "status", {})
appliance.status.update(appliance.get_status.return_value)
appliance.status.update(appliance.get_settings.return_value)
appliance.set_setting.side_effect = (
lambda x, y, appliance=appliance: appliance.status.update({x: {"value": y}})
)
appliance.start_program.side_effect = (
lambda x, appliance=appliance: appliance.status.update(
{"BSH.Common.Root.ActiveProgram": {"value": x}}
)
)
appliance.stop_program.side_effect = (
lambda appliance=appliance: appliance.status.update(
{"BSH.Common.Root.ActiveProgram": {}}
)
)
appliances[ha_id] = appliance
return list(appliances.values())
for appliance in MOCK_APPLIANCES.homeappliances:
if appliance.type == app:
return appliance.ha_id
raise ValueError(f"Appliance {app} not found")

View File

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

View File

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

View File

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

View File

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

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."""
from collections.abc import Awaitable, Callable
from unittest.mock import MagicMock, Mock
from unittest.mock import MagicMock
from homeconnect.api import HomeConnectError
import pytest
from syrupy import SnapshotAssertion
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.home_connect.const import DOMAIN
from homeassistant.components.home_connect.diagnostics import (
@ -16,43 +14,37 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import get_all_appliances
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("bypass_throttle")
async def test_async_get_config_entry_diagnostics(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
client: MagicMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test config entry diagnostics."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot
@pytest.mark.usefixtures("bypass_throttle")
async def test_async_get_device_diagnostics(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
client: MagicMock,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test device config entry diagnostics."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
device = device_registry.async_get_or_create(
@ -61,69 +53,3 @@ async def test_async_get_device_diagnostics(
)
assert await async_get_device_diagnostics(hass, config_entry, device) == snapshot
@pytest.mark.usefixtures("bypass_throttle")
async def test_async_device_diagnostics_not_found(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test device config entry diagnostics."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, "Random-Device-ID")},
)
with pytest.raises(ValueError):
await async_get_device_diagnostics(hass, config_entry, device)
@pytest.mark.parametrize(
("api_error", "expected_connection_status"),
[
(HomeConnectError(), "unknown"),
(
HomeConnectError(
{
"key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed",
}
),
"offline",
),
],
)
@pytest.mark.usefixtures("bypass_throttle")
async def test_async_device_diagnostics_api_error(
api_error: HomeConnectError,
expected_connection_status: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
appliance: Mock,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test device config entry diagnostics."""
appliance.get_programs_available.side_effect = api_error
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, appliance.haId)},
)
diagnostics = await async_get_device_diagnostics(hass, config_entry, device)
assert diagnostics["programs"] is None

View File

@ -2,27 +2,18 @@
from collections.abc import Awaitable, Callable
from typing import Any
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from aiohomeconnect.const import OAUTH2_TOKEN
from aiohomeconnect.model import SettingKey, StatusKey
from aiohomeconnect.model.error import HomeConnectError
import pytest
from requests import HTTPError
import requests_mock
import respx
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.home_connect import (
SCAN_INTERVAL,
bsh_key_to_translation_key,
)
from homeassistant.components.home_connect.const import (
BSH_CHILD_LOCK_STATE,
BSH_OPERATION_STATE,
BSH_POWER_STATE,
BSH_REMOTE_START_ALLOWANCE_STATE,
COOKING_LIGHTING,
DOMAIN,
OAUTH2_TOKEN,
)
from homeassistant.components.home_connect.const import DOMAIN
from homeassistant.components.home_connect.utils import bsh_key_to_translation_key
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
@ -39,7 +30,6 @@ from .conftest import (
FAKE_ACCESS_TOKEN,
FAKE_REFRESH_TOKEN,
SERVER_ACCESS_TOKEN,
get_all_appliances,
)
from tests.common import MockConfigEntry
@ -126,28 +116,26 @@ SERVICE_PROGRAM_CALL_PARAMS = [
]
SERVICE_APPLIANCE_METHOD_MAPPING = {
"set_option_active": "set_options_active_program",
"set_option_selected": "set_options_selected_program",
"set_option_active": "set_active_program_option",
"set_option_selected": "set_selected_program_option",
"change_setting": "set_setting",
"pause_program": "execute_command",
"resume_program": "execute_command",
"select_program": "select_program",
"pause_program": "put_command",
"resume_program": "put_command",
"select_program": "set_selected_program",
"start_program": "start_program",
}
@pytest.mark.usefixtures("bypass_throttle")
async def test_api_setup(
async def test_entry_setup(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
client: MagicMock,
) -> None:
"""Test setup and unload."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(config_entry.entry_id)
@ -156,72 +144,60 @@ async def test_api_setup(
assert config_entry.state == ConfigEntryState.NOT_LOADED
async def test_update_throttle(
appliance: Mock,
freezer: FrozenDateTimeFactory,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
) -> None:
"""Test to check Throttle functionality."""
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED
get_appliances_call_count = get_appliances.call_count
# First re-load after 1 minute is not blocked.
assert await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state == ConfigEntryState.NOT_LOADED
freezer.tick(SCAN_INTERVAL.seconds + 0.1)
assert await hass.config_entries.async_setup(config_entry.entry_id)
assert get_appliances.call_count == get_appliances_call_count + 1
# Second re-load is blocked by Throttle.
assert await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state == ConfigEntryState.NOT_LOADED
freezer.tick(SCAN_INTERVAL.seconds - 0.1)
assert await hass.config_entries.async_setup(config_entry.entry_id)
assert get_appliances.call_count == get_appliances_call_count + 1
@pytest.mark.usefixtures("bypass_throttle")
async def test_exception_handling(
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry,
setup_credentials: None,
get_appliances: MagicMock,
problematic_appliance: Mock,
client_with_exception: MagicMock,
) -> None:
"""Test exception handling."""
get_appliances.return_value = [problematic_appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.LOADED
@pytest.mark.parametrize("token_expiration_time", [12345])
@pytest.mark.usefixtures("bypass_throttle")
@respx.mock
async def test_token_refresh_success(
integration_setup: Callable[[], Awaitable[bool]],
hass: HomeAssistant,
platforms: list[Platform],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
requests_mock: requests_mock.Mocker,
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test where token is expired and the refresh attempt succeeds."""
assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN
requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN)
requests_mock.get("/api/homeappliances", json={"data": {"homeappliances": []}})
aioclient_mock.post(
OAUTH2_TOKEN,
json=SERVER_ACCESS_TOKEN,
)
assert await integration_setup()
appliances = client.get_home_appliances.return_value
async def mock_get_home_appliances():
await client._auth.async_get_access_token()
return appliances
client.get_home_appliances.return_value = None
client.get_home_appliances.side_effect = mock_get_home_appliances
def init_side_effect(auth) -> MagicMock:
client._auth = auth
return client
assert config_entry.state == ConfigEntryState.NOT_LOADED
with (
patch("homeassistant.components.home_connect.PLATFORMS", platforms),
patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock,
):
client_mock.side_effect = MagicMock(side_effect=init_side_effect)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.LOADED
# Verify token request
@ -240,45 +216,43 @@ async def test_token_refresh_success(
)
@pytest.mark.usefixtures("bypass_throttle")
async def test_http_error(
async def test_client_error(
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
client_with_exception: MagicMock,
) -> None:
"""Test HTTP errors during setup integration."""
get_appliances.side_effect = HTTPError(response=MagicMock())
"""Test client errors during setup integration."""
client_with_exception.get_home_appliances.return_value = None
client_with_exception.get_home_appliances.side_effect = HomeConnectError()
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED
assert get_appliances.call_count == 1
assert not await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.SETUP_RETRY
assert client_with_exception.get_home_appliances.call_count == 1
@pytest.mark.parametrize(
"service_call",
SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
)
@pytest.mark.usefixtures("bypass_throttle")
async def test_services(
service_call: list[dict[str, Any]],
service_call: dict[str, Any],
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
appliance: Mock,
client: MagicMock,
appliance_ha_id: str,
) -> None:
"""Create and test services."""
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, appliance.haId)},
identifiers={(DOMAIN, appliance_ha_id)},
)
service_name = service_call["service"]
@ -286,8 +260,7 @@ async def test_services(
await hass.services.async_call(**service_call)
await hass.async_block_till_done()
assert (
getattr(appliance, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count
== 1
getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1
)
@ -295,26 +268,24 @@ async def test_services(
"service_call",
SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
)
@pytest.mark.usefixtures("bypass_throttle")
async def test_services_exception(
service_call: list[dict[str, Any]],
service_call: dict[str, Any],
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
problematic_appliance: Mock,
client_with_exception: MagicMock,
appliance_ha_id: str,
device_registry: dr.DeviceRegistry,
) -> None:
"""Raise a HomeAssistantError when there is an API error."""
get_appliances.return_value = [problematic_appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.LOADED
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, problematic_appliance.haId)},
identifiers={(DOMAIN, appliance_ha_id)},
)
service_call["service_data"]["device_id"] = device_entry.id
@ -323,25 +294,47 @@ async def test_services_exception(
await hass.services.async_call(**service_call)
@pytest.mark.usefixtures("bypass_throttle")
async def test_services_appliance_not_found(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
appliance: Mock,
client: MagicMock,
device_registry: dr.DeviceRegistry,
) -> None:
"""Raise a ServiceValidationError when device id does not match."""
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
service_call = SERVICE_KV_CALL_PARAMS[0]
service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS"
with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"):
await hass.services.async_call(**service_call)
unrelated_config_entry = MockConfigEntry(
domain="TEST",
)
unrelated_config_entry.add_to_hass(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=unrelated_config_entry.entry_id,
identifiers={("RANDOM", "ABCD")},
)
service_call["service_data"]["device_id"] = device_entry.id
with pytest.raises(
ServiceValidationError, match=r"Home Connect config entry.*not found"
):
await hass.services.async_call(**service_call)
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("RANDOM", "ABCD")},
)
service_call["service_data"]["device_id"] = device_entry.id
with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"):
await hass.services.async_call(**service_call)
@ -351,7 +344,7 @@ async def test_entity_migration(
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
config_entry_v1_1: MockConfigEntry,
appliance: Mock,
appliance_ha_id: str,
platforms: list[Platform],
) -> None:
"""Test entity migration."""
@ -360,34 +353,39 @@ async def test_entity_migration(
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry_v1_1.entry_id,
identifiers={(DOMAIN, appliance.haId)},
identifiers={(DOMAIN, appliance_ha_id)},
)
test_entities = [
(
SENSOR_DOMAIN,
"Operation State",
BSH_OPERATION_STATE,
StatusKey.BSH_COMMON_OPERATION_STATE,
),
(
SWITCH_DOMAIN,
"ChildLock",
BSH_CHILD_LOCK_STATE,
SettingKey.BSH_COMMON_CHILD_LOCK,
),
(
SWITCH_DOMAIN,
"Power",
BSH_POWER_STATE,
SettingKey.BSH_COMMON_POWER_STATE,
),
(
BINARY_SENSOR_DOMAIN,
"Remote Start",
BSH_REMOTE_START_ALLOWANCE_STATE,
StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED,
),
(
LIGHT_DOMAIN,
"Light",
COOKING_LIGHTING,
SettingKey.COOKING_COMMON_LIGHTING,
),
( # An already migrated entity
SWITCH_DOMAIN,
SettingKey.REFRIGERATION_COMMON_VACATION_MODE,
SettingKey.REFRIGERATION_COMMON_VACATION_MODE,
),
]
@ -395,7 +393,7 @@ async def test_entity_migration(
entity_registry.async_get_or_create(
domain,
DOMAIN,
f"{appliance.haId}-{old_unique_id_suffix}",
f"{appliance_ha_id}-{old_unique_id_suffix}",
device_id=device_entry.id,
config_entry=config_entry_v1_1,
)
@ -406,7 +404,7 @@ async def test_entity_migration(
for domain, _, expected_unique_id_suffix in test_entities:
assert entity_registry.async_get_entity_id(
domain, DOMAIN, f"{appliance.haId}-{expected_unique_id_suffix}"
domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}"
)
assert config_entry_v1_1.minor_version == 2

View File

@ -1,20 +1,24 @@
"""Tests for home_connect light entities."""
from collections.abc import Awaitable, Callable, Generator
from unittest.mock import MagicMock, Mock
from collections.abc import Awaitable, Callable
from typing import Any
from unittest.mock import MagicMock, call
from homeconnect.api import HomeConnectAppliance, HomeConnectError
from aiohomeconnect.model import (
ArrayOfEvents,
ArrayOfSettings,
Event,
EventKey,
EventMessage,
EventType,
GetSetting,
SettingKey,
)
from aiohomeconnect.model.error import HomeConnectError
import pytest
from homeassistant.components.home_connect.const import (
BSH_AMBIENT_LIGHT_BRIGHTNESS,
BSH_AMBIENT_LIGHT_COLOR,
BSH_AMBIENT_LIGHT_CUSTOM_COLOR,
BSH_AMBIENT_LIGHT_ENABLED,
COOKING_LIGHTING,
COOKING_LIGHTING_BRIGHTNESS,
REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS,
REFRIGERATION_EXTERNAL_LIGHT_POWER,
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
)
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.config_entries import ConfigEntryState
@ -23,26 +27,15 @@ from homeassistant.const import (
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import get_all_appliances
from tests.common import MockConfigEntry, load_json_object_fixture
from tests.common import MockConfigEntry
TEST_HC_APP = "Hood"
SETTINGS_STATUS = {
setting.pop("key"): setting
for setting in load_json_object_fixture("home_connect/settings.json")
.get(TEST_HC_APP)
.get("data")
.get("settings")
}
@pytest.fixture
def platforms() -> list[str]:
@ -51,29 +44,31 @@ def platforms() -> list[str]:
async def test_light(
bypass_throttle: Generator[None],
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: Mock,
client: MagicMock,
) -> None:
"""Test switch entities."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
@pytest.mark.parametrize(
("entity_id", "status", "service", "service_data", "state", "appliance"),
(
"entity_id",
"set_settings_args",
"service",
"exprected_attributes",
"state",
"appliance_ha_id",
),
[
(
"light.hood_functional_light",
{
COOKING_LIGHTING: {
"value": True,
},
SettingKey.COOKING_COMMON_LIGHTING: True,
},
SERVICE_TURN_ON,
{},
@ -83,58 +78,18 @@ async def test_light(
(
"light.hood_functional_light",
{
COOKING_LIGHTING: {
"value": True,
},
COOKING_LIGHTING_BRIGHTNESS: {"value": 70},
SettingKey.COOKING_COMMON_LIGHTING: True,
SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80,
},
SERVICE_TURN_ON,
{"brightness": 200},
{"brightness": 199},
STATE_ON,
"Hood",
),
(
"light.hood_functional_light",
{
COOKING_LIGHTING: {"value": False},
COOKING_LIGHTING_BRIGHTNESS: {"value": 70},
},
SERVICE_TURN_OFF,
{},
STATE_OFF,
"Hood",
),
(
"light.hood_functional_light",
{
COOKING_LIGHTING: {
"value": None,
},
COOKING_LIGHTING_BRIGHTNESS: None,
},
SERVICE_TURN_ON,
{},
STATE_UNKNOWN,
"Hood",
),
(
"light.hood_ambient_light",
{
BSH_AMBIENT_LIGHT_ENABLED: {
"value": True,
},
BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70},
},
SERVICE_TURN_ON,
{"brightness": 200},
STATE_ON,
"Hood",
),
(
"light.hood_ambient_light",
{
BSH_AMBIENT_LIGHT_ENABLED: {"value": False},
BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70},
SettingKey.COOKING_COMMON_LIGHTING: False,
},
SERVICE_TURN_OFF,
{},
@ -144,8 +99,28 @@ async def test_light(
(
"light.hood_ambient_light",
{
BSH_AMBIENT_LIGHT_ENABLED: {"value": True},
BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {},
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80,
},
SERVICE_TURN_ON,
{"brightness": 199},
STATE_ON,
"Hood",
),
(
"light.hood_ambient_light",
{
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: False,
},
SERVICE_TURN_OFF,
{},
STATE_OFF,
"Hood",
),
(
"light.hood_ambient_light",
{
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
},
SERVICE_TURN_ON,
{},
@ -155,15 +130,28 @@ async def test_light(
(
"light.hood_ambient_light",
{
BSH_AMBIENT_LIGHT_ENABLED: {"value": True},
BSH_AMBIENT_LIGHT_COLOR: {
"value": "",
},
BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {},
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00",
},
SERVICE_TURN_ON,
{
"rgb_color": [255, 255, 0],
"rgb_color": (255, 255, 0),
},
STATE_ON,
"Hood",
),
(
"light.hood_ambient_light",
{
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc",
},
SERVICE_TURN_ON,
{
"hs_color": (255.484, 15.196),
"brightness": 199,
},
STATE_ON,
"Hood",
@ -171,10 +159,7 @@ async def test_light(
(
"light.fridgefreezer_external_light",
{
REFRIGERATION_EXTERNAL_LIGHT_POWER: {
"value": True,
},
REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS: {"value": 75},
SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER: True,
},
SERVICE_TURN_ON,
{},
@ -182,167 +167,268 @@ async def test_light(
"FridgeFreezer",
),
],
indirect=["appliance"],
indirect=["appliance_ha_id"],
)
async def test_light_functionality(
entity_id: str,
status: dict,
set_settings_args: dict[SettingKey, Any],
service: str,
service_data: dict,
exprected_attributes: dict[str, Any],
state: str,
appliance: Mock,
bypass_throttle: Generator[None],
appliance_ha_id: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
client: MagicMock,
) -> None:
"""Test light functionality."""
appliance.status.update(
HomeConnectAppliance.json2dict(
load_json_object_fixture("home_connect/settings.json")
.get(appliance.name)
.get("data")
.get("settings")
)
)
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update(status)
service_data = exprected_attributes.copy()
service_data["entity_id"] = entity_id
await hass.services.async_call(
LIGHT_DOMAIN,
service,
service_data,
blocking=True,
{key: value for key, value in service_data.items() if value is not None},
)
assert hass.states.is_state(entity_id, state)
await hass.async_block_till_done()
client.set_setting.assert_has_calls(
[
call(appliance_ha_id, setting_key=setting_key, value=value)
for setting_key, value in set_settings_args.items()
]
)
entity_state = hass.states.get(entity_id)
assert entity_state is not None
assert entity_state.state == state
for key, value in exprected_attributes.items():
assert entity_state.attributes[key] == value
@pytest.mark.parametrize(
(
"entity_id",
"status",
"events",
"appliance_ha_id",
),
[
(
"light.hood_ambient_light",
{
EventKey.BSH_COMMON_SETTING_AMBIENT_LIGHT_COLOR: "BSH.Common.EnumType.AmbientLightColor.Color1",
},
"Hood",
),
],
indirect=["appliance_ha_id"],
)
async def test_light_color_different_than_custom(
entity_id: str,
events: dict[EventKey, Any],
appliance_ha_id: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test that light color attributes are not set if color is different than custom."""
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
"rgb_color": (255, 255, 0),
"entity_id": entity_id,
},
)
await hass.async_block_till_done()
entity_state = hass.states.get(entity_id)
assert entity_state is not None
assert entity_state.state == STATE_ON
assert entity_state.attributes["rgb_color"] is not None
assert entity_state.attributes["hs_color"] is not None
await client.add_events(
[
EventMessage(
appliance_ha_id,
EventType.NOTIFY,
ArrayOfEvents(
[
Event(
key=event_key,
raw_key=event_key.value,
timestamp=0,
level="",
handling="",
value=value,
)
for event_key, value in events.items()
]
),
)
]
)
await hass.async_block_till_done()
entity_state = hass.states.get(entity_id)
assert entity_state is not None
assert entity_state.state == STATE_ON
assert entity_state.attributes["rgb_color"] is None
assert entity_state.attributes["hs_color"] is None
@pytest.mark.parametrize(
(
"entity_id",
"setting",
"service",
"service_data",
"mock_attr",
"attr_side_effect",
"problematic_appliance",
"exception_match",
),
[
(
"light.hood_functional_light",
{
COOKING_LIGHTING: {
"value": False,
},
SettingKey.COOKING_COMMON_LIGHTING: True,
},
SERVICE_TURN_ON,
{},
"set_setting",
[HomeConnectError, HomeConnectError],
"Hood",
r"Error.*turn.*on.*",
),
(
"light.hood_functional_light",
{
COOKING_LIGHTING: {
"value": True,
},
COOKING_LIGHTING_BRIGHTNESS: {"value": 70},
SettingKey.COOKING_COMMON_LIGHTING: True,
SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70,
},
SERVICE_TURN_ON,
{"brightness": 200},
"set_setting",
[HomeConnectError, HomeConnectError],
"Hood",
r"Error.*turn.*on.*",
),
(
"light.hood_functional_light",
{
COOKING_LIGHTING: {"value": False},
SettingKey.COOKING_COMMON_LIGHTING: False,
},
SERVICE_TURN_OFF,
{},
"set_setting",
[HomeConnectError, HomeConnectError],
"Hood",
r"Error.*turn.*off.*",
),
(
"light.hood_ambient_light",
{
BSH_AMBIENT_LIGHT_ENABLED: {
"value": True,
},
BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70},
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70,
},
SERVICE_TURN_ON,
{},
"set_setting",
[HomeConnectError, HomeConnectError],
"Hood",
r"Error.*turn.*on.*",
),
(
"light.hood_ambient_light",
{
BSH_AMBIENT_LIGHT_ENABLED: {
"value": True,
},
BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70},
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70,
},
SERVICE_TURN_ON,
{"brightness": 200},
"set_setting",
[HomeConnectError, None, HomeConnectError],
"Hood",
r"Error.*set.*brightness.*",
),
(
"light.hood_ambient_light",
{
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: 70,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00",
},
SERVICE_TURN_ON,
{"rgb_color": (255, 255, 0)},
[HomeConnectError, None, HomeConnectError],
r"Error.*select.*custom color.*",
),
(
"light.hood_ambient_light",
{
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00",
},
SERVICE_TURN_ON,
{"rgb_color": (255, 255, 0)},
[HomeConnectError, None, None, HomeConnectError],
r"Error.*set.*color.*",
),
(
"light.hood_ambient_light",
{
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc",
},
SERVICE_TURN_ON,
{
"hs_color": (255.484, 15.196),
"brightness": 199,
},
[HomeConnectError, None, None, HomeConnectError],
r"Error.*set.*color.*",
),
],
indirect=["problematic_appliance"],
)
async def test_switch_exception_handling(
async def test_light_exception_handling(
entity_id: str,
status: dict,
setting: dict[SettingKey, dict[str, Any]],
service: str,
service_data: dict,
mock_attr: str,
attr_side_effect: list,
problematic_appliance: Mock,
attr_side_effect: list[type[HomeConnectError] | None],
exception_match: str,
bypass_throttle: Generator[None],
hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry,
setup_credentials: None,
get_appliances: MagicMock,
client_with_exception: MagicMock,
) -> None:
"""Test light exception handling."""
problematic_appliance.status.update(SETTINGS_STATUS)
problematic_appliance.set_setting.side_effect = attr_side_effect
get_appliances.return_value = [problematic_appliance]
client_with_exception.get_settings.side_effect = None
client_with_exception.get_settings.return_value = ArrayOfSettings(
[
GetSetting(
key=setting_key,
raw_key=setting_key.value,
value=value,
)
for setting_key, value in setting.items()
]
)
client_with_exception.set_setting.side_effect = [
exception() if exception else None for exception in attr_side_effect
]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.LOADED
# Assert that an exception is called.
with pytest.raises(HomeConnectError):
getattr(problematic_appliance, mock_attr)()
await client_with_exception.set_setting()
problematic_appliance.status.update(status)
service_data["entity_id"] = entity_id
with pytest.raises(HomeAssistantError, match=exception_match):
await hass.services.async_call(
LIGHT_DOMAIN, service, service_data, blocking=True
)
assert getattr(problematic_appliance, mock_attr).call_count == len(attr_side_effect)
assert client_with_exception.set_setting.call_count == len(attr_side_effect)

View File

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

View File

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

View File

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

View File

@ -1,24 +1,34 @@
"""Tests for home_connect sensor entities."""
from collections.abc import Awaitable, Callable, Generator
from unittest.mock import MagicMock, Mock
from collections.abc import Awaitable, Callable
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from homeconnect.api import HomeConnectAppliance, HomeConnectError
from aiohomeconnect.model import (
ArrayOfSettings,
Event,
EventKey,
EventMessage,
GetSetting,
ProgramKey,
SettingKey,
)
from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.event import ArrayOfEvents, EventType
from aiohomeconnect.model.program import (
ArrayOfAvailablePrograms,
EnumerateAvailableProgram,
)
from aiohomeconnect.model.setting import SettingConstraints
import pytest
from homeassistant.components import automation, script
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.home_connect.const import (
ATTR_ALLOWED_VALUES,
ATTR_CONSTRAINTS,
BSH_ACTIVE_PROGRAM,
BSH_CHILD_LOCK_STATE,
BSH_POWER_OFF,
BSH_POWER_ON,
BSH_POWER_STANDBY,
BSH_POWER_STATE,
DOMAIN,
REFRIGERATION_SUPERMODEFREEZER,
)
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
@ -36,19 +46,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from .conftest import get_all_appliances
from tests.common import MockConfigEntry, load_json_object_fixture
SETTINGS_STATUS = {
setting.pop("key"): setting
for setting in load_json_object_fixture("home_connect/settings.json")
.get("Dishwasher")
.get("data")
.get("settings")
}
PROGRAM = "LaundryCare.Dryer.Program.Mix"
from tests.common import MockConfigEntry
@pytest.fixture
@ -58,231 +56,285 @@ def platforms() -> list[str]:
async def test_switches(
bypass_throttle: Generator[None],
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: Mock,
client: MagicMock,
) -> None:
"""Test switch entities."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
@pytest.mark.parametrize(
("entity_id", "status", "service", "state", "appliance"),
[
(
"switch.dishwasher_program_mix",
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}},
SERVICE_TURN_ON,
STATE_ON,
"Dishwasher",
),
(
"switch.dishwasher_program_mix",
{BSH_ACTIVE_PROGRAM: {"value": ""}},
SERVICE_TURN_OFF,
STATE_OFF,
"Dishwasher",
),
(
"switch.dishwasher_child_lock",
{BSH_CHILD_LOCK_STATE: {"value": True}},
SERVICE_TURN_ON,
STATE_ON,
"Dishwasher",
),
(
"switch.dishwasher_child_lock",
{BSH_CHILD_LOCK_STATE: {"value": False}},
SERVICE_TURN_OFF,
STATE_OFF,
"Dishwasher",
),
],
indirect=["appliance"],
)
async def test_switch_functionality(
entity_id: str,
status: dict,
service: str,
state: str,
bypass_throttle: Generator[None],
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
setup_credentials: None,
appliance: Mock,
get_appliances: MagicMock,
) -> None:
"""Test switch functionality."""
appliance.status.update(SETTINGS_STATUS)
appliance.get_programs_available.return_value = [PROGRAM]
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update(status)
await hass.services.async_call(
SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True
)
assert hass.states.is_state(entity_id, state)
@pytest.mark.parametrize(
(
"entity_id",
"status",
"service",
"settings_key_arg",
"setting_value_arg",
"state",
"appliance_ha_id",
),
[
(
"switch.dishwasher_child_lock",
SERVICE_TURN_ON,
SettingKey.BSH_COMMON_CHILD_LOCK,
True,
STATE_ON,
"Dishwasher",
),
(
"switch.dishwasher_child_lock",
SERVICE_TURN_OFF,
SettingKey.BSH_COMMON_CHILD_LOCK,
False,
STATE_OFF,
"Dishwasher",
),
],
indirect=["appliance_ha_id"],
)
async def test_switch_functionality(
entity_id: str,
settings_key_arg: SettingKey,
setting_value_arg: Any,
service: str,
state: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
appliance_ha_id: str,
client: MagicMock,
) -> None:
"""Test switch functionality."""
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id})
await hass.async_block_till_done()
client.set_setting.assert_awaited_once_with(
appliance_ha_id, setting_key=settings_key_arg, value=setting_value_arg
)
assert hass.states.is_state(entity_id, state)
@pytest.mark.parametrize(
("entity_id", "program_key", "appliance_ha_id"),
[
(
"switch.dryer_program_mix",
ProgramKey.LAUNDRY_CARE_DRYER_MIX,
"Dryer",
),
],
indirect=["appliance_ha_id"],
)
async def test_program_switch_functionality(
entity_id: str,
program_key: ProgramKey,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
appliance_ha_id: str,
client: MagicMock,
) -> None:
"""Test switch functionality."""
async def mock_stop_program(ha_id: str) -> None:
"""Mock stop program."""
await client.add_events(
[
EventMessage(
ha_id,
EventType.NOTIFY,
ArrayOfEvents(
[
Event(
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
timestamp=0,
level="",
handling="",
value=ProgramKey.UNKNOWN,
)
]
),
),
]
)
client.stop_program = AsyncMock(side_effect=mock_stop_program)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
await hass.services.async_call(
SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}
)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, STATE_ON)
client.start_program.assert_awaited_once_with(
appliance_ha_id, program_key=program_key
)
await hass.services.async_call(
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}
)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, STATE_OFF)
client.stop_program.assert_awaited_once_with(appliance_ha_id)
@pytest.mark.parametrize(
(
"entity_id",
"service",
"mock_attr",
"problematic_appliance",
"exception_match",
),
[
(
"switch.dishwasher_program_mix",
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}},
"switch.dishwasher_program_eco50",
SERVICE_TURN_ON,
"start_program",
"Dishwasher",
r"Error.*start.*program.*",
),
(
"switch.dishwasher_program_mix",
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}},
"switch.dishwasher_program_eco50",
SERVICE_TURN_OFF,
"stop_program",
"Dishwasher",
r"Error.*stop.*program.*",
),
(
"switch.dishwasher_power",
{BSH_POWER_STATE: {"value": BSH_POWER_OFF}},
SERVICE_TURN_OFF,
"set_setting",
"Dishwasher",
r"Error.*turn.*off.*",
),
(
"switch.dishwasher_power",
{BSH_POWER_STATE: {"value": ""}},
SERVICE_TURN_ON,
"set_setting",
"Dishwasher",
r"Error.*turn.*on.*",
),
(
"switch.dishwasher_child_lock",
{BSH_CHILD_LOCK_STATE: {"value": ""}},
SERVICE_TURN_ON,
"set_setting",
"Dishwasher",
r"Error.*turn.*on.*",
),
(
"switch.dishwasher_child_lock",
{BSH_CHILD_LOCK_STATE: {"value": ""}},
SERVICE_TURN_OFF,
"set_setting",
"Dishwasher",
r"Error.*turn.*off.*",
),
],
indirect=["problematic_appliance"],
)
async def test_switch_exception_handling(
entity_id: str,
status: dict,
service: str,
mock_attr: str,
exception_match: str,
bypass_throttle: Generator[None],
hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry,
setup_credentials: None,
problematic_appliance: Mock,
get_appliances: MagicMock,
client_with_exception: MagicMock,
) -> None:
"""Test exception handling."""
problematic_appliance.get_programs_available.side_effect = None
problematic_appliance.get_programs_available.return_value = [PROGRAM]
get_appliances.return_value = [problematic_appliance]
client_with_exception.get_available_programs.side_effect = None
client_with_exception.get_available_programs.return_value = (
ArrayOfAvailablePrograms(
[
EnumerateAvailableProgram(
key=ProgramKey.DISHCARE_DISHWASHER_ECO_50,
raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value,
)
]
)
)
client_with_exception.get_settings.side_effect = None
client_with_exception.get_settings.return_value = ArrayOfSettings(
[
GetSetting(
key=SettingKey.BSH_COMMON_CHILD_LOCK,
raw_key=SettingKey.BSH_COMMON_CHILD_LOCK.value,
value=False,
),
GetSetting(
key=SettingKey.BSH_COMMON_POWER_STATE,
raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
value=BSH_POWER_ON,
constraints=SettingConstraints(
allowed_values=[BSH_POWER_ON, BSH_POWER_OFF]
),
),
]
)
assert config_entry.state == ConfigEntryState.NOT_LOADED
problematic_appliance.status.update(status)
assert await integration_setup()
assert await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.LOADED
# Assert that an exception is called.
with pytest.raises(HomeConnectError):
getattr(problematic_appliance, mock_attr)()
await getattr(client_with_exception, mock_attr)()
with pytest.raises(HomeAssistantError, match=exception_match):
await hass.services.async_call(
SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert getattr(problematic_appliance, mock_attr).call_count == 2
assert getattr(client_with_exception, mock_attr).call_count == 2
@pytest.mark.parametrize(
("entity_id", "status", "service", "state", "appliance"),
("entity_id", "status", "service", "state", "appliance_ha_id"),
[
(
"switch.fridgefreezer_freezer_super_mode",
{REFRIGERATION_SUPERMODEFREEZER: {"value": True}},
{SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: True},
SERVICE_TURN_ON,
STATE_ON,
"FridgeFreezer",
),
(
"switch.fridgefreezer_freezer_super_mode",
{REFRIGERATION_SUPERMODEFREEZER: {"value": False}},
{SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: False},
SERVICE_TURN_OFF,
STATE_OFF,
"FridgeFreezer",
),
],
indirect=["appliance"],
indirect=["appliance_ha_id"],
)
async def test_ent_desc_switch_functionality(
entity_id: str,
status: dict,
service: str,
state: str,
bypass_throttle: Generator[None],
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
appliance: Mock,
get_appliances: MagicMock,
appliance_ha_id: str,
client: MagicMock,
) -> None:
"""Test switch functionality - entity description setup."""
appliance.status.update(
HomeConnectAppliance.json2dict(
load_json_object_fixture("home_connect/settings.json")
.get(appliance.name)
.get("data")
.get("settings")
)
)
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update(status)
await hass.services.async_call(
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id})
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, state)
@ -292,13 +344,13 @@ async def test_ent_desc_switch_functionality(
"status",
"service",
"mock_attr",
"problematic_appliance",
"appliance_ha_id",
"exception_match",
),
[
(
"switch.fridgefreezer_freezer_super_mode",
{REFRIGERATION_SUPERMODEFREEZER: {"value": ""}},
{SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""},
SERVICE_TURN_ON,
"set_setting",
"FridgeFreezer",
@ -306,203 +358,257 @@ async def test_ent_desc_switch_functionality(
),
(
"switch.fridgefreezer_freezer_super_mode",
{REFRIGERATION_SUPERMODEFREEZER: {"value": ""}},
{SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""},
SERVICE_TURN_OFF,
"set_setting",
"FridgeFreezer",
r"Error.*turn.*off.*",
),
],
indirect=["problematic_appliance"],
indirect=["appliance_ha_id"],
)
async def test_ent_desc_switch_exception_handling(
entity_id: str,
status: dict,
status: dict[SettingKey, str],
service: str,
mock_attr: str,
exception_match: str,
bypass_throttle: Generator[None],
hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry,
setup_credentials: None,
problematic_appliance: Mock,
get_appliances: MagicMock,
appliance_ha_id: str,
client_with_exception: MagicMock,
) -> None:
"""Test switch exception handling - entity description setup."""
problematic_appliance.status.update(
HomeConnectAppliance.json2dict(
load_json_object_fixture("home_connect/settings.json")
.get(problematic_appliance.name)
.get("data")
.get("settings")
)
client_with_exception.get_settings.side_effect = None
client_with_exception.get_settings.return_value = ArrayOfSettings(
[
GetSetting(
key=key,
raw_key=key.value,
value=value,
)
for key, value in status.items()
]
)
get_appliances.return_value = [problematic_appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.LOADED
# Assert that an exception is called.
with pytest.raises(HomeConnectError):
getattr(problematic_appliance, mock_attr)()
problematic_appliance.status.update(status)
await client_with_exception.set_setting()
with pytest.raises(HomeAssistantError, match=exception_match):
await hass.services.async_call(
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert getattr(problematic_appliance, mock_attr).call_count == 2
assert client_with_exception.set_setting.call_count == 2
@pytest.mark.parametrize(
("entity_id", "status", "allowed_values", "service", "power_state", "appliance"),
(
"entity_id",
"allowed_values",
"service",
"setting_value_arg",
"power_state",
"appliance_ha_id",
),
[
(
"switch.dishwasher_power",
{BSH_POWER_STATE: {"value": BSH_POWER_ON}},
[BSH_POWER_ON, BSH_POWER_OFF],
SERVICE_TURN_ON,
BSH_POWER_ON,
STATE_ON,
"Dishwasher",
),
(
"switch.dishwasher_power",
{BSH_POWER_STATE: {"value": BSH_POWER_OFF}},
[BSH_POWER_ON, BSH_POWER_OFF],
SERVICE_TURN_OFF,
BSH_POWER_OFF,
STATE_OFF,
"Dishwasher",
),
(
"switch.dishwasher_power",
{BSH_POWER_STATE: {"value": BSH_POWER_ON}},
[BSH_POWER_ON, BSH_POWER_STANDBY],
SERVICE_TURN_ON,
BSH_POWER_ON,
STATE_ON,
"Dishwasher",
),
(
"switch.dishwasher_power",
{BSH_POWER_STATE: {"value": BSH_POWER_STANDBY}},
[BSH_POWER_ON, BSH_POWER_STANDBY],
SERVICE_TURN_OFF,
BSH_POWER_STANDBY,
STATE_OFF,
"Dishwasher",
),
],
indirect=["appliance"],
indirect=["appliance_ha_id"],
)
@pytest.mark.usefixtures("bypass_throttle")
async def test_power_swtich(
entity_id: str,
status: dict,
allowed_values: list[str],
allowed_values: list[str | None] | None,
service: str,
setting_value_arg: str,
power_state: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
appliance: Mock,
get_appliances: MagicMock,
appliance_ha_id: str,
client: MagicMock,
) -> None:
"""Test power switch functionality."""
appliance.get.side_effect = [
{
ATTR_CONSTRAINTS: {
ATTR_ALLOWED_VALUES: allowed_values,
},
}
]
appliance.status.update(SETTINGS_STATUS)
appliance.status.update(status)
get_appliances.return_value = [appliance]
client.get_settings.side_effect = None
client.get_settings.return_value = ArrayOfSettings(
[
GetSetting(
key=SettingKey.BSH_COMMON_POWER_STATE,
raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
value="",
constraints=SettingConstraints(
allowed_values=allowed_values,
),
)
]
)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
await hass.services.async_call(
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id})
await hass.async_block_till_done()
client.set_setting.assert_awaited_once_with(
appliance_ha_id,
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
value=setting_value_arg,
)
assert hass.states.is_state(entity_id, power_state)
@pytest.mark.parametrize(
("entity_id", "allowed_values", "service", "appliance", "exception_match"),
("initial_value"),
[
(BSH_POWER_OFF),
(BSH_POWER_STANDBY),
],
)
async def test_power_switch_fetch_off_state_from_current_value(
initial_value: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test power switch functionality to fetch the off state from the current value."""
client.get_settings.side_effect = None
client.get_settings.return_value = ArrayOfSettings(
[
GetSetting(
key=SettingKey.BSH_COMMON_POWER_STATE,
raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
value=initial_value,
)
]
)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
assert hass.states.is_state("switch.dishwasher_power", STATE_OFF)
@pytest.mark.parametrize(
("entity_id", "allowed_values", "service", "exception_match"),
[
(
"switch.dishwasher_power",
[BSH_POWER_ON],
SERVICE_TURN_OFF,
"Dishwasher",
r".*not support.*turn.*off.*",
),
(
"switch.dishwasher_power",
None,
SERVICE_TURN_OFF,
"Dishwasher",
r".*Unable.*turn.*off.*support.*not.*determined.*",
),
(
"switch.dishwasher_power",
HomeConnectError(),
SERVICE_TURN_OFF,
r".*Unable.*turn.*off.*support.*not.*determined.*",
),
],
indirect=["appliance"],
)
@pytest.mark.usefixtures("bypass_throttle")
async def test_power_switch_service_validation_errors(
entity_id: str,
allowed_values: list[str],
allowed_values: list[str | None] | None | HomeConnectError,
service: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
appliance: Mock,
exception_match: str,
get_appliances: MagicMock,
client: MagicMock,
) -> None:
"""Test power switch functionality validation errors."""
if allowed_values:
appliance.get.side_effect = [
{
ATTR_CONSTRAINTS: {
ATTR_ALLOWED_VALUES: allowed_values,
},
}
]
appliance.status.update(SETTINGS_STATUS)
get_appliances.return_value = [appliance]
client.get_settings.side_effect = None
if isinstance(allowed_values, HomeConnectError):
exception = allowed_values
client.get_settings.return_value = ArrayOfSettings(
[
GetSetting(
key=SettingKey.BSH_COMMON_POWER_STATE,
raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
value=BSH_POWER_ON,
)
]
)
client.get_setting = AsyncMock(side_effect=exception)
else:
setting = GetSetting(
key=SettingKey.BSH_COMMON_POWER_STATE,
raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
value=BSH_POWER_ON,
constraints=SettingConstraints(
allowed_values=allowed_values,
),
)
client.get_settings.return_value = ArrayOfSettings([setting])
client.get_setting = AsyncMock(return_value=setting)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update({BSH_POWER_STATE: {"value": BSH_POWER_ON}})
with pytest.raises(HomeAssistantError, match=exception_match):
await hass.services.async_call(
SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.usefixtures("bypass_throttle")
async def test_create_issue(
hass: HomeAssistant,
appliance: Mock,
appliance_ha_id: str,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
client: MagicMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test we create an issue when an automation or script is using a deprecated entity."""
entity_id = "switch.washer_program_mix"
appliance.status.update(SETTINGS_STATUS)
appliance.get_programs_available.return_value = [PROGRAM]
get_appliances.return_value = [appliance]
issue_id = f"deprecated_program_switch_{entity_id}"
assert await async_setup_component(
@ -539,7 +645,7 @@ async def test_create_issue(
)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
assert automations_with_entity(hass, entity_id)[0] == "automation.test"

View File

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