diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 1f28240c06a..53c85d6d96f 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -26,6 +26,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.IMAGE, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py new file mode 100644 index 00000000000..c2e5458c2ed --- /dev/null +++ b/homeassistant/components/ecovacs/button.py @@ -0,0 +1,108 @@ +"""Ecovacs button module.""" +from dataclasses import dataclass + +from deebot_client.capabilities import CapabilityExecute, CapabilityLifeSpan +from deebot_client.events import LifeSpan + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, SUPPORTED_LIFESPANS +from .controller import EcovacsController +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, +) +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsButtonEntityDescription( + ButtonEntityDescription, + EcovacsCapabilityEntityDescription, +): + """Ecovacs button entity description.""" + + +@dataclass(kw_only=True, frozen=True) +class EcovacsLifespanButtonEntityDescription(ButtonEntityDescription): + """Ecovacs lifespan button entity description.""" + + component: LifeSpan + + +ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = ( + EcovacsButtonEntityDescription( + capability_fn=lambda caps: caps.map.relocation if caps.map else None, + key="relocate", + translation_key="relocate", + entity_category=EntityCategory.CONFIG, + ), +) + +LIFESPAN_ENTITY_DESCRIPTIONS = tuple( + EcovacsLifespanButtonEntityDescription( + component=component, + key=f"reset_lifespan_{component.name.lower()}", + translation_key=f"reset_lifespan_{component.name.lower()}", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ) + for component in SUPPORTED_LIFESPANS +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + entities: list[EcovacsEntity] = get_supported_entitites( + controller, EcovacsButtonEntity, ENTITY_DESCRIPTIONS + ) + for device in controller.devices: + lifespan_capability = device.capabilities.life_span + for description in LIFESPAN_ENTITY_DESCRIPTIONS: + if description.component in lifespan_capability.types: + entities.append( + EcovacsResetLifespanButtonEntity( + device, lifespan_capability, description + ) + ) + + if entities: + async_add_entities(entities) + + +class EcovacsButtonEntity( + EcovacsDescriptionEntity[CapabilityExecute], + ButtonEntity, +): + """Ecovacs button entity.""" + + entity_description: EcovacsLifespanButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + await self._device.execute_command(self._capability.execute()) + + +class EcovacsResetLifespanButtonEntity( + EcovacsDescriptionEntity[CapabilityLifeSpan], + ButtonEntity, +): + """Ecovacs reset lifespan button entity.""" + + entity_description: EcovacsLifespanButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + await self._device.execute_command( + self._capability.reset(self.entity_description.component) + ) diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py index ed33f90f191..5edbe11c265 100644 --- a/homeassistant/components/ecovacs/const.py +++ b/homeassistant/components/ecovacs/const.py @@ -1,5 +1,12 @@ """Ecovacs constants.""" +from deebot_client.events import LifeSpan DOMAIN = "ecovacs" CONF_CONTINENT = "continent" + +SUPPORTED_LIFESPANS = ( + LifeSpan.BRUSH, + LifeSpan.FILTER, + LifeSpan.SIDE_BRUSH, +) diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index ca55d090ccf..86b3dc70dc1 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -8,6 +8,20 @@ } } }, + "button": { + "relocate": { + "default": "mdi:map-marker-question" + }, + "reset_lifespan_brush": { + "default": "mdi:broom" + }, + "reset_lifespan_filter": { + "default": "mdi:air-filter" + }, + "reset_lifespan_side_brush": { + "default": "mdi:broom" + } + }, "sensor": { "error": { "default": "mdi:alert-circle" diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 10dbf9c904d..16a1b4acd43 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -36,7 +36,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from .const import DOMAIN, SUPPORTED_LIFESPANS from .controller import EcovacsController from .entity import ( EcovacsCapabilityEntityDescription, @@ -154,11 +154,7 @@ LIFESPAN_ENTITY_DESCRIPTIONS = tuple( native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ) - for component in ( - LifeSpan.BRUSH, - LifeSpan.FILTER, - LifeSpan.SIDE_BRUSH, - ) + for component in SUPPORTED_LIFESPANS ) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 0ee72a942bd..56e3ec1f866 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -24,6 +24,20 @@ "name": "Mop attached" } }, + "button": { + "relocate": { + "name": "Relocate" + }, + "reset_lifespan_brush": { + "name": "Reset brush lifespan" + }, + "reset_lifespan_filter": { + "name": "Reset filter lifespan" + }, + "reset_lifespan_side_brush": { + "name": "Reset side brush lifespan" + } + }, "image": { "map": { "name": "Map" diff --git a/tests/components/ecovacs/snapshots/test_button.ambr b/tests/components/ecovacs/snapshots/test_button.ambr new file mode 100644 index 00000000000..ca61d16602a --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_button.ambr @@ -0,0 +1,173 @@ +# serializer version: 1 +# name: test_buttons[yna5x1][button.ozmo_950_relocate:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ozmo_950_relocate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relocate', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relocate', + 'unique_id': 'E1234567890000000001_relocate', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_relocate:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Relocate', + }), + 'context': , + 'entity_id': 'button.ozmo_950_relocate', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ozmo_950_reset_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_brush', + 'unique_id': 'E1234567890000000001_reset_lifespan_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Reset brush lifespan', + }), + 'context': , + 'entity_id': 'button.ozmo_950_reset_brush_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ozmo_950_reset_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_filter', + 'unique_id': 'E1234567890000000001_reset_lifespan_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Reset filter lifespan', + }), + 'context': , + 'entity_id': 'button.ozmo_950_reset_filter_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_side_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ozmo_950_reset_side_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset side brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_side_brush', + 'unique_id': 'E1234567890000000001_reset_lifespan_side_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_side_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Reset side brush lifespan', + }), + 'context': , + 'entity_id': 'button.ozmo_950_reset_side_brush_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py new file mode 100644 index 00000000000..f804e813256 --- /dev/null +++ b/tests/components/ecovacs/test_button.py @@ -0,0 +1,110 @@ +"""Tests for Ecovacs sensors.""" + +from deebot_client.command import Command +from deebot_client.commands.json import ResetLifeSpan, SetRelocationState +from deebot_client.events import LifeSpan +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button.const import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = [ + pytest.mark.usefixtures("init_integration"), + pytest.mark.freeze_time("2024-01-01 00:00:00"), +] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.BUTTON + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entities"), + [ + ( + "yna5x1", + [ + ("button.ozmo_950_relocate", SetRelocationState()), + ("button.ozmo_950_reset_brush_lifespan", ResetLifeSpan(LifeSpan.BRUSH)), + ( + "button.ozmo_950_reset_filter_lifespan", + ResetLifeSpan(LifeSpan.FILTER), + ), + ( + "button.ozmo_950_reset_side_brush_lifespan", + ResetLifeSpan(LifeSpan.SIDE_BRUSH), + ), + ], + ), + ], + ids=["yna5x1"], +) +async def test_buttons( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + entities: list[tuple[str, Command]], +) -> None: + """Test that sensor entity snapshots match.""" + assert sorted(hass.states.async_entity_ids()) == [e[0] for e in entities] + device = controller.devices[0] + for entity_id, command in entities: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_UNKNOWN + + device._execute_command.reset_mock() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device._execute_command.assert_called_with(command) + + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == "2024-01-01T00:00:00+00:00" + assert snapshot(name=f"{entity_id}:state") == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "button.ozmo_950_reset_brush_lifespan", + "button.ozmo_950_reset_filter_lifespan", + "button.ozmo_950_reset_side_brush_lifespan", + ], + ), + ], +) +async def test_disabled_by_default_buttons( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default buttons.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + assert ( + entry := entity_registry.async_get(entity_id) + ), f"Entity registry entry for {entity_id} is missing" + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 04e71567dda..11fe403ca9c 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -116,7 +116,7 @@ async def test_devices_in_dr( @pytest.mark.parametrize( ("device_fixture", "entities"), [ - ("yna5x1", 17), + ("yna5x1", 21), ], ) async def test_all_entities_loaded(