From 6c43ce69d3189f3b38cd69cc6d7e1a944eb2fe8f Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 25 Jul 2023 05:29:48 -0600 Subject: [PATCH] Add time platform to Roborock (#94039) --- homeassistant/components/roborock/const.py | 1 + .../components/roborock/strings.json | 8 + homeassistant/components/roborock/time.py | 150 ++++++++++++++++++ tests/components/roborock/test_time.py | 39 +++++ 4 files changed, 198 insertions(+) create mode 100644 homeassistant/components/roborock/time.py create mode 100644 tests/components/roborock/test_time.py diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index e16ab3d91ae..2fc59134d14 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -11,5 +11,6 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.TIME, Platform.NUMBER, ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 3989f08505b..cd629e208e3 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -154,6 +154,14 @@ "name": "Status indicator light" } }, + "time": { + "dnd_start_time": { + "name": "Do not disturb begin" + }, + "dnd_end_time": { + "name": "Do not disturb end" + } + }, "vacuum": { "roborock": { "state_attributes": { diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py new file mode 100644 index 00000000000..514d147d469 --- /dev/null +++ b/homeassistant/components/roborock/time.py @@ -0,0 +1,150 @@ +"""Support for Roborock time.""" +import asyncio +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import datetime +from datetime import time +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.time import TimeEntity, TimeEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import 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 RoborockTimeDescriptionMixin: + """Define an entity description mixin for time entities.""" + + # Gets the status of the switch + cache_key: CacheableAttribute + # Sets the status of the switch + update_value: Callable[[AttributeCache, datetime.time], Coroutine[Any, Any, dict]] + # Attribute from cache + get_value: Callable[[AttributeCache], datetime.time] + + +@dataclass +class RoborockTimeDescription(TimeEntityDescription, RoborockTimeDescriptionMixin): + """Class to describe an Roborock time entity.""" + + +TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ + RoborockTimeDescription( + key="dnd_start_time", + translation_key="dnd_start_time", + icon="mdi:bell-cancel", + cache_key=CacheableAttribute.dnd_timer, + update_value=lambda cache, desired_time: cache.update_value( + [ + desired_time.hour, + desired_time.minute, + cache.value.get("end_hour"), + cache.value.get("end_minute"), + ] + ), + get_value=lambda cache: datetime.time( + hour=cache.value.get("start_hour"), minute=cache.value.get("start_minute") + ), + entity_category=EntityCategory.CONFIG, + ), + RoborockTimeDescription( + key="dnd_end_time", + translation_key="dnd_end_time", + icon="mdi:bell-ring", + cache_key=CacheableAttribute.dnd_timer, + update_value=lambda cache, desired_time: cache.update_value( + [ + cache.value.get("start_hour"), + cache.value.get("start_minute"), + desired_time.hour, + desired_time.minute, + ] + ), + get_value=lambda cache: datetime.time( + hour=cache.value.get("end_hour"), minute=cache.value.get("end_minute") + ), + entity_category=EntityCategory.CONFIG, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock time platform.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + possible_entities: list[ + tuple[RoborockDataUpdateCoordinator, RoborockTimeDescription] + ] = [ + (coordinator, description) + for coordinator in coordinators.values() + for description in TIME_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[RoborockTimeEntity] = [] + 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( + RoborockTimeEntity( + f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", + coordinator, + description, + ) + ) + async_add_entities(valid_entities) + + +class RoborockTimeEntity(RoborockEntity, TimeEntity): + """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" + + entity_description: RoborockTimeDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockTimeDescription, + ) -> None: + """Create a time entity.""" + self.entity_description = entity_description + super().__init__(unique_id, coordinator.device_info, coordinator.api) + + @property + def native_value(self) -> time | None: + """Return the value reported by the time.""" + return self.entity_description.get_value( + self.get_cache(self.entity_description.cache_key) + ) + + async def async_set_value(self, value: time) -> None: + """Set the time.""" + await self.entity_description.update_value( + self.get_cache(self.entity_description.cache_key), value + ) diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py new file mode 100644 index 00000000000..6ba996ca23f --- /dev/null +++ b/tests/components/roborock/test_time.py @@ -0,0 +1,39 @@ +"""Test Roborock Time platform.""" +from datetime import time +from unittest.mock import patch + +import pytest + +from homeassistant.components.time import SERVICE_SET_VALUE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("time.roborock_s7_maxv_do_not_disturb_begin"), + ("time.roborock_s7_maxv_do_not_disturb_end"), + ], +) +async def test_update_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test turning switch entities on and off.""" + # 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( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=1, minute=1)}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once