From 5467fe8ff1a1d24a2ead49ee4b260f8b717afce1 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 24 Jan 2024 17:17:43 +0100 Subject: [PATCH] Add Ecovacs select entities (#108766) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ecovacs/__init__.py | 1 + homeassistant/components/ecovacs/icons.json | 8 ++ homeassistant/components/ecovacs/select.py | 101 +++++++++++++++ homeassistant/components/ecovacs/strings.json | 20 +++ tests/components/ecovacs/conftest.py | 24 +++- .../ecovacs/snapshots/test_select.ambr | 57 +++++++++ .../components/ecovacs/test_binary_sensor.py | 22 +++- tests/components/ecovacs/test_init.py | 18 +++ tests/components/ecovacs/test_select.py | 115 ++++++++++++++++++ tests/components/ecovacs/test_sensor.py | 19 ++- 10 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/ecovacs/select.py create mode 100644 tests/components/ecovacs/snapshots/test_select.ambr create mode 100644 tests/components/ecovacs/test_select.py diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 22572d47580..5a17fd6d66f 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.SELECT, Platform.SENSOR, Platform.VACUUM, ] diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index 50c03ad2bd2..f29dd1bb1b1 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -45,6 +45,14 @@ "total_stats_cleanings": { "default": "mdi:counter" } + }, + "select": { + "water_amount": { + "default": "mdi:water" + }, + "work_mode": { + "default": "mdi:cog" + } } } } diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py new file mode 100644 index 00000000000..cd1cdd10379 --- /dev/null +++ b/homeassistant/components/ecovacs/select.py @@ -0,0 +1,101 @@ +"""Ecovacs select entity module.""" +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Generic + +from deebot_client.capabilities import CapabilitySetTypes +from deebot_client.device import Device +from deebot_client.events import WaterInfoEvent, WorkModeEvent + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +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 +from .controller import EcovacsController +from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsSelectEntityDescription( + SelectEntityDescription, + EcovacsCapabilityEntityDescription, + Generic[EventT], +): + """Ecovacs select entity description.""" + + current_option_fn: Callable[[EventT], str | None] + options_fn: Callable[[CapabilitySetTypes], list[str]] + + +ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( + EcovacsSelectEntityDescription[WaterInfoEvent]( + capability_fn=lambda caps: caps.water, + current_option_fn=lambda e: e.amount.display_name, + options_fn=lambda water: [amount.display_name for amount in water.types], + key="water_amount", + translation_key="water_amount", + entity_category=EntityCategory.CONFIG, + ), + EcovacsSelectEntityDescription[WorkModeEvent]( + capability_fn=lambda caps: caps.clean.work_mode, + current_option_fn=lambda e: e.mode.display_name, + options_fn=lambda cap: [mode.display_name for mode in cap.types], + key="work_mode", + translation_key="work_mode", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), +) + + +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 = get_supported_entitites( + controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS + ) + if entities: + async_add_entities(entities) + + +class EcovacsSelectEntity( + EcovacsDescriptionEntity[CapabilitySetTypes[EventT, str]], + SelectEntity, +): + """Ecovacs select entity.""" + + _attr_current_option: str | None = None + entity_description: EcovacsSelectEntityDescription + + def __init__( + self, + device: Device, + capability: CapabilitySetTypes[EventT, str], + entity_description: EcovacsSelectEntityDescription, + **kwargs: Any, + ) -> None: + """Initialize entity.""" + super().__init__(device, capability, entity_description, **kwargs) + self._attr_options = entity_description.options_fn(capability) + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: EventT) -> None: + self._attr_current_option = self.entity_description.current_option_fn(event) + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self._device.execute_command(self._capability.set(option)) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 7497e97e795..7a9065d7706 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -67,6 +67,26 @@ "name": "Total time cleaned" } }, + "select": { + "water_amount": { + "name": "Water amount", + "state": { + "high": "High", + "low": "Low", + "medium": "Medium", + "ultrahigh": "Ultrahigh" + } + }, + "work_mode": { + "name": "Work mode", + "state": { + "mop": "Mop", + "mop_after_vacuum": "Mop after vacuum", + "vacuum": "Vacuum", + "vacuum_and_mop": "Vacuum & mop" + } + } + }, "vacuum": { "vacuum": { "state_attributes": { diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index 65b214e6b9c..74e4d30a16d 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -9,8 +9,10 @@ from deebot_client.exceptions import ApiError from deebot_client.models import Credentials import pytest +from homeassistant.components.ecovacs import PLATFORMS from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import VALID_ENTRY_DATA @@ -103,6 +105,12 @@ def mock_device_execute() -> AsyncMock: yield mock_device_execute +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return PLATFORMS + + @pytest.fixture async def init_integration( hass: HomeAssistant, @@ -110,13 +118,21 @@ async def init_integration( mock_authenticator: Mock, mock_mqtt_client: Mock, mock_device_execute: AsyncMock, + platforms: Platform | list[Platform], ) -> MockConfigEntry: """Set up the Ecovacs integration for testing.""" - mock_config_entry.add_to_hass(hass) + if not isinstance(platforms, list): + platforms = [platforms] - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - return mock_config_entry + with patch( + "homeassistant.components.ecovacs.PLATFORMS", + platforms, + ): + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield mock_config_entry @pytest.fixture diff --git a/tests/components/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr new file mode 100644 index 00000000000..abf37a17256 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_select.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_amount:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'medium', + 'high', + 'ultrahigh', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ozmo_950_water_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water amount', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_amount', + 'unique_id': 'E1234567890000000001_water_amount', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_amount:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Water amount', + 'options': list([ + 'low', + 'medium', + 'high', + 'ultrahigh', + ]), + }), + 'context': , + 'entity_id': 'select.ozmo_950_water_amount', + 'last_changed': , + 'last_updated': , + 'state': 'ultrahigh', + }) +# --- diff --git a/tests/components/ecovacs/test_binary_sensor.py b/tests/components/ecovacs/test_binary_sensor.py index a912df60c62..f72ad6bd7e5 100644 --- a/tests/components/ecovacs/test_binary_sensor.py +++ b/tests/components/ecovacs/test_binary_sensor.py @@ -1,25 +1,32 @@ """Tests for Ecovacs binary sensors.""" -from deebot_client.event_bus import EventBus from deebot_client.events import WaterAmount, WaterInfoEvent import pytest from syrupy import SnapshotAssertion +from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController -from homeassistant.const import STATE_OFF, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .util import notify_and_wait pytestmark = [pytest.mark.usefixtures("init_integration")] +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.BINARY_SENSOR + + async def test_mop_attached( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - controller: EcovacsController, snapshot: SnapshotAssertion, + controller: EcovacsController, ) -> None: """Test mop_attached binary sensor.""" entity_id = "binary_sensor.ozmo_950_mop_attached" @@ -30,7 +37,12 @@ async def test_mop_attached( assert entity_entry == snapshot(name=f"{entity_id}-entity_entry") assert entity_entry.device_id - event_bus: EventBus = controller.devices[0].events + device = controller.devices[0] + + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + event_bus = device.events await notify_and_wait( hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=True) ) diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 103ab254650..c64d3055624 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -110,3 +110,21 @@ async def test_devices_in_dr( ) ) assert device_entry == snapshot(name=device.device_info.did) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +@pytest.mark.parametrize( + ("device_fixture", "entities"), + [ + ("yna5x1", 16), + ], +) +async def test_all_entities_loaded( + hass: HomeAssistant, + device_fixture: str, + entities: int, +) -> None: + """Test that all entities are loaded together.""" + assert ( + hass.states.async_entity_ids_count() == entities + ), f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}" diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py new file mode 100644 index 00000000000..cfe34c5a7a6 --- /dev/null +++ b/tests/components/ecovacs/test_select.py @@ -0,0 +1,115 @@ +"""Tests for Ecovacs select entities.""" + +from deebot_client.command import Command +from deebot_client.commands.json import SetWaterInfo +from deebot_client.event_bus import EventBus +from deebot_client.events import WaterAmount, WaterInfoEvent +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components import select +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + SERVICE_SELECT_OPTION, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .util import block_till_done + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.SELECT + + +async def notify_events(hass: HomeAssistant, event_bus: EventBus): + """Notify events.""" + event_bus.notify(WaterInfoEvent(WaterAmount.ULTRAHIGH)) + await block_till_done(hass, event_bus) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "select.ozmo_950_water_amount", + ], + ), + ], +) +async def test_selects( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + entity_ids: list[str], +) -> None: + """Test that select entity snapshots match.""" + assert entity_ids == sorted(hass.states.async_entity_ids()) + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_UNKNOWN + + device = controller.devices[0] + await notify_events(hass, device.events) + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + 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.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "current_state", "set_state", "command"), + [ + ( + "yna5x1", + "select.ozmo_950_water_amount", + "ultrahigh", + "low", + SetWaterInfo(WaterAmount.LOW), + ), + ], +) +async def test_selects_change( + hass: HomeAssistant, + controller: EcovacsController, + entity_id: list[str], + current_state: str, + set_state: str, + command: Command, +) -> None: + """Test that changing select entities works.""" + device = controller.devices[0] + await notify_events(hass, device.events) + + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == current_state + + device._execute_command.reset_mock() + await hass.services.async_call( + select.DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: set_state}, + blocking=True, + ) + device._execute_command.assert_called_with(command) diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 35dc0dbbe53..18d65349fa2 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -13,16 +13,23 @@ from deebot_client.events import ( import pytest from syrupy import SnapshotAssertion +from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .util import block_till_done pytestmark = [pytest.mark.usefixtures("init_integration")] +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.SENSOR + + async def notify_events(hass: HomeAssistant, event_bus: EventBus): """Notify events.""" event_bus.notify(StatsEvent(10, 300, "spotArea")) @@ -65,18 +72,20 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): ) async def test_sensors( hass: HomeAssistant, - controller: EcovacsController, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + controller: EcovacsController, entity_ids: list[str], ) -> None: """Test that sensor entity snapshots match.""" - assert entity_ids == sorted(hass.states.async_entity_ids(Platform.SENSOR)) + assert entity_ids == sorted(hass.states.async_entity_ids()) for entity_id in entity_ids: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" assert state.state == STATE_UNKNOWN - await notify_events(hass, controller.devices[0].events) + device = controller.devices[0] + await notify_events(hass, device.events) for entity_id in entity_ids: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" assert snapshot(name=f"{entity_id}:state") == state @@ -85,6 +94,8 @@ async def test_sensors( 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(