From d0f82d6f020627152db6f619f7702d158c0e578c Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 26 Jun 2024 09:40:19 -0400 Subject: [PATCH] Add support for Dyad vacuums to Roborock (#115331) --- homeassistant/components/roborock/__init__.py | 87 ++++- .../components/roborock/binary_sensor.py | 18 +- homeassistant/components/roborock/button.py | 20 +- .../components/roborock/coordinator.py | 60 ++- homeassistant/components/roborock/device.py | 67 +++- .../components/roborock/diagnostics.py | 6 +- homeassistant/components/roborock/image.py | 15 +- homeassistant/components/roborock/models.py | 15 + homeassistant/components/roborock/number.py | 13 +- homeassistant/components/roborock/select.py | 22 +- homeassistant/components/roborock/sensor.py | 111 +++++- .../components/roborock/strings.json | 48 +++ homeassistant/components/roborock/switch.py | 13 +- homeassistant/components/roborock/time.py | 13 +- homeassistant/components/roborock/vacuum.py | 19 +- tests/components/roborock/conftest.py | 45 ++- .../roborock/snapshots/test_diagnostics.ambr | 363 ++++++++++++++++++ tests/components/roborock/test_init.py | 48 ++- tests/components/roborock/test_sensor.py | 8 +- 19 files changed, 874 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index cdbddbda95b..310c5fee92b 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Coroutine +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -12,6 +13,7 @@ from roborock import HomeDataRoom, RoborockException, RoborockInvalidCredentials from roborock.code_mappings import RoborockCategory from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 +from roborock.version_a01_apis import RoborockMqttClientA01 from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry @@ -20,13 +22,27 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS -from .coordinator import RoborockDataUpdateCoordinator +from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) +@dataclass +class RoborockCoordinators: + """Roborock coordinators type.""" + + v1: list[RoborockDataUpdateCoordinator] + a01: list[RoborockDataUpdateCoordinatorA01] + + def values( + self, + ) -> list[RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01]: + """Return all coordinators.""" + return self.v1 + self.a01 + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up roborock from a config entry.""" @@ -37,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL]) _LOGGER.debug("Getting home data") try: - home_data = await api_client.get_home_data(user_data) + home_data = await api_client.get_home_data_v2(user_data) except RoborockInvalidCredentials as err: raise ConfigEntryAuthFailed( "Invalid credentials", @@ -66,21 +82,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return_exceptions=True, ) # Valid coordinators are those where we had networking cached or we could get networking - valid_coordinators: list[RoborockDataUpdateCoordinator] = [ + v1_coords = [ coord for coord in coordinators if isinstance(coord, RoborockDataUpdateCoordinator) ] - if len(valid_coordinators) == 0: + a01_coords = [ + coord + for coord in coordinators + if isinstance(coord, RoborockDataUpdateCoordinatorA01) + ] + if len(v1_coords) + len(a01_coords) == 0: raise ConfigEntryNotReady( "No devices were able to successfully setup", translation_domain=DOMAIN, translation_key="no_coordinators", ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - coordinator.api.device_info.device.duid: coordinator - for coordinator in valid_coordinators - } + valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = valid_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -92,14 +111,19 @@ def build_setup_functions( user_data: UserData, product_info: dict[str, HomeDataProduct], home_data_rooms: list[HomeDataRoom], -) -> list[Coroutine[Any, Any, RoborockDataUpdateCoordinator | None]]: +) -> list[ + Coroutine[ + Any, + Any, + RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None, + ] +]: """Create a list of setup functions that can later be called asynchronously.""" return [ setup_device( hass, user_data, device, product_info[device.product_id], home_data_rooms ) for device in device_map.values() - if product_info[device.product_id].category == RoborockCategory.VACUUM ] @@ -109,11 +133,33 @@ async def setup_device( device: HomeDataDevice, product_info: HomeDataProduct, home_data_rooms: list[HomeDataRoom], +) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None: + """Set up a coordinator for a given device.""" + if device.pv == "1.0": + return await setup_device_v1( + hass, user_data, device, product_info, home_data_rooms + ) + if device.pv == "A01": + if product_info.category == RoborockCategory.WET_DRY_VAC: + return await setup_device_a01(hass, user_data, device, product_info) + _LOGGER.info( + "Not adding device %s because its protocol version %s or category %s is not supported", + device.duid, + device.pv, + product_info.category.name, + ) + return None + + +async def setup_device_v1( + hass: HomeAssistant, + user_data: UserData, + device: HomeDataDevice, + product_info: HomeDataProduct, + home_data_rooms: list[HomeDataRoom], ) -> RoborockDataUpdateCoordinator | None: """Set up a device Coordinator.""" - mqtt_client = RoborockMqttClientV1( - user_data, DeviceData(device, product_info.model) - ) + mqtt_client = RoborockMqttClientV1(user_data, DeviceData(device, product_info.name)) try: networking = await mqtt_client.get_networking() if networking is None: @@ -170,6 +216,21 @@ async def setup_device( return coordinator +async def setup_device_a01( + hass: HomeAssistant, + user_data: UserData, + device: HomeDataDevice, + product_info: HomeDataProduct, +) -> RoborockDataUpdateCoordinatorA01 | None: + """Set up a A01 protocol device.""" + mqtt_client = RoborockMqttClientA01( + user_data, DeviceData(device, product_info.name), product_info.category + ) + coord = RoborockDataUpdateCoordinatorA01(hass, device, product_info, mqtt_client) + await coord.async_config_entry_first_refresh() + return coord + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 00716207f7a..2fd6dd8d7d5 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -18,9 +18,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify +from . import RoborockCoordinators from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockCoordinatedEntity +from .device import RoborockCoordinatedEntityV1 @dataclass(frozen=True, kw_only=True) @@ -75,34 +76,33 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Roborock vacuum binary sensors.""" - coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( RoborockBinarySensorEntity( - f"{description.key}_{slugify(device_id)}", coordinator, description, ) - for device_id, coordinator in coordinators.items() + for coordinator in coordinators.v1 for description in BINARY_SENSOR_DESCRIPTIONS if description.value_fn(coordinator.roborock_device_info.props) is not None ) -class RoborockBinarySensorEntity(RoborockCoordinatedEntity, BinarySensorEntity): +class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity): """Representation of a Roborock binary sensor.""" entity_description: RoborockBinarySensorDescription def __init__( self, - unique_id: str, coordinator: RoborockDataUpdateCoordinator, description: RoborockBinarySensorDescription, ) -> None: """Initialize the entity.""" - super().__init__(unique_id, coordinator) + super().__init__( + f"{description.key}_{slugify(coordinator.duid)}", + coordinator, + ) self.entity_description = description @property diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index fe6dfabb56c..445033a0f6d 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -13,9 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify +from . import RoborockCoordinators from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockEntity +from .device import RoborockEntityV1 @dataclass(frozen=True, kw_only=True) @@ -68,33 +69,34 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Roborock button platform.""" - coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( RoborockButtonEntity( - f"{description.key}_{slugify(device_id)}", coordinator, description, ) - for device_id, coordinator in coordinators.items() + for coordinator in coordinators.v1 for description in CONSUMABLE_BUTTON_DESCRIPTIONS + if isinstance(coordinator, RoborockDataUpdateCoordinator) ) -class RoborockButtonEntity(RoborockEntity, ButtonEntity): +class RoborockButtonEntity(RoborockEntityV1, ButtonEntity): """A class to define Roborock button entities.""" entity_description: RoborockButtonDescription def __init__( self, - unique_id: str, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockButtonDescription, ) -> None: """Create a button entity.""" - super().__init__(unique_id, coordinator.device_info, coordinator.api) + super().__init__( + f"{entity_description.key}_{slugify(coordinator.duid)}", + coordinator.device_info, + coordinator.api, + ) self.entity_description = entity_description async def async_press(self) -> None: diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 32b7a487ac8..430e2815a7b 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -9,18 +9,21 @@ import logging from roborock import HomeDataRoom from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from roborock.roborock_typing import DeviceProp from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1 from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 +from roborock.version_a01_apis import RoborockClientA01 from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN -from .models import RoborockHassDeviceInfo, RoborockMapInfo +from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo SCAN_INTERVAL = timedelta(seconds=30) @@ -77,6 +80,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): "Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance", self.roborock_device_info.device.duid, ) + await self.api.async_disconnect() # We use the cloud api if the local api fails to connect. self.api = self.cloud_api # Right now this should never be called if the cloud api is the primary api, @@ -137,3 +141,57 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.maps[self.current_map].rooms[room.segment_id] = ( self._home_data_rooms.get(room.iot_id, "Unknown") ) + + @property + def duid(self) -> str: + """Get the unique id of the device as specified by Roborock.""" + return self.roborock_device_info.device.duid + + +class RoborockDataUpdateCoordinatorA01( + DataUpdateCoordinator[ + dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType] + ] +): + """Class to manage fetching data from the API for A01 devices.""" + + def __init__( + self, + hass: HomeAssistant, + device: HomeDataDevice, + product_info: HomeDataProduct, + api: RoborockClientA01, + ) -> None: + """Initialize.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + self.api = api + self.device_info = DeviceInfo( + name=device.name, + identifiers={(DOMAIN, device.duid)}, + manufacturer="Roborock", + model=product_info.model, + sw_version=device.fv, + ) + self.request_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol] = [ + RoborockDyadDataProtocol.STATUS, + RoborockDyadDataProtocol.POWER, + RoborockDyadDataProtocol.MESH_LEFT, + RoborockDyadDataProtocol.BRUSH_LEFT, + RoborockDyadDataProtocol.ERROR, + RoborockDyadDataProtocol.TOTAL_RUN_TIME, + ] + self.roborock_device_info = RoborockA01HassDeviceInfo(device, product_info) + + async def _async_update_data( + self, + ) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType]: + return await self.api.update_values(self.request_protocols) + + async def release(self) -> None: + """Disconnect from API.""" + await self.api.async_release() + + @property + def duid(self) -> str: + """Get the unique id of the device as specified by Roborock.""" + return self.roborock_device_info.device.duid diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 6450d849859..4a16ada5967 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -2,6 +2,7 @@ from typing import Any +from roborock.api import RoborockClient from roborock.command_cache import CacheableAttribute from roborock.containers import Consumable, Status from roborock.exceptions import RoborockException @@ -9,6 +10,7 @@ from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand from roborock.version_1_apis.roborock_client_v1 import AttributeCache, RoborockClientV1 from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 +from roborock.version_a01_apis import RoborockClientA01 from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -16,7 +18,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import RoborockDataUpdateCoordinator +from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 class RoborockEntity(Entity): @@ -28,17 +30,24 @@ class RoborockEntity(Entity): self, unique_id: str, device_info: DeviceInfo, - api: RoborockClientV1, + api: RoborockClient, ) -> None: - """Initialize the coordinated Roborock Device.""" + """Initialize the Roborock Device.""" self._attr_unique_id = unique_id self._attr_device_info = device_info self._api = api - @property - def api(self) -> RoborockClientV1: - """Returns the api.""" - return self._api + +class RoborockEntityV1(RoborockEntity): + """Representation of a base Roborock V1 Entity.""" + + _api: RoborockClientV1 + + def __init__( + self, unique_id: str, device_info: DeviceInfo, api: RoborockClientV1 + ) -> None: + """Initialize the Roborock Device.""" + super().__init__(unique_id, device_info, api) def get_cache(self, attribute: CacheableAttribute) -> AttributeCache: """Get an item from the api cache.""" @@ -66,9 +75,26 @@ class RoborockEntity(Entity): ) from err return response + @property + def api(self) -> RoborockClientV1: + """Returns the api.""" + return self._api -class RoborockCoordinatedEntity( - RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinator] + +class RoborockEntityA01(RoborockEntity): + """Representation of a base Roborock Entity for A01 devices.""" + + _api: RoborockClientA01 + + def __init__( + self, unique_id: str, device_info: DeviceInfo, api: RoborockClientA01 + ) -> None: + """Initialize the Roborock Device.""" + super().__init__(unique_id, device_info, api) + + +class RoborockCoordinatedEntityV1( + RoborockEntityV1, CoordinatorEntity[RoborockDataUpdateCoordinator] ): """Representation of a base a coordinated Roborock Entity.""" @@ -83,7 +109,7 @@ class RoborockCoordinatedEntity( | None = None, ) -> None: """Initialize the coordinated Roborock Device.""" - RoborockEntity.__init__( + RoborockEntityV1.__init__( self, unique_id=unique_id, device_info=coordinator.device_info, @@ -138,3 +164,24 @@ class RoborockCoordinatedEntity( self.coordinator.roborock_device_info.props.consumable = value self.coordinator.data = self.coordinator.roborock_device_info.props self.schedule_update_ha_state() + + +class RoborockCoordinatedEntityA01( + RoborockEntityA01, CoordinatorEntity[RoborockDataUpdateCoordinatorA01] +): + """Representation of a base a coordinated Roborock Entity.""" + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinatorA01, + ) -> None: + """Initialize the coordinated Roborock Device.""" + RoborockEntityA01.__init__( + self, + unique_id=unique_id, + device_info=coordinator.device_info, + api=coordinator.api, + ) + CoordinatorEntity.__init__(self, coordinator=coordinator) + self._attr_unique_id = unique_id diff --git a/homeassistant/components/roborock/diagnostics.py b/homeassistant/components/roborock/diagnostics.py index 79a9f0bafed..9be8b6f4d63 100644 --- a/homeassistant/components/roborock/diagnostics.py +++ b/homeassistant/components/roborock/diagnostics.py @@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant +from . import RoborockCoordinators from .const import DOMAIN -from .coordinator import RoborockDataUpdateCoordinator TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"] @@ -21,9 +21,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id] return { "config_entry": async_redact_data(config_entry.data, TO_REDACT_CONFIG), diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 9dfe8d53cd3..d1731d289db 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -21,9 +21,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify import homeassistant.util.dt as dt_util +from . import RoborockCoordinators from .const import DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_SLEEP from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockCoordinatedEntity +from .device import RoborockCoordinatedEntityV1 async def async_setup_entry( @@ -33,9 +34,7 @@ async def async_setup_entry( ) -> None: """Set up Roborock image platform.""" - coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id] drawables = [ drawable for drawable, default_value in DEFAULT_DRAWABLES.items() @@ -46,7 +45,7 @@ async def async_setup_entry( await asyncio.gather( *( create_coordinator_maps(coord, drawables) - for coord in coordinators.values() + for coord in coordinators.v1 ) ) ) @@ -54,7 +53,7 @@ async def async_setup_entry( async_add_entities(entities) -class RoborockMap(RoborockCoordinatedEntity, ImageEntity): +class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): """A class to let you visualize the map.""" _attr_has_entity_name = True @@ -70,7 +69,7 @@ class RoborockMap(RoborockCoordinatedEntity, ImageEntity): drawables: list[Drawable], ) -> None: """Initialize a Roborock map.""" - RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) + RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator) ImageEntity.__init__(self, coordinator.hass) self._attr_name = map_name self.parser = RoborockMapDataParser( @@ -184,7 +183,7 @@ async def create_coordinator_maps( api_data: bytes = map_update[0] if isinstance(map_update[0], bytes) else b"" entities.append( RoborockMap( - f"{slugify(coord.roborock_device_info.device.duid)}_map_{map_info.name}", + f"{slugify(coord.duid)}_map_{map_info.name}", coord, map_flag, api_data, diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index b516c0ee05c..4b8ab43b4a1 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -26,6 +26,21 @@ class RoborockHassDeviceInfo: } +@dataclass +class RoborockA01HassDeviceInfo: + """A model to describe A01 roborock devices.""" + + device: HomeDataDevice + product: HomeDataProduct + + def as_dict(self) -> dict[str, dict[str, Any]]: + """Turn RoborockA01HassDeviceInfo into a dictionary.""" + return { + "device": self.device.as_dict(), + "product": self.product.as_dict(), + } + + @dataclass class RoborockMapInfo: """A model to describe all information about a map we may want.""" diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index a432c527b0e..5e776d40f2d 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -17,9 +17,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify +from . import RoborockCoordinators from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockEntity +from .device import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) @@ -54,14 +55,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Roborock number platform.""" - coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id] possible_entities: list[ tuple[RoborockDataUpdateCoordinator, RoborockNumberDescription] ] = [ (coordinator, description) - for coordinator in coordinators.values() + for coordinator in coordinators.v1 for description in NUMBER_DESCRIPTIONS ] # We need to check if this function is supported by the device. @@ -81,7 +80,7 @@ async def async_setup_entry( else: valid_entities.append( RoborockNumberEntity( - f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", + f"{description.key}_{slugify(coordinator.duid)}", coordinator, description, ) @@ -89,7 +88,7 @@ async def async_setup_entry( async_add_entities(valid_entities) -class RoborockNumberEntity(RoborockEntity, NumberEntity): +class RoborockNumberEntity(RoborockEntityV1, NumberEntity): """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" entity_description: RoborockNumberDescription diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index fa7f4250804..c6073645086 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -14,9 +14,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify +from . import RoborockCoordinators from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockCoordinatedEntity +from .device import RoborockCoordinatedEntityV1 @dataclass(frozen=True, kw_only=True) @@ -69,14 +70,10 @@ async def async_setup_entry( ) -> None: """Set up Roborock select platform.""" - coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - RoborockSelectEntity( - f"{description.key}_{slugify(device_id)}", coordinator, description, options - ) - for device_id, coordinator in coordinators.items() + RoborockSelectEntity(coordinator, description, options) + for coordinator in coordinators.v1 for description in SELECT_DESCRIPTIONS if ( options := description.options_lambda( @@ -87,21 +84,24 @@ async def async_setup_entry( ) -class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity): +class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" entity_description: RoborockSelectDescription def __init__( self, - unique_id: str, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockSelectDescription, options: list[str], ) -> None: """Create a select entity.""" self.entity_description = entity_description - super().__init__(unique_id, coordinator, entity_description.protocol_listener) + super().__init__( + f"{entity_description.key}_{slugify(coordinator.duid)}", + coordinator, + entity_description.protocol_listener, + ) self._attr_options = options async def async_select_option(self, option: str) -> None: diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index acee1688cc7..3be7461d149 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -6,13 +6,14 @@ from collections.abc import Callable from dataclasses import dataclass import datetime +from roborock.code_mappings import DyadError, RoborockDyadStateCode from roborock.containers import ( RoborockDockErrorCode, RoborockDockTypeCode, RoborockErrorCode, RoborockStateCode, ) -from roborock.roborock_message import RoborockDataProtocol +from roborock.roborock_message import RoborockDataProtocol, RoborockDyadDataProtocol from roborock.roborock_typing import DeviceProp from homeassistant.components.sensor import ( @@ -32,9 +33,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import slugify +from . import RoborockCoordinators from .const import DOMAIN -from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockCoordinatedEntity +from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 +from .device import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 @dataclass(frozen=True, kw_only=True) @@ -46,6 +48,13 @@ class RoborockSensorDescription(SensorEntityDescription): protocol_listener: RoborockDataProtocol | None = None +@dataclass(frozen=True, kw_only=True) +class RoborockSensorDescriptionA01(SensorEntityDescription): + """A class that describes Roborock sensors.""" + + data_protocol: RoborockDyadDataProtocol + + def _dock_error_value_fn(properties: DeviceProp) -> str | None: if ( status := properties.status.dock_error_status @@ -193,41 +202,101 @@ SENSOR_DESCRIPTIONS = [ ] +A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [ + RoborockSensorDescriptionA01( + key="status", + data_protocol=RoborockDyadDataProtocol.STATUS, + translation_key="a01_status", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=RoborockDyadStateCode.keys(), + ), + RoborockSensorDescriptionA01( + key="battery", + data_protocol=RoborockDyadDataProtocol.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + RoborockSensorDescriptionA01( + key="filter_time_left", + data_protocol=RoborockDyadDataProtocol.MESH_LEFT, + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + translation_key="filter_time_left", + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescriptionA01( + key="brush_remaining", + data_protocol=RoborockDyadDataProtocol.BRUSH_LEFT, + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + translation_key="brush_remaining", + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescriptionA01( + key="error", + data_protocol=RoborockDyadDataProtocol.ERROR, + device_class=SensorDeviceClass.ENUM, + translation_key="a01_error", + entity_category=EntityCategory.DIAGNOSTIC, + options=DyadError.keys(), + ), + RoborockSensorDescriptionA01( + key="total_cleaning_time", + native_unit_of_measurement=UnitOfTime.MINUTES, + data_protocol=RoborockDyadDataProtocol.TOTAL_RUN_TIME, + device_class=SensorDeviceClass.DURATION, + translation_key="total_cleaning_time", + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Roborock vacuum sensors.""" - coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( RoborockSensorEntity( - f"{description.key}_{slugify(device_id)}", coordinator, description, ) - for device_id, coordinator in coordinators.items() + for coordinator in coordinators.v1 for description in SENSOR_DESCRIPTIONS if description.value_fn(coordinator.roborock_device_info.props) is not None ) + async_add_entities( + RoborockSensorEntityA01( + coordinator, + description, + ) + for coordinator in coordinators.a01 + for description in A01_SENSOR_DESCRIPTIONS + if description.data_protocol in coordinator.data + ) -class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): +class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity): """Representation of a Roborock sensor.""" entity_description: RoborockSensorDescription def __init__( self, - unique_id: str, coordinator: RoborockDataUpdateCoordinator, description: RoborockSensorDescription, ) -> None: """Initialize the entity.""" self.entity_description = description - super().__init__(unique_id, coordinator, description.protocol_listener) + super().__init__( + f"{description.key}_{slugify(coordinator.duid)}", + coordinator, + description.protocol_listener, + ) @property def native_value(self) -> StateType | datetime.datetime: @@ -235,3 +304,23 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): return self.entity_description.value_fn( self.coordinator.roborock_device_info.props ) + + +class RoborockSensorEntityA01(RoborockCoordinatedEntityA01, SensorEntity): + """Representation of a A01 Roborock sensor.""" + + entity_description: RoborockSensorDescriptionA01 + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinatorA01, + description: RoborockSensorDescriptionA01, + ) -> None: + """Initialize the entity.""" + self.entity_description = description + super().__init__(f"{description.key}_{slugify(coordinator.duid)}", coordinator) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data[self.entity_description.data_protocol] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index aaf476d7fc6..c7fc34386fd 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -95,6 +95,54 @@ } }, "sensor": { + "a01_error": { + "name": "Error", + "state": { + "none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]", + "dirty_tank_full": "Dirty tank full", + "water_level_sensor_stuck": "Water level sensor stuck.", + "clean_tank_empty": "Clean tank empty", + "clean_head_entangled": "Cleaning head entangled", + "clean_head_too_hot": "Cleaning head too hot.", + "fan_protection_e5": "Fan protection", + "cleaning_head_blocked": "Cleaning head blocked", + "temperature_protection": "Temperature protection", + "fan_protection_e4": "[%key:component::roborock::entity::sensor::a01_error::state::fan_protection_e5%]", + "fan_protection_e9": "[%key:component::roborock::entity::sensor::a01_error::state::fan_protection_e5%]", + "battery_temperature_protection_e0": "[%key:component::roborock::entity::sensor::a01_error::state::temperature_protection%]", + "battery_temperature_protection": "Battery temperature protection", + "battery_temperature_protection_2": "[%key:component::roborock::entity::sensor::a01_error::state::battery_temperature_protection%]", + "power_adapter_error": "Power adapter error", + "dirty_charging_contacts": "Clean charging contacts", + "low_battery": "[%key:component::roborock::entity::sensor::vacuum_error::state::low_battery%]", + "battery_under_10": "Battery under 10%" + } + }, + "a01_status": { + "name": "Status", + "state": { + "unknown": "[%key:component::roborock::entity::sensor::status::state::unknown%]", + "fetching": "Fetching", + "fetch_failed": "Fetch failed", + "updating": "[%key:component::roborock::entity::sensor::status::state::updating%]", + "washing": "Washing", + "ready": "Ready", + "charging": "[%key:component::roborock::entity::sensor::status::state::charging%]", + "mop_washing": "Washing mop", + "self_clean_cleaning": "Self clean cleaning", + "self_clean_deep_cleaning": "Self clean deep cleaning", + "self_clean_rinsing": "Self clean rinsing", + "self_clean_dehydrating": "Self clean drying", + "drying": "Drying", + "ventilating": "Ventilating", + "reserving": "Reserving", + "mop_washing_paused": "Mop washing paused", + "dusting_mode": "Dusting mode" + } + }, + "brush_remaining": { + "name": "Roller left" + }, "cleaning_area": { "name": "Cleaning area" }, diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 9a34060fe96..cdfc0c2dc96 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -18,9 +18,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify +from . import RoborockCoordinators from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockEntity +from .device import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) @@ -102,14 +103,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Roborock switch platform.""" - coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id] possible_entities: list[ tuple[RoborockDataUpdateCoordinator, RoborockSwitchDescription] ] = [ (coordinator, description) - for coordinator in coordinators.values() + for coordinator in coordinators.v1 for description in SWITCH_DESCRIPTIONS ] # We need to check if this function is supported by the device. @@ -129,7 +128,7 @@ async def async_setup_entry( else: valid_entities.append( RoborockSwitch( - f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", + f"{description.key}_{slugify(coordinator.duid)}", coordinator, description, ) @@ -137,7 +136,7 @@ async def async_setup_entry( async_add_entities(valid_entities) -class RoborockSwitch(RoborockEntity, SwitchEntity): +class RoborockSwitch(RoborockEntityV1, SwitchEntity): """A class to let you turn functionality on Roborock devices on and off that does need a coordinator.""" entity_description: RoborockSwitchDescription diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 6ccc2ef0b27..21ab26c0013 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -19,9 +19,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify +from . import RoborockCoordinators from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockEntity +from .device import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) @@ -118,14 +119,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Roborock time platform.""" - coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id] possible_entities: list[ tuple[RoborockDataUpdateCoordinator, RoborockTimeDescription] ] = [ (coordinator, description) - for coordinator in coordinators.values() + for coordinator in coordinators.v1 for description in TIME_DESCRIPTIONS ] # We need to check if this function is supported by the device. @@ -145,7 +144,7 @@ async def async_setup_entry( else: valid_entities.append( RoborockTimeEntity( - f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", + f"{description.key}_{slugify(coordinator.duid)}", coordinator, description, ) @@ -153,7 +152,7 @@ async def async_setup_entry( async_add_entities(valid_entities) -class RoborockTimeEntity(RoborockEntity, TimeEntity): +class RoborockTimeEntity(RoborockEntityV1, TimeEntity): """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" entity_description: RoborockTimeDescription diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 16cf518aa02..cefcc85d7f8 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -23,9 +23,10 @@ from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify +from . import RoborockCoordinators from .const import DOMAIN, GET_MAPS_SERVICE_NAME from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockCoordinatedEntity +from .device import RoborockCoordinatedEntityV1 STATE_CODE_TO_STATE = { RoborockStateCode.starting: STATE_IDLE, # "Starting" @@ -60,12 +61,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Roborock sensor.""" - coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - RoborockVacuum(slugify(device_id), coordinator) - for device_id, coordinator in coordinators.items() + RoborockVacuum(coordinator) + for coordinator in coordinators.v1 + if isinstance(coordinator, RoborockDataUpdateCoordinator) ) platform = entity_platform.async_get_current_platform() @@ -78,7 +78,7 @@ async def async_setup_entry( ) -class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): +class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): """General Representation of a Roborock vacuum.""" _attr_icon = "mdi:robot-vacuum" @@ -99,14 +99,13 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): def __init__( self, - unique_id: str, coordinator: RoborockDataUpdateCoordinator, ) -> None: """Initialize a vacuum.""" StateVacuumEntity.__init__(self) - RoborockCoordinatedEntity.__init__( + RoborockCoordinatedEntityV1.__init__( self, - unique_id, + slugify(coordinator.duid), coordinator, listener_request=[ RoborockDataProtocol.FAN_POWER, diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index d3bb0a221b1..a7ebbf10af3 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -1,9 +1,13 @@ """Global fixtures for Roborock integration.""" +from copy import deepcopy from unittest.mock import patch import pytest from roborock import RoomMapping +from roborock.code_mappings import DyadError, RoborockDyadStateCode +from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol +from roborock.version_a01_apis import RoborockMqttClientA01 from homeassistant.components.roborock.const import ( CONF_BASE_URL, @@ -28,6 +32,28 @@ from .mock_data import ( from tests.common import MockConfigEntry +class A01Mock(RoborockMqttClientA01): + """A class to mock the A01 client.""" + + def __init__(self, user_data, device_info, category) -> None: + """Initialize the A01Mock.""" + super().__init__(user_data, device_info, category) + self.protocol_responses = { + RoborockDyadDataProtocol.STATUS: RoborockDyadStateCode.drying.name, + RoborockDyadDataProtocol.POWER: 100, + RoborockDyadDataProtocol.MESH_LEFT: 111, + RoborockDyadDataProtocol.BRUSH_LEFT: 222, + RoborockDyadDataProtocol.ERROR: DyadError.none.name, + RoborockDyadDataProtocol.TOTAL_RUN_TIME: 213, + } + + async def update_values( + self, dyad_data_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol] + ): + """Update values with a predetermined response that can be overridden.""" + return {prot: self.protocol_responses[prot] for prot in dyad_data_protocols} + + @pytest.fixture(name="bypass_api_fixture") def bypass_api_fixture() -> None: """Skip calls to the API.""" @@ -35,7 +61,7 @@ def bypass_api_fixture() -> None: patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), patch("homeassistant.components.roborock.RoborockMqttClientV1._send_command"), patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", return_value=HOME_DATA, ), patch( @@ -95,6 +121,23 @@ def bypass_api_fixture() -> None: "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", return_value=b"123", ), + patch( + "homeassistant.components.roborock.coordinator.RoborockClientA01", + A01Mock, + ), + patch("homeassistant.components.roborock.RoborockMqttClientA01", A01Mock), + ): + yield + + +@pytest.fixture +def bypass_api_fixture_v1_only(bypass_api_fixture) -> None: + """Bypass api for tests that require only having v1 devices.""" + home_data_copy = deepcopy(HOME_DATA) + home_data_copy.received_devices = [] + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=home_data_copy, ): yield diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 3d78e5fd638..4318b537a2c 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -588,6 +588,369 @@ }), }), }), + '**REDACTED-2**': dict({ + 'api': dict({ + 'misc_info': dict({ + }), + }), + 'roborock_device_info': dict({ + 'device': dict({ + 'activeTime': 1700754026, + 'deviceStatus': dict({ + '10001': '{"f":"t"}', + '10002': '', + '10004': '{"sid_in_use":25,"sid_version":5,"location":"de","bom":"A.03.0291","language":"en"}', + '10005': '{"sn":"dyad_sn","ssid":"dyad_ssid","timezone":"Europe/Stockholm","posix_timezone":"CET-1CEST,M3.5.0,M10.5.0/3","ip":"1.123.12.1","mac":"b0:4a:33:33:33:33","oba":{"language":"en","name":"A.03.0291_CE","bom":"A.03.0291","location":"de","wifiplan":"EU","timezone":"CET-1CEST,M3.5.0,M10.5.0/3;Europe/Berlin","logserver":"awsde0","featureset":"0"}"}', + '10007': '{"mqttOtaData":{"mqttOtaStatus":{"status":"IDLE"}}}', + '200': 0, + '201': 3, + '202': 0, + '203': 2, + '204': 1, + '205': 1, + '206': 3, + '207': 4, + '208': 1, + '209': 100, + '210': 0, + '212': 1, + '213': 1, + '214': 513, + '215': 513, + '216': 0, + '221': 100, + '222': 0, + '223': 2, + '224': 1, + '225': 360, + '226': 0, + '227': 1320, + '228': 360, + '229': '000,000,003,000,005,000,000,000,003,000,005,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,012,003,000,000', + '230': 352, + '235': 0, + '237': 0, + }), + 'duid': '**REDACTED**', + 'f': False, + 'fv': '01.12.34', + 'iconUrl': '', + 'localKey': '**REDACTED**', + 'name': 'Dyad Pro', + 'online': True, + 'productId': 'dyad_product', + 'pv': 'A01', + 'share': True, + 'shareTime': 1701367095, + 'silentOtaSwitch': False, + 'timeZoneId': 'Europe/Stockholm', + 'tuyaMigrated': False, + }), + 'product': dict({ + 'capability': 2, + 'category': 'roborock.wetdryvac', + 'id': 'dyad_product', + 'model': 'roborock.wetdryvac.a56', + 'name': 'Roborock Dyad Pro', + 'schema': list([ + dict({ + 'code': 'drying_status', + 'id': '134', + 'mode': 'ro', + 'name': '烘干状态', + 'type': 'RAW', + }), + dict({ + 'code': 'start', + 'id': '200', + 'mode': 'rw', + 'name': '启停', + 'type': 'VALUE', + }), + dict({ + 'code': 'status', + 'id': '201', + 'mode': 'ro', + 'name': '状态', + 'type': 'VALUE', + }), + dict({ + 'code': 'self_clean_mode', + 'id': '202', + 'mode': 'rw', + 'name': '自清洁模式', + 'type': 'VALUE', + }), + dict({ + 'code': 'self_clean_level', + 'id': '203', + 'mode': 'rw', + 'name': '自清洁强度', + 'type': 'VALUE', + }), + dict({ + 'code': 'warm_level', + 'id': '204', + 'mode': 'rw', + 'name': '烘干强度', + 'type': 'VALUE', + }), + dict({ + 'code': 'clean_mode', + 'id': '205', + 'mode': 'rw', + 'name': '洗地模式', + 'type': 'VALUE', + }), + dict({ + 'code': 'suction', + 'id': '206', + 'mode': 'rw', + 'name': '吸力', + 'type': 'VALUE', + }), + dict({ + 'code': 'water_level', + 'id': '207', + 'mode': 'rw', + 'name': '水量', + 'type': 'VALUE', + }), + dict({ + 'code': 'brush_speed', + 'id': '208', + 'mode': 'rw', + 'name': '滚刷转速', + 'type': 'VALUE', + }), + dict({ + 'code': 'power', + 'id': '209', + 'mode': 'ro', + 'name': '电量', + 'type': 'VALUE', + }), + dict({ + 'code': 'countdown_time', + 'id': '210', + 'mode': 'rw', + 'name': '预约时间', + 'type': 'VALUE', + }), + dict({ + 'code': 'auto_self_clean_set', + 'id': '212', + 'mode': 'rw', + 'name': '自动自清洁', + 'type': 'VALUE', + }), + dict({ + 'code': 'auto_dry', + 'id': '213', + 'mode': 'rw', + 'name': '自动烘干', + 'type': 'VALUE', + }), + dict({ + 'code': 'mesh_left', + 'id': '214', + 'mode': 'ro', + 'name': '滤网已工作时间', + 'type': 'VALUE', + }), + dict({ + 'code': 'brush_left', + 'id': '215', + 'mode': 'ro', + 'name': '滚刷已工作时间', + 'type': 'VALUE', + }), + dict({ + 'code': 'error', + 'id': '216', + 'mode': 'ro', + 'name': '错误值', + 'type': 'VALUE', + }), + dict({ + 'code': 'mesh_reset', + 'id': '218', + 'mode': 'rw', + 'name': '滤网重置', + 'type': 'VALUE', + }), + dict({ + 'code': 'brush_reset', + 'id': '219', + 'mode': 'rw', + 'name': '滚刷重置', + 'type': 'VALUE', + }), + dict({ + 'code': 'volume_set', + 'id': '221', + 'mode': 'rw', + 'name': '音量', + 'type': 'VALUE', + }), + dict({ + 'code': 'stand_lock_auto_run', + 'id': '222', + 'mode': 'rw', + 'name': '直立解锁自动运行开关', + 'type': 'VALUE', + }), + dict({ + 'code': 'auto_self_clean_set_mode', + 'id': '223', + 'mode': 'rw', + 'name': '自动自清洁 - 模式', + 'type': 'VALUE', + }), + dict({ + 'code': 'auto_dry_mode', + 'id': '224', + 'mode': 'rw', + 'name': '自动烘干 - 模式', + 'type': 'VALUE', + }), + dict({ + 'code': 'silent_dry_duration', + 'id': '225', + 'mode': 'rw', + 'name': '静音烘干时长', + 'type': 'VALUE', + }), + dict({ + 'code': 'silent_mode', + 'id': '226', + 'mode': 'rw', + 'name': '勿扰模式开关', + 'type': 'VALUE', + }), + dict({ + 'code': 'silent_mode_start_time', + 'id': '227', + 'mode': 'rw', + 'name': '勿扰开启时间', + 'type': 'VALUE', + }), + dict({ + 'code': 'silent_mode_end_time', + 'id': '228', + 'mode': 'rw', + 'name': '勿扰结束时间', + 'type': 'VALUE', + }), + dict({ + 'code': 'recent_run_time', + 'id': '229', + 'mode': 'rw', + 'name': '近30天每天洗地时长', + 'type': 'STRING', + }), + dict({ + 'code': 'total_run_time', + 'id': '230', + 'mode': 'rw', + 'name': '洗地总时长', + 'type': 'VALUE', + }), + dict({ + 'code': 'feature_info', + 'id': '235', + 'mode': 'ro', + 'name': 'featureinfo', + 'type': 'VALUE', + }), + dict({ + 'code': 'recover_settings', + 'id': '236', + 'mode': 'rw', + 'name': '恢复初始设置', + 'type': 'VALUE', + }), + dict({ + 'code': 'dry_countdown', + 'id': '237', + 'mode': 'ro', + 'name': '烘干倒计时', + 'type': 'VALUE', + }), + dict({ + 'code': 'id_query', + 'id': '10000', + 'mode': 'rw', + 'name': 'ID点数据查询', + 'type': 'STRING', + }), + dict({ + 'code': 'f_c', + 'id': '10001', + 'mode': 'ro', + 'name': '防串货', + 'type': 'STRING', + }), + dict({ + 'code': 'schedule_task', + 'id': '10002', + 'mode': 'rw', + 'name': '定时任务', + 'type': 'STRING', + }), + dict({ + 'code': 'snd_switch', + 'id': '10003', + 'mode': 'rw', + 'name': '语音包切换', + 'type': 'STRING', + }), + dict({ + 'code': 'snd_state', + 'id': '10004', + 'mode': 'rw', + 'name': '语音包/OBA信息', + 'type': 'STRING', + }), + dict({ + 'code': 'product_info', + 'id': '10005', + 'mode': 'ro', + 'name': '产品信息', + 'type': 'STRING', + }), + dict({ + 'code': 'privacy_info', + 'id': '10006', + 'mode': 'rw', + 'name': '隐私协议', + 'type': 'STRING', + }), + dict({ + 'code': 'ota_nfo', + 'id': '10007', + 'mode': 'ro', + 'name': 'OTA info', + 'type': 'STRING', + }), + dict({ + 'code': 'rpc_req', + 'id': '10101', + 'mode': 'wo', + 'name': 'rpc req', + 'type': 'STRING', + }), + dict({ + 'code': 'rpc_resp', + 'id': '10102', + 'mode': 'ro', + 'name': 'rpc resp', + 'type': 'STRING', + }), + ]), + }), + }), + }), }), }) # --- diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index de858ef7cb2..0437ce781f1 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,7 +1,9 @@ """Test for Roborock init.""" +from copy import deepcopy from unittest.mock import patch +import pytest from roborock import RoborockException, RoborockInvalidCredentials from homeassistant.components.roborock.const import DOMAIN @@ -9,6 +11,8 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .mock_data import HOME_DATA + from tests.common import MockConfigEntry @@ -34,7 +38,7 @@ async def test_config_entry_not_ready( """Test that when coordinator update fails, entry retries.""" with ( patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", @@ -51,7 +55,7 @@ async def test_config_entry_not_ready_home_data( """Test that when we fail to get home data, entry retries.""" with ( patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", side_effect=RoborockException(), ), patch( @@ -64,7 +68,9 @@ async def test_config_entry_not_ready_home_data( async def test_get_networking_fails( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, ) -> None: """Test that when networking fails, we attempt to retry.""" with patch( @@ -76,7 +82,9 @@ async def test_get_networking_fails( async def test_get_networking_fails_none( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, ) -> None: """Test that when networking returns None, we attempt to retry.""" with patch( @@ -88,7 +96,9 @@ async def test_get_networking_fails_none( async def test_cloud_client_fails_props( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, ) -> None: """Test that if networking succeeds, but we can't communicate with the vacuum, we can't get props, fail.""" with ( @@ -106,7 +116,9 @@ async def test_cloud_client_fails_props( async def test_local_client_fails_props( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, ) -> None: """Test that if networking succeeds, but we can't communicate locally with the vacuum, we can't get props, fail.""" with patch( @@ -118,7 +130,9 @@ async def test_local_client_fails_props( async def test_fails_maps_continue( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, ) -> None: """Test that if we fail to get the maps, we still setup.""" with patch( @@ -136,7 +150,7 @@ async def test_reauth_started( ) -> None: """Test reauth flow started.""" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", side_effect=RoborockInvalidCredentials(), ): await async_setup_component(hass, DOMAIN, {}) @@ -145,3 +159,21 @@ async def test_reauth_started( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_not_supported_protocol( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we output a message on incorrect protocol.""" + home_data_copy = deepcopy(HOME_DATA) + home_data_copy.received_devices[0].pv = "random" + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=home_data_copy, + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert "because its protocol version random" in caplog.text diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 88ed6e1098c..e608895ca43 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -21,7 +21,7 @@ from tests.common import MockConfigEntry async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 28 + assert len(hass.states.async_all("sensor")) == 34 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -54,6 +54,12 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state == "2023-01-01T03:43:58+00:00" ) + assert hass.states.get("sensor.dyad_pro_status").state == "drying" + assert hass.states.get("sensor.dyad_pro_battery").state == "100" + assert hass.states.get("sensor.dyad_pro_filter_time_left").state == "111" + assert hass.states.get("sensor.dyad_pro_roller_left").state == "222" + assert hass.states.get("sensor.dyad_pro_error").state == "none" + assert hass.states.get("sensor.dyad_pro_total_cleaning_time").state == "213" async def test_listener_update(