diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 65f2e1713596c..c6b3cf541c6e1 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -3,14 +3,17 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass import itertools import logging from typing import Any +from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.consumeable import ConsumableAttribute from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockZeoProtocol +from roborock.roborock_typing import RoborockCommand from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory @@ -97,6 +100,41 @@ class RoborockButtonDescriptionA01(ButtonEntityDescription): ] +@dataclass(frozen=True, kw_only=True) +class RoborockDockCommandButtonDescription(ButtonEntityDescription): + """Describes a Roborock dock command button entity.""" + + api_command: RoborockCommand + availability_fn: Callable[[PropertiesApi], bool] + """Return True if this button should be created for the given device.""" + + +DOCK_COMMAND_BUTTON_DESCRIPTIONS: list[RoborockDockCommandButtonDescription] = [ + RoborockDockCommandButtonDescription( + key="dock_empty", + translation_key="dock_empty", + api_command=RoborockCommand.APP_START_COLLECT_DUST, + availability_fn=lambda api: api.dust_collection_mode is not None, + entity_registry_enabled_default=False, + ), + RoborockDockCommandButtonDescription( + key="dock_wash_mop", + translation_key="dock_wash_mop", + api_command=RoborockCommand.APP_START_WASH, + # wash_towel_mode being non-None is the API proxy for "has mop washing station" + availability_fn=lambda api: api.wash_towel_mode is not None, + entity_registry_enabled_default=False, + ), + RoborockDockCommandButtonDescription( + key="dock_stop_drying", + translation_key="dock_stop_drying", + api_command=RoborockCommand.APP_STOP_WASH, + availability_fn=lambda api: api.wash_towel_mode is not None, + entity_registry_enabled_default=False, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, @@ -139,6 +177,13 @@ async def async_setup_entry( if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator) for description in ZEO_BUTTON_DESCRIPTIONS ), + ( + RoborockDockCommandButtonEntity(coordinator, description) + for coordinator in config_entry.runtime_data.v1 + for description in DOCK_COMMAND_BUTTON_DESCRIPTIONS + if isinstance(coordinator, RoborockDataUpdateCoordinator) + if description.availability_fn(coordinator.properties_api) + ), ) ) @@ -233,3 +278,26 @@ async def async_press(self) -> None: ) from err finally: await self.coordinator.async_request_refresh() + + +class RoborockDockCommandButtonEntity(RoborockEntityV1, ButtonEntity): + """A class to define Roborock dock command button entities.""" + + entity_description: RoborockDockCommandButtonDescription + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockDockCommandButtonDescription, + ) -> None: + """Create a dock command button entity.""" + super().__init__( + f"{entity_description.key}_{coordinator.duid_slug}", + coordinator.dock_device_info, + api=coordinator.properties_api.command, + ) + self.entity_description = entity_description + + async def async_press(self, **kwargs: Any) -> None: + """Send the dock command.""" + await self.send(self.entity_description.api_command) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 39eeed3e0f8a5..11504914cbde5 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -84,6 +84,15 @@ } }, "button": { + "dock_empty": { + "name": "Empty" + }, + "dock_stop_drying": { + "name": "Stop drying" + }, + "dock_wash_mop": { + "name": "Wash mop" + }, "pause": { "name": "Pause" }, diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 08c75f234e96c..e653d5102d151 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -27,6 +27,7 @@ NamedRoomMapping, NetworkInfo, RoborockBase, + RoborockDockWashTowelModeCode, RoborockDyadStateCode, ValleyElectricityTimer, WorkStatusMapping, @@ -374,6 +375,7 @@ def create_v1_properties(network_info: NetworkInfo) -> AsyncMock: trait_spec=DustCollectionModeTrait ) v1_properties.wash_towel_mode = make_mock_trait(trait_spec=WashTowelModeTrait) + v1_properties.wash_towel_mode.wash_mode = RoborockDockWashTowelModeCode.light v1_properties.smart_wash_params = make_mock_trait(trait_spec=SmartWashParamsTrait) v1_properties.home = make_home_trait( map_info=MULTI_MAP_LIST.map_info, diff --git a/tests/components/roborock/snapshots/test_button.ambr b/tests/components/roborock/snapshots/test_button.ambr index dc4ea7ca120cd..0ca6ca385f066 100644 --- a/tests/components/roborock/snapshots/test_button.ambr +++ b/tests/components/roborock/snapshots/test_button.ambr @@ -1,4 +1,151 @@ # serializer version: 1 +# name: test_buttons[button.roborock_s7_2_dock_empty-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.roborock_s7_2_dock_empty', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Empty', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Empty', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dock_empty', + 'unique_id': 'dock_empty_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_2_dock_empty-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Dock Empty', + }), + 'context': , + 'entity_id': 'button.roborock_s7_2_dock_empty', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_2_dock_stop_drying-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.roborock_s7_2_dock_stop_drying', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stop drying', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop drying', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dock_stop_drying', + 'unique_id': 'dock_stop_drying_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_2_dock_stop_drying-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Dock Stop drying', + }), + 'context': , + 'entity_id': 'button.roborock_s7_2_dock_stop_drying', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_2_dock_wash_mop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.roborock_s7_2_dock_wash_mop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wash mop', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wash mop', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dock_wash_mop', + 'unique_id': 'dock_wash_mop_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_2_dock_wash_mop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Dock Wash mop', + }), + 'context': , + 'entity_id': 'button.roborock_s7_2_dock_wash_mop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[button.roborock_s7_2_reset_air_filter_consumable-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -293,6 +440,153 @@ 'state': 'unknown', }) # --- +# name: test_buttons[button.roborock_s7_maxv_dock_empty-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.roborock_s7_maxv_dock_empty', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Empty', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Empty', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dock_empty', + 'unique_id': 'dock_empty_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_dock_empty-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Dock Empty', + }), + 'context': , + 'entity_id': 'button.roborock_s7_maxv_dock_empty', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_dock_stop_drying-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.roborock_s7_maxv_dock_stop_drying', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stop drying', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop drying', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dock_stop_drying', + 'unique_id': 'dock_stop_drying_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_dock_stop_drying-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Dock Stop drying', + }), + 'context': , + 'entity_id': 'button.roborock_s7_maxv_dock_stop_drying', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_dock_wash_mop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.roborock_s7_maxv_dock_wash_mop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wash mop', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wash mop', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dock_wash_mop', + 'unique_id': 'dock_wash_mop_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_dock_wash_mop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Dock Wash mop', + }), + 'context': , + 'entity_id': 'button.roborock_s7_maxv_dock_wash_mop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[button.roborock_s7_maxv_reset_air_filter_consumable-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 287bbf4c82f99..798e8c1441a5c 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -1,17 +1,20 @@ """Test Roborock Button platform.""" -from unittest.mock import Mock +from unittest.mock import Mock, call import pytest from roborock import RoborockException from roborock.exceptions import RoborockTimeout +from roborock.roborock_typing import RoborockCommand from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import SERVICE_PRESS +from homeassistant.components.roborock import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from .conftest import FakeDevice @@ -272,3 +275,85 @@ async def test_press_a01_button_failure( washing_machine.zeo.set_value.assert_called_once() assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.parametrize( + ("entity_id", "expected_command"), + [ + ( + "button.roborock_s7_maxv_dock_empty", + RoborockCommand.APP_START_COLLECT_DUST, + ), + ( + "button.roborock_s7_maxv_dock_wash_mop", + RoborockCommand.APP_START_WASH, + ), + ( + "button.roborock_s7_maxv_dock_stop_drying", + RoborockCommand.APP_STOP_WASH, + ), + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_press_dock_command_button_success( + hass: HomeAssistant, + bypass_api_client_fixture: None, + setup_entry: MockConfigEntry, + entity_id: str, + expected_command: RoborockCommand, + fake_vacuum: FakeDevice, +) -> None: + """Test pressing dock command button entities sends the correct command.""" + assert hass.states.get(entity_id) is not None + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + assert fake_vacuum.v1_properties + assert fake_vacuum.v1_properties.command.send.call_args == call( + expected_command, params=None + ) + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_press_dock_command_button_failure( + hass: HomeAssistant, + bypass_api_client_fixture: None, + setup_entry: MockConfigEntry, + fake_vacuum: FakeDevice, +) -> None: + """Test that a dock command button raises HomeAssistantError on failure.""" + assert fake_vacuum.v1_properties + fake_vacuum.v1_properties.command.send.side_effect = RoborockException + + with pytest.raises( + HomeAssistantError, match="Error while calling APP_START_COLLECT_DUST" + ): + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": "button.roborock_s7_maxv_dock_empty"}, + ) + + +async def test_dock_command_buttons_not_created_without_traits( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_vacuum: FakeDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test that dock command buttons are not created when traits are unavailable.""" + assert fake_vacuum.v1_properties + fake_vacuum.v1_properties.dust_collection_mode = None + fake_vacuum.v1_properties.wash_towel_mode = None + + await async_setup_component(hass, DOMAIN, {}) + + assert entity_registry.async_get("button.roborock_s7_maxv_dock_empty") is None + assert entity_registry.async_get("button.roborock_s7_maxv_dock_wash_mop") is None + assert entity_registry.async_get("button.roborock_s7_maxv_dock_stop_drying") is None