From 5158461dec2ccd2ea5c2cb33f34177dd08efbca9 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 23 Jul 2023 11:02:16 -0600 Subject: [PATCH] Add Number platform to Roborock (#94209) --- homeassistant/components/roborock/const.py | 8 +- homeassistant/components/roborock/device.py | 8 +- homeassistant/components/roborock/number.py | 121 ++++++++++++++++++ .../components/roborock/strings.json | 5 + tests/components/roborock/test_number.py | 38 ++++++ 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/roborock/number.py create mode 100644 tests/components/roborock/test_number.py diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 287229c9fd1..e16ab3d91ae 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -6,4 +6,10 @@ CONF_ENTRY_CODE = "code" CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" -PLATFORMS = [Platform.VACUUM, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.VACUUM, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.NUMBER, +] diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 90ca13c5146..86d578d852a 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -31,7 +31,7 @@ class RoborockEntity(Entity): @property def api(self) -> RoborockLocalClient: - """Returns the api.""" + """Return the Api.""" return self._api def get_cache(self, attribute: CacheableAttribute) -> AttributeCache: @@ -39,7 +39,9 @@ class RoborockEntity(Entity): return self._api.cache.get(attribute) async def send( - self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None + self, + command: RoborockCommand, + params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Send a command to a vacuum cleaner.""" try: @@ -87,7 +89,7 @@ class RoborockCoordinatedEntity( async def send( self, command: RoborockCommand, - params: dict[str, Any] | list[Any] | None = None, + params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Overloads normal send command but refreshes coordinator.""" res = await super().send(command, params) diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py new file mode 100644 index 00000000000..4eaf1464f89 --- /dev/null +++ b/homeassistant/components/roborock/number.py @@ -0,0 +1,121 @@ +"""Support for Roborock number.""" +import asyncio +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from roborock.api import AttributeCache +from roborock.command_cache import CacheableAttribute +from roborock.exceptions import RoborockException + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class RoborockNumberDescriptionMixin: + """Define an entity description mixin for button entities.""" + + # Gets the status of the switch + cache_key: CacheableAttribute + # Sets the status of the switch + update_value: Callable[[AttributeCache, float], Coroutine[Any, Any, dict]] + + +@dataclass +class RoborockNumberDescription( + NumberEntityDescription, RoborockNumberDescriptionMixin +): + """Class to describe an Roborock number entity.""" + + +NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [ + RoborockNumberDescription( + key="volume", + translation_key="volume", + icon="mdi:volume-source", + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + cache_key=CacheableAttribute.sound_volume, + entity_category=EntityCategory.CONFIG, + update_value=lambda cache, value: cache.update_value([int(value)]), + ) +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock number platform.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + possible_entities: list[ + tuple[RoborockDataUpdateCoordinator, RoborockNumberDescription] + ] = [ + (coordinator, description) + for coordinator in coordinators.values() + for description in NUMBER_DESCRIPTIONS + ] + # We need to check if this function is supported by the device. + results = await asyncio.gather( + *( + coordinator.api.cache.get(description.cache_key).async_value() + for coordinator, description in possible_entities + ), + return_exceptions=True, + ) + valid_entities: list[RoborockNumberEntity] = [] + for (coordinator, description), result in zip(possible_entities, results): + if result is None or isinstance(result, RoborockException): + _LOGGER.debug("Not adding entity because of %s", result) + else: + valid_entities.append( + RoborockNumberEntity( + f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", + coordinator, + description, + ) + ) + async_add_entities(valid_entities) + + +class RoborockNumberEntity(RoborockEntity, NumberEntity): + """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" + + entity_description: RoborockNumberDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockNumberDescription, + ) -> None: + """Create a number entity.""" + self.entity_description = entity_description + super().__init__(unique_id, coordinator.device_info, coordinator.api) + + @property + def native_value(self) -> float | None: + """Get native value.""" + return self.get_cache(self.entity_description.cache_key).value + + async def async_set_native_value(self, value: float) -> None: + """Set number value.""" + await self.entity_description.update_value( + self.get_cache(self.entity_description.cache_key), value + ) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 3b3e6221895..3989f08505b 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -27,6 +27,11 @@ } }, "entity": { + "number": { + "volume": { + "name": "Volume" + } + }, "sensor": { "cleaning_area": { "name": "Cleaning area" diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py new file mode 100644 index 00000000000..b660bfc2969 --- /dev/null +++ b/tests/components/roborock/test_number.py @@ -0,0 +1,38 @@ +"""Test Roborock Number platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("number.roborock_s7_maxv_volume", 3.0), + ], +) +async def test_update_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + value: float, +) -> None: + """Test allowed changing values for number entities.""" + # Ensure that the entity exist, as these test can pass even if there is no entity. + assert hass.states.get(entity_id) is not None + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ) as mock_send_message: + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once