diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index b4ceb11be92..637fd7aa3a8 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -187,6 +187,7 @@ SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.LIGHT, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py new file mode 100644 index 00000000000..138979409a5 --- /dev/null +++ b/homeassistant/components/home_connect/button.py @@ -0,0 +1,160 @@ +"""Provides button entities for Home Connect.""" + +from aiohomeconnect.model import CommandKey, EventKey +from aiohomeconnect.model.error import HomeConnectError + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import setup_home_connect_entry +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) +from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error + + +class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription): + """Describes Home Connect button entity.""" + + key: CommandKey + + +COMMAND_BUTTONS = ( + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_OPEN_DOOR, + translation_key="open_door", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_PARTLY_OPEN_DOOR, + translation_key="partly_open_door", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_PAUSE_PROGRAM, + translation_key="pause_program", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_RESUME_PROGRAM, + translation_key="resume_program", + ), +) + + +def _get_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + entities: list[HomeConnectEntity] = [] + entities.extend( + HomeConnectCommandButtonEntity(entry.runtime_data, appliance, description) + for description in COMMAND_BUTTONS + if description.key in appliance.commands + ) + if appliance.info.type in APPLIANCES_WITH_PROGRAMS: + entities.append( + HomeConnectStopProgramButtonEntity(entry.runtime_data, appliance) + ) + + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Home Connect button entities.""" + setup_home_connect_entry( + entry, + _get_entities_for_appliance, + async_add_entities, + ) + + +class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity): + """Describes Home Connect button entity.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: ButtonEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + appliance, + # The entity is subscribed to the appliance connected event, + # but it will receive also the disconnected event + ButtonEntityDescription( + key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, + ), + ) + self.entity_description = desc + self.appliance = appliance + self.unique_id = f"{appliance.info.ha_id}-{desc.key}" + + def update_native_value(self) -> None: + """Set the value of the entity.""" + + +class HomeConnectCommandButtonEntity(HomeConnectButtonEntity): + """Button entity for Home Connect commands.""" + + entity_description: HomeConnectCommandButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.put_command( + self.appliance.info.ha_id, + command_key=self.entity_description.key, + value=True, + ) + except HomeConnectError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(error), + "command": self.entity_description.key, + }, + ) from error + + +class HomeConnectStopProgramButtonEntity(HomeConnectButtonEntity): + """Button entity for stopping a program.""" + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + appliance, + ButtonEntityDescription( + key="StopProgram", + translation_key="stop_program", + ), + ) + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.stop_program(self.appliance.info.ha_id) + except HomeConnectError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="stop_program", + translation_placeholders=get_dict_from_home_connect_error(error), + ) from error diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index b5f0f711597..80ae8173d86 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -11,6 +11,7 @@ from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( + CommandKey, Event, EventKey, EventMessage, @@ -53,6 +54,7 @@ EVENT_STREAM_RECONNECT_DELAY = 30 class HomeConnectApplianceData: """Class to hold Home Connect appliance data.""" + commands: set[CommandKey] events: dict[EventKey, Event] info: HomeAppliance options: dict[OptionKey, ProgramDefinitionOption] @@ -62,6 +64,7 @@ class HomeConnectApplianceData: def update(self, other: HomeConnectApplianceData) -> None: """Update data with data from other instance.""" + self.commands.update(other.commands) self.events.update(other.events) self.info.connected = other.info.connected self.options.clear() @@ -408,7 +411,18 @@ class HomeConnectCoordinator( unit=option.unit, ) + try: + commands = { + command.key + for command in ( + await self.client.get_available_commands(appliance.ha_id) + ).commands + } + except HomeConnectError: + commands = set() + appliance_data = HomeConnectApplianceData( + commands=commands, events=events, info=appliance, options=options, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 8a4dd68530f..db53e76fb95 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -815,6 +815,23 @@ "name": "Wine compartment door" } }, + "button": { + "open_door": { + "name": "Open door" + }, + "partly_open_door": { + "name": "Partly open door" + }, + "pause_program": { + "name": "Pause program" + }, + "resume_program": { + "name": "Resume program" + }, + "stop_program": { + "name": "Stop program" + } + }, "light": { "cooking_lighting": { "name": "Functional light" diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index e0d60dc8614..49cbc89ba41 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( + ArrayOfCommands, ArrayOfEvents, ArrayOfHomeAppliances, ArrayOfOptions, @@ -50,6 +51,9 @@ MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings. MOCK_STATUS = ArrayOfStatus.from_dict( load_json_object_fixture("home_connect/status.json")["data"] ) +MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( + "home_connect/available_commands.json" +) CLIENT_ID = "1234" @@ -326,6 +330,14 @@ async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): raise HomeConnectApiError("error.key", "error description") +async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + """Get available commands.""" + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.ha_id == ha_id and appliance.type in MOCK_AVAILABLE_COMMANDS: + return ArrayOfCommands.from_dict(MOCK_AVAILABLE_COMMANDS[appliance.type]) + raise HomeConnectApiError("error.key", "error description") + + @pytest.fixture(name="client") def mock_client(request: pytest.FixtureRequest) -> MagicMock: """Fixture to mock Client from HomeConnect.""" @@ -385,6 +397,7 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM ), ) + mock.stop_program = AsyncMock() mock.set_active_program_option = AsyncMock( side_effect=_get_set_program_options_side_effect(event_queue), ) @@ -404,6 +417,9 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.get_setting = AsyncMock(side_effect=_get_setting_side_effect) mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) + mock.get_available_commands = AsyncMock( + side_effect=_get_available_commands_side_effect + ) mock.put_command = AsyncMock() mock.get_available_program = AsyncMock( return_value=ProgramDefinition(ProgramKey.UNKNOWN, options=[]) @@ -446,6 +462,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.start_program = AsyncMock(side_effect=exception) mock.stop_program = AsyncMock(side_effect=exception) mock.set_selected_program = AsyncMock(side_effect=exception) + mock.stop_program = AsyncMock(side_effect=exception) mock.set_active_program_option = AsyncMock(side_effect=exception) mock.set_active_program_options = AsyncMock(side_effect=exception) mock.set_selected_program_option = AsyncMock(side_effect=exception) @@ -455,6 +472,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.get_setting = AsyncMock(side_effect=exception) mock.get_status = AsyncMock(side_effect=exception) mock.get_all_programs = AsyncMock(side_effect=exception) + mock.get_available_commands = AsyncMock(side_effect=exception) mock.put_command = AsyncMock(side_effect=exception) mock.get_available_program = AsyncMock(side_effect=exception) mock.get_active_program_options = AsyncMock(side_effect=exception) diff --git a/tests/components/home_connect/fixtures/available_commands.json b/tests/components/home_connect/fixtures/available_commands.json new file mode 100644 index 00000000000..e4ed6c21b7c --- /dev/null +++ b/tests/components/home_connect/fixtures/available_commands.json @@ -0,0 +1,142 @@ +{ + "Cooktop": { + "commands": [ + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Hood": { + "commands": [ + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Oven": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + }, + { + "key": "BSH.Common.Command.PartlyOpenDoor", + "name": "Partly open door" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "CleaningRobot": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Dishwasher": { + "commands": [ + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Dryer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Washer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "WasherDryer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Freezer": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + }, + "FridgeFreezer": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + }, + "Refrigerator": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + } +} diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py new file mode 100644 index 00000000000..5af7e40ca43 --- /dev/null +++ b/tests/components/home_connect/test_button.py @@ -0,0 +1,315 @@ +"""Tests for home_connect button entities.""" + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from aiohomeconnect.model import ArrayOfCommands, CommandKey, EventMessage +from aiohomeconnect.model.command import Command +from aiohomeconnect.model.error import HomeConnectApiError +from aiohomeconnect.model.event import ArrayOfEvents, EventType +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BUTTON] + + +async def test_buttons( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test button entities.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_available_commands_original_mock = client.get_available_commands + get_available_programs_mock = client.get_available_programs + + async def get_available_commands_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_commands_original_mock.side_effect(ha_id) + + async def get_available_programs_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_programs_mock.side_effect(ha_id) + + client.get_available_commands = AsyncMock( + side_effect=get_available_commands_side_effect + ) + client.get_available_programs = AsyncMock( + side_effect=get_available_programs_side_effect + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_available_commands = get_available_commands_original_mock + client.get_available_programs = get_available_programs_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + +async def test_button_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_ids = [ + "button.washer_pause_program", + "button.washer_stop_program", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("entity_id", "method_call", "expected_kwargs"), + [ + ( + "button.washer_pause_program", + "put_command", + {"command_key": CommandKey.BSH_COMMON_PAUSE_PROGRAM, "value": True}, + ), + ("button.washer_stop_program", "stop_program", {}), + ], +) +async def test_button_functionality( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + entity_id: str, + method_call: str, + expected_kwargs: dict[str, Any], + appliance_ha_id: str, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + getattr(client, method_call).assert_called_with(appliance_ha_id, **expected_kwargs) + + +async def test_command_button_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_id = "button.washer_pause_program" + + client_with_exception.get_available_commands = AsyncMock( + return_value=ArrayOfCommands( + [ + Command( + CommandKey.BSH_COMMON_PAUSE_PROGRAM, + "Pause Program", + ) + ] + ) + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + with pytest.raises(HomeAssistantError, match=r"Error.*executing.*command"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_stop_program_button_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_id = "button.washer_stop_program" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + with pytest.raises(HomeAssistantError, match=r"Error.*stop.*program"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + )