diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index c008e74471c..ce7222f96a2 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -31,6 +31,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, Platform.VACUUM, ] diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index 276786ea8ac..b639ff81e63 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -78,6 +78,23 @@ "work_mode": { "default": "mdi:cog" } + }, + "switch": { + "advanced_mode": { + "default": "mdi:tune" + }, + "carpet_auto_fan_boost": { + "default": "mdi:fan-auto" + }, + "clean_preference": { + "default": "mdi:broom" + }, + "continuous_cleaning": { + "default": "mdi:refresh-auto" + }, + "true_detect": { + "default": "mdi:laser-pointer" + } } } } diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 520c2ce65ca..f56b65a4e46 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -132,6 +132,23 @@ } } }, + "switch": { + "advanced_mode": { + "name": "Advanced mode" + }, + "carpet_auto_fan_boost": { + "name": "Carpet auto fan speed boost" + }, + "clean_preference": { + "name": "Clean preference" + }, + "continuous_cleaning": { + "name": "Continuous cleaning" + }, + "true_detect": { + "name": "True detect" + } + }, "vacuum": { "vacuum": { "state_attributes": { diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py new file mode 100644 index 00000000000..e9e915877d8 --- /dev/null +++ b/homeassistant/components/ecovacs/switch.py @@ -0,0 +1,111 @@ +"""Ecovacs switch module.""" +from dataclasses import dataclass +from typing import Any + +from deebot_client.capabilities import CapabilitySetEnable +from deebot_client.events import EnableEvent + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +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, + EcovacsEntity, +) +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsSwitchEntityDescription( + SwitchEntityDescription, + EcovacsCapabilityEntityDescription, +): + """Ecovacs switch entity description.""" + + +ENTITY_DESCRIPTIONS: tuple[EcovacsSwitchEntityDescription, ...] = ( + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.settings.advanced_mode, + key="advanced_mode", + translation_key="advanced_mode", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.clean.continuous, + key="continuous_cleaning", + translation_key="continuous_cleaning", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.settings.carpet_auto_fan_boost, + key="carpet_auto_fan_boost", + translation_key="carpet_auto_fan_boost", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.clean.preference, + key="clean_preference", + translation_key="clean_preference", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.settings.true_detect, + key="true_detect", + translation_key="true_detect", + 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: list[EcovacsEntity] = get_supported_entitites( + controller, EcovacsSwitchEntity, ENTITY_DESCRIPTIONS + ) + if entities: + async_add_entities(entities) + + +class EcovacsSwitchEntity( + EcovacsDescriptionEntity[CapabilitySetEnable], + SwitchEntity, +): + """Ecovacs switch entity.""" + + entity_description: EcovacsSwitchEntityDescription + + _attr_is_on = False + + 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: EnableEvent) -> None: + self._attr_is_on = event.enable + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._device.execute_command(self._capability.set(True)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._device.execute_command(self._capability.set(False)) diff --git a/tests/components/ecovacs/snapshots/test_switch.ambr b/tests/components/ecovacs/snapshots/test_switch.ambr new file mode 100644 index 00000000000..75441c4f918 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_switch.ambr @@ -0,0 +1,130 @@ +# serializer version: 1 +# name: test_switch_entities[yna5x1][switch.ozmo_950_advanced_mode:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ozmo_950_advanced_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Advanced mode', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'advanced_mode', + 'unique_id': 'E1234567890000000001_advanced_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_advanced_mode:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Advanced mode', + }), + 'context': , + 'entity_id': 'switch.ozmo_950_advanced_mode', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_carpet_auto_fan_speed_boost:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ozmo_950_carpet_auto_fan_speed_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Carpet auto fan speed boost', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carpet_auto_fan_boost', + 'unique_id': 'E1234567890000000001_carpet_auto_fan_boost', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_carpet_auto_fan_speed_boost:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Carpet auto fan speed boost', + }), + 'context': , + 'entity_id': 'switch.ozmo_950_carpet_auto_fan_speed_boost', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_continuous_cleaning:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ozmo_950_continuous_cleaning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Continuous cleaning', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'continuous_cleaning', + 'unique_id': 'E1234567890000000001_continuous_cleaning', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_continuous_cleaning:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Continuous cleaning', + }), + 'context': , + 'entity_id': 'switch.ozmo_950_continuous_cleaning', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 3b43de6164e..8557ccb983c 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -118,7 +118,7 @@ async def test_devices_in_dr( @pytest.mark.parametrize( ("device_fixture", "entities"), [ - ("yna5x1", 22), + ("yna5x1", 25), ], ) async def test_all_entities_loaded( diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index 096c62751c0..3d9607fc9af 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from deebot_client.command import Command from deebot_client.commands.json import SetVolume -from deebot_client.event_bus import EventBus from deebot_client.events import Event, VolumeEvent import pytest from syrupy import SnapshotAssertion @@ -31,12 +30,6 @@ def platforms() -> Platform | list[Platform]: return Platform.NUMBER -async def notify_events(hass: HomeAssistant, event_bus: EventBus): - """Notify events.""" - event_bus.notify(VolumeEvent(5, 11)) - await block_till_done(hass, event_bus) - - @dataclass(frozen=True) class NumberTestCase: """Number test.""" diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py new file mode 100644 index 00000000000..43c5d25e18f --- /dev/null +++ b/tests/components/ecovacs/test_switch.py @@ -0,0 +1,159 @@ +"""Tests for Ecovacs select entities.""" + +from dataclasses import dataclass + +from deebot_client.command import Command +from deebot_client.commands.json import ( + SetAdvancedMode, + SetCarpetAutoFanBoost, + SetContinuousCleaning, +) +from deebot_client.events import ( + AdvancedModeEvent, + CarpetAutoFanBoostEvent, + ContinuousCleaningEvent, + Event, +) +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.components.switch.const import DOMAIN as PLATFORM_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + 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.SWITCH + + +@dataclass(frozen=True) +class SwitchTestCase: + """Switch test.""" + + entity_id: str + event: Event + command: type[Command] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "tests"), + [ + ( + "yna5x1", + [ + SwitchTestCase( + "switch.ozmo_950_advanced_mode", + AdvancedModeEvent(True), + SetAdvancedMode, + ), + SwitchTestCase( + "switch.ozmo_950_continuous_cleaning", + ContinuousCleaningEvent(True), + SetContinuousCleaning, + ), + SwitchTestCase( + "switch.ozmo_950_carpet_auto_fan_speed_boost", + CarpetAutoFanBoostEvent(True), + SetCarpetAutoFanBoost, + ), + ], + ), + ], + ids=["yna5x1"], +) +async def test_switch_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + tests: list[SwitchTestCase], +) -> None: + """Test switch entities.""" + device = controller.devices[0] + event_bus = device.events + + assert sorted(hass.states.async_entity_ids()) == sorted( + test.entity_id for test in tests + ) + for test_case in tests: + entity_id = test_case.entity_id + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_OFF + + event_bus.notify(test_case.event) + await block_till_done(hass, event_bus) + + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert snapshot(name=f"{entity_id}:state") == state + assert state.state == STATE_ON + + 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)} + + device._execute_command.reset_mock() + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device._execute_command.assert_called_with(test_case.command(False)) + + device._execute_command.reset_mock() + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device._execute_command.assert_called_with(test_case.command(True)) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "switch.ozmo_950_advanced_mode", + "switch.ozmo_950_continuous_cleaning", + "switch.ozmo_950_carpet_auto_fan_speed_boost", + ], + ), + ], + ids=["yna5x1"], +) +async def test_disabled_by_default_switch_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default switch entities.""" + 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