diff --git a/.coveragerc b/.coveragerc index 829b0fd9391..03428e8e305 100644 --- a/.coveragerc +++ b/.coveragerc @@ -287,6 +287,7 @@ omit = homeassistant/components/ecovacs/controller.py homeassistant/components/ecovacs/entity.py homeassistant/components/ecovacs/image.py + homeassistant/components/ecovacs/number.py homeassistant/components/ecovacs/util.py homeassistant/components/ecovacs/vacuum.py homeassistant/components/ecowitt/__init__.py diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 53c85d6d96f..c008e74471c 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -28,6 +28,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.IMAGE, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.VACUUM, diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index 86b3dc70dc1..276786ea8ac 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -22,6 +22,17 @@ "default": "mdi:broom" } }, + "number": { + "clean_count": { + "default": "mdi:counter" + }, + "volume": { + "default": "mdi:volume-high", + "state": { + "0": "mdi:volume-off" + } + } + }, "sensor": { "error": { "default": "mdi:alert-circle" diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py new file mode 100644 index 00000000000..45250ab69b1 --- /dev/null +++ b/homeassistant/components/ecovacs/number.py @@ -0,0 +1,103 @@ +"""Ecovacs number module.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic + +from deebot_client.capabilities import CapabilitySet +from deebot_client.events import CleanCountEvent, VolumeEvent + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +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, + EventT, +) +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsNumberEntityDescription( + NumberEntityDescription, + EcovacsCapabilityEntityDescription, + Generic[EventT], +): + """Ecovacs number entity description.""" + + native_max_value_fn: Callable[[EventT], float | int | None] = lambda _: None + value_fn: Callable[[EventT], float | None] + + +ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = ( + EcovacsNumberEntityDescription[VolumeEvent]( + capability_fn=lambda caps: caps.settings.volume, + value_fn=lambda e: e.volume, + native_max_value_fn=lambda e: e.maximum, + key="volume", + translation_key="volume", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=10, + native_step=1.0, + ), + EcovacsNumberEntityDescription[CleanCountEvent]( + capability_fn=lambda caps: caps.clean.count, + value_fn=lambda e: e.count, + key="clean_count", + translation_key="clean_count", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_min_value=1, + native_max_value=4, + native_step=1.0, + ), +) + + +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, EcovacsNumberEntity, ENTITY_DESCRIPTIONS + ) + if entities: + async_add_entities(entities) + + +class EcovacsNumberEntity( + EcovacsDescriptionEntity[CapabilitySet[EventT, int]], + NumberEntity, +): + """Ecovacs number entity.""" + + entity_description: EcovacsNumberEntityDescription + + 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_native_value = self.entity_description.value_fn(event) + if maximum := self.entity_description.native_max_value_fn(event): + self._attr_native_max_value = maximum + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self._device.execute_command(self._capability.set(int(value))) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index d15e8a67062..520c2ce65ca 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -61,6 +61,14 @@ "name": "Map" } }, + "number": { + "clean_count": { + "name": "Clean count" + }, + "volume": { + "name": "Volume" + } + }, "sensor": { "error": { "name": "Error", diff --git a/tests/components/ecovacs/snapshots/test_number.ambr b/tests/components/ecovacs/snapshots/test_number.ambr new file mode 100644 index 00000000000..bb0d0b35f6a --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_number.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_number_entities[yna5x1][number.ozmo_950_volume:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.ozmo_950_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'E1234567890000000001_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[yna5x1][number.ozmo_950_volume:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.ozmo_950_volume', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 3a344609961..3b43de6164e 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", 21), + ("yna5x1", 22), ], ) async def test_all_entities_loaded( diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py new file mode 100644 index 00000000000..096c62751c0 --- /dev/null +++ b/tests/components/ecovacs/test_number.py @@ -0,0 +1,156 @@ +"""Tests for Ecovacs select entities.""" + +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 + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.components.number.const import ( + ATTR_VALUE, + DOMAIN as PLATFORM_DOMAIN, + SERVICE_SET_VALUE, +) +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 + +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.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.""" + + entity_id: str + event: Event + current_state: str + set_value: int + command: Command + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "tests"), + [ + ( + "yna5x1", + [ + NumberTestCase( + "number.ozmo_950_volume", VolumeEvent(5, 11), "5", 10, SetVolume(10) + ), + ], + ), + ], + ids=["yna5x1"], +) +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + tests: list[NumberTestCase], +) -> None: + """Test that number entity snapshots match.""" + 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_UNKNOWN + + 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 == test_case.current_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)} + + device._execute_command.reset_mock() + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: test_case.set_value}, + blocking=True, + ) + device._execute_command.assert_called_with(test_case.command) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + ["number.ozmo_950_volume"], + ), + ], + ids=["yna5x1"], +) +async def test_disabled_by_default_number_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default number 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 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_volume_maximum( + hass: HomeAssistant, + controller: EcovacsController, +) -> None: + """Test volume maximum.""" + device = controller.devices[0] + event_bus = device.events + entity_id = "number.ozmo_950_volume" + assert (state := hass.states.get(entity_id)) + assert state.attributes["max"] == 10 + + event_bus.notify(VolumeEvent(5, 20)) + await block_till_done(hass, event_bus) + assert (state := hass.states.get(entity_id)) + assert state.state == "5" + assert state.attributes["max"] == 20 + + event_bus.notify(VolumeEvent(10, None)) + await block_till_done(hass, event_bus) + assert (state := hass.states.get(entity_id)) + assert state.state == "10" + assert state.attributes["max"] == 20