From ec2773bf573fb778cec8684521dce81b2affadbf Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 18 Oct 2025 20:45:53 +0000 Subject: [PATCH 01/23] First pass at roborock rewrite Fix is cleaning Update images reorder setup functions Revert coordinator and entity inheritance changes Revert device changes Revert some changes to reduce diffs Revert changes in switch Revert time entity changes Update coordinator and fix image and select tests Update to new import paths Update to new import locations Update image tests Update binary sensor tests Update clean summary and remove unnecessary waits Make more init tests pass Update sensor and select tests and many coordinator improvements Use new diagnostics api Fix unique device identifiers in tests Remove _get_update_interval Fix room sensor test Update TODOs Refresh status before attempting home discovery Refresh all traits in coordinator Update coordinator behavior to handle busy device Add back partial failure behavior Remove todo and replace with create_home_data_from_api_client Reduce unnecessary diffs and improve failure handling Remove async-timeout forbidden package --- homeassistant/components/roborock/__init__.py | 238 +- .../components/roborock/binary_sensor.py | 16 +- homeassistant/components/roborock/button.py | 58 +- .../components/roborock/coordinator.py | 576 ++-- .../components/roborock/diagnostics.py | 12 +- homeassistant/components/roborock/entity.py | 105 +- homeassistant/components/roborock/image.py | 9 +- homeassistant/components/roborock/models.py | 34 +- homeassistant/components/roborock/number.py | 75 +- homeassistant/components/roborock/select.py | 69 +- homeassistant/components/roborock/sensor.py | 67 +- homeassistant/components/roborock/switch.py | 113 +- homeassistant/components/roborock/time.py | 139 +- homeassistant/components/roborock/vacuum.py | 52 +- tests/components/roborock/conftest.py | 348 ++- tests/components/roborock/mock_data.py | 17 +- .../roborock/snapshots/test_diagnostics.ambr | 2326 ++++++++--------- tests/components/roborock/test_button.py | 32 +- tests/components/roborock/test_config_flow.py | 17 +- tests/components/roborock/test_coordinator.py | 319 --- tests/components/roborock/test_diagnostics.py | 1 - tests/components/roborock/test_image.py | 234 +- tests/components/roborock/test_init.py | 261 +- tests/components/roborock/test_number.py | 57 +- tests/components/roborock/test_select.py | 95 +- tests/components/roborock/test_sensor.py | 57 +- tests/components/roborock/test_switch.py | 56 +- tests/components/roborock/test_time.py | 37 +- tests/components/roborock/test_vacuum.py | 184 +- 29 files changed, 2355 insertions(+), 3249 deletions(-) delete mode 100644 tests/components/roborock/test_coordinator.py diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 77764e1b7007d7..ff24dc3f4cf1c4 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -9,16 +9,20 @@ from typing import Any from roborock import ( - HomeDataRoom, RoborockException, RoborockInvalidCredentials, RoborockInvalidUserAgreement, RoborockNoUserAgreement, -) +)<<<<<<< HEAD from roborock.data 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 roborock.devices.cache import InMemoryCache +from roborock.devices.device import RoborockDevice +from roborock.devices.device_manager import ( + HomeDataApi, + create_device_manager, + create_home_data_from_api_client, +) +>rom roborock.web_api import RoborockApiClient from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -32,6 +36,9 @@ RoborockCoordinators, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, + RoborockDyadUpdateCoordinator, + RoborockZeoUpdateCoordinator, + UserApiClient, ) from .roborock_storage import async_remove_map_storage @@ -49,9 +56,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> entry.data[CONF_BASE_URL], session=async_get_clientsession(hass), ) - _LOGGER.debug("Getting home data") + home_data_api: HomeDataApi = create_home_data_from_api_client(api_client, user_data) try: - home_data = await api_client.get_home_data_v3(user_data) + device_manager = await create_device_manager( + user_data, + home_data_api, + # This can be improved with a local cache of network information and home + # information to allow local-only startup in the future. + InMemoryCache(), + ) + devices = await device_manager.get_devices() except RoborockInvalidCredentials as err: raise ConfigEntryAuthFailed( "Invalid credentials", @@ -76,28 +90,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_key="home_data_fail", ) from err - _LOGGER.debug("Got home data %s", home_data) - all_devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices - device_map: dict[str, HomeDataDevice] = { - device.duid: device for device in all_devices - } - product_info: dict[str, HomeDataProduct] = { - product.id: product for product in home_data.products - } - # Get a Coordinator if the device is available or if we have connected to the device before + _LOGGER.debug("Device manager found %d devices", len(devices)) + for device in devices: + entry.async_on_unload(device.close) + coordinators = await asyncio.gather( - *build_setup_functions( - hass, - entry, - device_map, - user_data, - product_info, - home_data.rooms, - api_client, - ), + *build_setup_functions(hass, entry, devices, user_data, api_client), return_exceptions=True, ) - # Valid coordinators are those where we had networking cached or we could get networking v1_coords = [ coord for coord in coordinators @@ -114,9 +114,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_domain=DOMAIN, translation_key="no_coordinators", ) - valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) - await asyncio.gather( - *(coord.refresh_coordinator_map() for coord in valid_coordinators.v1) + valid_coordinators = RoborockCoordinators( + api_client=UserApiClient(api_client, user_data), + v1=v1_coords, + a01=a01_coords, ) async def on_stop(_: Any) -> None: @@ -138,6 +139,22 @@ async def on_stop(_: Any) -> None: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _remove_stale_devices(hass, entry, devices) + + return True + + +def _remove_stale_devices( + hass: HomeAssistant, + entry: RoborockConfigEntry, + devices: list[RoborockDevice], +) -> None: + """Remove stale devices from the device registry. + + The devices that are no longer in the account are removed from the device registry. + The API returns all devices, even if they are offline. + """ + device_map: dict[str, RoborockDevice] = {device.duid: device for device in devices} device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry( device_registry, config_entry_id=entry.entry_id @@ -159,8 +176,6 @@ async def on_stop(_: Any) -> None: remove_config_entry_id=entry.entry_id, ) - return True - async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: """Migrate old configuration entries to the new format.""" @@ -190,10 +205,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) - def build_setup_functions( hass: HomeAssistant, entry: RoborockConfigEntry, - device_map: dict[str, HomeDataDevice], + devices: list[RoborockDevice], user_data: UserData, - product_info: dict[str, HomeDataProduct], - home_data_rooms: list[HomeDataRoom], api_client: RoborockApiClient, ) -> list[ Coroutine[ @@ -202,135 +215,46 @@ def build_setup_functions( RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None, ] ]: - """Create a list of setup functions that can later be called asynchronously.""" - return [ - setup_device( - hass, - entry, - user_data, - device, - product_info[device.product_id], - home_data_rooms, - api_client, - ) - for device in device_map.values() - ] - + """Create coordinators for all devices.""" + coordinators: list[ + RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 + ] = [] + for device in devices: + _LOGGER.debug("Creating device %s: %s", device.name, device) + if device.v1_properties is not None: + coordinators.append( + RoborockDataUpdateCoordinator(hass, entry, device, device.v1_properties) + ) + elif device.dyad is not None: + coordinators.append( + RoborockDyadUpdateCoordinator(hass, entry, device, device.dyad) + ) + elif device.zeo is not None: + coordinators.append( + RoborockZeoUpdateCoordinator(hass, entry, device, device.zeo) + ) + else: + _LOGGER.warning( + "Not adding device %s because its protocol version %s or category %s is not supported", + device.duid, + device.device_info.pv, + device.product.category.name, + ) -async def setup_device( - hass: HomeAssistant, - entry: RoborockConfigEntry, - user_data: UserData, - device: HomeDataDevice, - product_info: HomeDataProduct, - home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, -) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None: - """Set up a coordinator for a given device.""" - if device.pv == "1.0": - return await setup_device_v1( - hass, entry, user_data, device, product_info, home_data_rooms, api_client - ) - if device.pv == "A01": - return await setup_device_a01(hass, entry, user_data, device, product_info) - _LOGGER.warning( - "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 + return [setup_coordinator(coordinator) for coordinator in coordinators] -async def setup_device_v1( - hass: HomeAssistant, - entry: RoborockConfigEntry, - user_data: UserData, - device: HomeDataDevice, - product_info: HomeDataProduct, - home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, -) -> RoborockDataUpdateCoordinator | None: - """Set up a device Coordinator.""" - mqtt_client = await hass.async_add_executor_job( - RoborockMqttClientV1, user_data, DeviceData(device, product_info.model) - ) - try: - await mqtt_client.async_connect() - networking = await mqtt_client.get_networking() - if networking is None: - # If the api does not return an error but does return None for - # get_networking - then we need to go through cache checking. - raise RoborockException("Networking request returned None.") # noqa: TRY301 - except RoborockException as err: - _LOGGER.warning( - "Not setting up %s because we could not get the network information of the device. " - "Please confirm it is online and the Roborock servers can communicate with it", - device.name, - ) - _LOGGER.debug(err) - await mqtt_client.async_release() - raise - coordinator = RoborockDataUpdateCoordinator( - hass, - entry, - device, - networking, - product_info, - mqtt_client, - home_data_rooms, - api_client, - user_data, - ) +async def setup_coordinator( + coordinator: RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01, +) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None: + """Set up a single coordinator.""" try: await coordinator.async_config_entry_first_refresh() - except ConfigEntryNotReady as ex: + except ConfigEntryNotReady: await coordinator.async_shutdown() - if isinstance(coordinator.api, RoborockMqttClientV1): - _LOGGER.warning( - "Not setting up %s because the we failed to get data for the first time using the online client. " - "Please ensure your Home Assistant instance can communicate with this device. " - "You may need to open firewall instances on your Home Assistant network and on your Vacuum's network", - device.name, - ) - # Most of the time if we fail to connect using the mqtt client, the problem is due to firewall, - # but in case if it isn't, the error can be included in debug logs for the user to grab. - if coordinator.last_exception: - _LOGGER.debug(coordinator.last_exception) - raise coordinator.last_exception from ex - elif coordinator.last_exception: - # If this is reached, we have verified that we can communicate with the Vacuum locally, - # so if there is an error here - it is not a communication issue but some other problem - extra_error = f"Please create an issue with the following error included: {coordinator.last_exception}" - _LOGGER.warning( - "Not setting up %s because the coordinator failed to get data for the first time using the " - "offline client %s", - device.name, - extra_error, - ) - raise coordinator.last_exception from ex - return coordinator - - -async def setup_device_a01( - hass: HomeAssistant, - entry: RoborockConfigEntry, - user_data: UserData, - device: HomeDataDevice, - product_info: HomeDataProduct, -) -> RoborockDataUpdateCoordinatorA01 | None: - """Set up a A01 protocol device.""" - mqtt_client = await hass.async_add_executor_job( - RoborockMqttClientA01, - user_data, - DeviceData(device, product_info.model), - product_info.category, - ) - coord = RoborockDataUpdateCoordinatorA01( - hass, entry, device, product_info, mqtt_client - ) - await coord.async_config_entry_first_refresh() - return coord + raise + else: + return coordinator async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 90583619d385a7..687d3a1f72532d 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from roborock.data import RoborockStateCode -from roborock.roborock_typing import DeviceProp from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -19,6 +18,7 @@ from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +from .models import DeviceState PARALLEL_UPDATES = 0 @@ -27,9 +27,11 @@ class RoborockBinarySensorDescription(BinarySensorEntityDescription): """A class that describes Roborock binary sensors.""" - value_fn: Callable[[DeviceProp], bool | int | None] - # If it is a dock entity + value_fn: Callable[[DeviceState], bool | int | None] + """A function that extracts the sensor value from DeviceState.""" + is_dock_entity: bool = False + """Whether this sensor is for the dock.""" BINARY_SENSOR_DESCRIPTIONS = [ @@ -92,7 +94,7 @@ async def async_setup_entry( ) for coordinator in config_entry.runtime_data.v1 for description in BINARY_SENSOR_DESCRIPTIONS - if description.value_fn(coordinator.roborock_device_info.props) is not None + if description.value_fn(coordinator.data) is not None ) @@ -117,8 +119,4 @@ def __init__( @property def is_on(self) -> bool: """Return the value reported by the sensor.""" - return bool( - self.entity_description.value_fn( - self.coordinator.roborock_device_info.props - ) - ) + return bool(self.entity_description.value_fn(self.coordinator.data)) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index fea38524fe0c9a..f36cb4b699edee 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -5,18 +5,28 @@ import asyncio from dataclasses import dataclass import itertools +import logging from typing import Any -from roborock.roborock_typing import RoborockCommand +from roborock.devices.traits.v1.consumeable import ConsumableAttribute +from roborock.exceptions import RoborockException from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import ( + RoborockConfigEntry, + RoborockDataUpdateCoordinator, + UserApiClient, +) from .entity import RoborockEntity, RoborockEntityV1 +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 0 @@ -24,40 +34,35 @@ class RoborockButtonDescription(ButtonEntityDescription): """Describes a Roborock button entity.""" - command: RoborockCommand - param: list | dict | None + attribute: ConsumableAttribute CONSUMABLE_BUTTON_DESCRIPTIONS = [ RoborockButtonDescription( key="reset_sensor_consumable", translation_key="reset_sensor_consumable", - command=RoborockCommand.RESET_CONSUMABLE, - param=["sensor_dirty_time"], + attribute=ConsumableAttribute.SENSOR_DIRTY_TIME, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), RoborockButtonDescription( key="reset_air_filter_consumable", translation_key="reset_air_filter_consumable", - command=RoborockCommand.RESET_CONSUMABLE, - param=["filter_work_time"], + attribute=ConsumableAttribute.FILTER_WORK_TIME, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), RoborockButtonDescription( key="reset_side_brush_consumable", translation_key="reset_side_brush_consumable", - command=RoborockCommand.RESET_CONSUMABLE, - param=["side_brush_work_time"], + attribute=ConsumableAttribute.SIDE_BRUSH_WORK_TIME, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), RoborockButtonDescription( key="reset_main_brush_consumable", translation_key="reset_main_brush_consumable", - command=RoborockCommand.RESET_CONSUMABLE, - param=["main_brush_work_time"], + attribute=ConsumableAttribute.MAIN_BRUSH_WORK_TIME, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), @@ -70,8 +75,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock button platform.""" + _LOGGER.debug("Setting up Roborock button platform") + api_client = config_entry.runtime_data.api_client routines_lists = await asyncio.gather( - *[coordinator.get_routines() for coordinator in config_entry.runtime_data.v1], + *[ + api_client.get_routines(coordinator.duid) + for coordinator in config_entry.runtime_data.v1 + ], ) async_add_entities( itertools.chain( @@ -91,6 +101,7 @@ async def async_setup_entry( key=str(routine.id), name=routine.name, ), + api_client=api_client, ) for coordinator, routines in zip( config_entry.runtime_data.v1, routines_lists, strict=True @@ -115,13 +126,24 @@ def __init__( super().__init__( f"{entity_description.key}_{coordinator.duid_slug}", coordinator.device_info, - coordinator.api, + api=coordinator.properties_api.command, ) self.entity_description = entity_description + self._consumable = coordinator.properties_api.consumables async def async_press(self) -> None: """Press the button.""" - await self.send(self.entity_description.command, self.entity_description.param) + _LOGGER.debug("Pressing button %s", self._consumable) + try: + await self._consumable.reset_consumable(self.entity_description.attribute) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "RESET_CONSUMABLE", + }, + ) from err class RoborockRoutineButtonEntity(RoborockEntity, ButtonEntity): @@ -133,17 +155,17 @@ def __init__( self, coordinator: RoborockDataUpdateCoordinator, entity_description: ButtonEntityDescription, + api_client: UserApiClient, ) -> None: """Create a button entity.""" super().__init__( f"{entity_description.key}_{coordinator.duid_slug}", coordinator.device_info, - coordinator.api, ) self._routine_id = int(entity_description.key) - self._coordinator = coordinator + self._api_client = api_client self.entity_description = entity_description async def async_press(self, **kwargs: Any) -> None: """Press the button.""" - await self._coordinator.execute_routines(self._routine_id) + await self._api_client.execute_routines(self._routine_id) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 1d22337db8cc23..7a2c93bb01b1dc 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -5,32 +5,17 @@ import asyncio from dataclasses import dataclass from datetime import timedelta -import io import logging +from typing import Any, TypeVar from propcache.api import cached_property -from roborock import HomeDataRoom -from roborock.data import ( - DeviceData, - HomeDataDevice, - HomeDataProduct, - HomeDataScene, - NetworkInfo, - RoborockCategory, - UserData, -) -from roborock.exceptions import RoborockException +from roborock.data import HomeDataScene, RoborockCategory, UserData +from roborock.devices.device import RoborockDevice +from roborock.devices.traits.a01 import DyadApi, ZeoApi +from roborock.devices.traits.v1 import PropertiesApi +from roborock.exceptions import RoborockDeviceBusy, 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 roborock.web_api import RoborockApiClient -from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor -from vacuum_map_parser_base.config.image_config import ImageConfig -from vacuum_map_parser_base.config.size import Size, Sizes -from vacuum_map_parser_base.map_data import MapData -from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS @@ -49,20 +34,14 @@ from .const import ( A01_UPDATE_INTERVAL, - CONF_SHOW_BACKGROUND, - DEFAULT_DRAWABLES, DOMAIN, - DRAWABLES, IMAGE_CACHE_INTERVAL, - MAP_FILE_FORMAT, - MAP_SCALE, - MAP_SLEEP, V1_CLOUD_IN_CLEANING_INTERVAL, V1_CLOUD_NOT_CLEANING_INTERVAL, V1_LOCAL_IN_CLEANING_INTERVAL, V1_LOCAL_NOT_CLEANING_INTERVAL, ) -from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo +from .models import DeviceState, RoborockMapInfo from .roborock_storage import RoborockMapStorage SCAN_INTERVAL = timedelta(seconds=30) @@ -74,6 +53,7 @@ class RoborockCoordinators: """Roborock coordinators type.""" + api_client: UserApiClient v1: list[RoborockDataUpdateCoordinator] a01: list[RoborockDataUpdateCoordinatorA01] @@ -87,7 +67,7 @@ def values( type RoborockConfigEntry = ConfigEntry[RoborockCoordinators] -class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): +class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]): """Class to manage fetching data from the API.""" config_entry: RoborockConfigEntry @@ -96,13 +76,8 @@ def __init__( self, hass: HomeAssistant, config_entry: RoborockConfigEntry, - device: HomeDataDevice, - device_networking: NetworkInfo, - product_info: HomeDataProduct, - cloud_api: RoborockMqttClientV1, - home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, - user_data: UserData, + device: RoborockDevice, + properties_api: PropertiesApi, ) -> None: """Initialize.""" super().__init__( @@ -110,64 +85,32 @@ def __init__( _LOGGER, config_entry=config_entry, name=DOMAIN, - # Assume we can use the local api. + # Update interval is adjusted in `_async_update_data` update_interval=V1_LOCAL_NOT_CLEANING_INTERVAL, ) - self.roborock_device_info = RoborockHassDeviceInfo( - device, - device_networking, - product_info, - DeviceProp(), + self._device = device + self.properties_api = properties_api + _LOGGER.debug( + "Creating coordinator for device %s - %s", device.duid, device.name ) - device_data = DeviceData(device, product_info.model, device_networking.ip) - self.api: RoborockLocalClientV1 | RoborockMqttClientV1 = RoborockLocalClientV1( - device_data, queue_timeout=5 - ) - self.cloud_api = cloud_api self.device_info = DeviceInfo( - name=self.roborock_device_info.device.name, + name=self._device.device_info.name, identifiers={(DOMAIN, self.duid)}, manufacturer="Roborock", - model=self.roborock_device_info.product.model, - model_id=self.roborock_device_info.product.model, - sw_version=self.roborock_device_info.device.fv, + model=self._device.product.model, + model_id=self._device.product.model, + sw_version=self._device.device_info.fv, ) self.current_map: int | None = None - - if mac := self.roborock_device_info.network_info.mac: + if mac := properties_api.network_info.mac: self.device_info[ATTR_CONNECTIONS] = { (dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)) } # Maps from map flag to map name self.maps: dict[int, RoborockMapInfo] = {} - self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} self.map_storage = RoborockMapStorage( hass, self.config_entry.entry_id, self.duid_slug ) - self._user_data = user_data - self._api_client = api_client - self._is_cloud_api = False - drawables = [ - drawable - for drawable, default_value in DEFAULT_DRAWABLES.items() - if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) - ] - colors = ColorsPalette() - if not config_entry.options.get(CONF_SHOW_BACKGROUND, False): - colors = ColorsPalette({SupportedColor.MAP_OUTSIDE: (0, 0, 0, 0)}) - self.map_parser = RoborockMapDataParser( - colors, - Sizes( - { - k: v * MAP_SCALE - for k, v in Sizes.SIZES.items() - if k != Size.MOP_PATH_WIDTH - } - ), - drawables, - ImageConfig(scale=MAP_SCALE), - [], - ) self.last_update_state: str | None = None @cached_property @@ -177,59 +120,41 @@ def dock_device_info(self) -> DeviceInfo: This must happen after the coordinator does the first update. Which will be the case when this is called. """ - dock_type = self.roborock_device_info.props.status.dock_type + dock_type = self.properties_api.status.dock_type return DeviceInfo( - name=f"{self.roborock_device_info.device.name} Dock", + name=f"{self._device.device_info.name} Dock", identifiers={(DOMAIN, f"{self.duid}_dock")}, manufacturer="Roborock", - model=f"{self.roborock_device_info.product.model} Dock", + model=f"{self._device.product.model} Dock", model_id=str(dock_type.value) if dock_type is not None else "Unknown", - sw_version=self.roborock_device_info.device.fv, + sw_version=self._device.device_info.fv, ) - def parse_map_data_v1( - self, map_bytes: bytes - ) -> tuple[bytes | None, MapData | None]: - """Parse map_bytes and return MapData and the image.""" - try: - parsed_map = self.map_parser.parse(map_bytes) - except (IndexError, ValueError) as err: - _LOGGER.debug("Exception when parsing map contents: %s", err) - return None, None - if parsed_map.image is None: - return None, None - img_byte_arr = io.BytesIO() - parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) - return img_byte_arr.getvalue(), parsed_map - async def _async_setup(self) -> None: """Set up the coordinator.""" - # Verify we can communicate locally - if we can't, switch to cloud api - await self._verify_api() - self.api.is_available = True - + # This will either read from the cache or load information about the + # home and cache the detail. The device can only load information for + # the current map so from here forward. + await self.properties_api.status.refresh() try: - maps = await self.api.get_multi_maps_list() - except RoborockException as err: - _LOGGER.debug("Failed to get maps: %s", err) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="map_failure", - translation_placeholders={"error": str(err)}, - ) from err - # Rooms names populated later with calls to `set_current_map_rooms` for each map - roborock_maps = maps.map_info if (maps and maps.map_info) else () + await self.properties_api.home.discover_home() + except RoborockDeviceBusy: + _LOGGER.info("Home discovery skipped while device is busy/cleaning") + + roborock_maps = list((self.properties_api.home.home_cache or {}).values()) + # Handle loading any stored images for the current or formerly active + # maps here. A single active map for each device is refreshed regularly, + # and the others maps are served from the cache. stored_images = await asyncio.gather( *[ - self.map_storage.async_load_map(roborock_map.mapFlag) + self.map_storage.async_load_map(roborock_map.map_flag) for roborock_map in roborock_maps ] ) self.maps = { - roborock_map.mapFlag: RoborockMapInfo( - flag=roborock_map.mapFlag, - name=roborock_map.name or f"Map {roborock_map.mapFlag}", - rooms={}, + roborock_map.map_flag: RoborockMapInfo( + flag=roborock_map.map_flag, + name=roborock_map.name or f"Map {roborock_map.map_flag}", image=image, last_updated=dt_util.utcnow() - IMAGE_CACHE_INTERVAL, map_data=None, @@ -245,26 +170,16 @@ async def update_map(self) -> None: # This exists as a safeguard/ to keep mypy happy. return try: - response = await self.cloud_api.get_map_v1() + await self.properties_api.map_content.refresh() except RoborockException as ex: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="map_failure", ) from ex - if not isinstance(response, bytes): - _LOGGER.debug("Failed to parse map contents: %s", response) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="map_failure", - ) - parsed_image, parsed_map = self.parse_map_data_v1(response) - if parsed_image is None or parsed_map is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="map_failure", - ) current_roborock_map_info = self.maps[self.current_map] - if parsed_image != self.maps[self.current_map].image: + parsed_image = self.properties_api.map_content.image_content + parsed_map = self.properties_api.map_content.map_data + if parsed_image is not None and parsed_image != current_roborock_map_info.image: await self.map_storage.async_save_map( self.current_map, parsed_image, @@ -273,126 +188,156 @@ async def update_map(self) -> None: current_roborock_map_info.last_updated = dt_util.utcnow() current_roborock_map_info.map_data = parsed_map - async def _verify_api(self) -> None: - """Verify that the api is reachable. If it is not, switch clients.""" - if isinstance(self.api, RoborockLocalClientV1): - try: - await self.api.async_connect() - await self.api.ping() - async_delete_issue( - self.hass, DOMAIN, f"cloud_api_used_{self.duid_slug}" - ) - except RoborockException: - _LOGGER.warning( - "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.duid, - ) - await self.api.async_disconnect() - # We use the cloud api if the local api fails to connect. - self.api = self.cloud_api - self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL - self._is_cloud_api = True - async_create_issue( - self.hass, - DOMAIN, - f"cloud_api_used_{self.duid_slug}", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="cloud_api_used", - translation_placeholders={ - "device_name": self.roborock_device_info.device.name - }, - learn_more_url="https://www.home-assistant.io/integrations/roborock/#the-integration-tells-me-it-cannot-reach-my-vacuum-and-is-using-the-cloud-api-and-that-this-is-not-supported-or-i-am-having-any-networking-issues", - ) - - # Right now this should never be called if the cloud api is the primary api, - # but in the future if it is, a new else should be added. - async def async_shutdown(self) -> None: """Shutdown the coordinator.""" await super().async_shutdown() - await asyncio.gather( - self.map_storage.flush(), - self.api.async_release(), - self.cloud_api.async_release(), - ) + await self.map_storage.flush() async def _update_device_prop(self) -> None: """Update device properties.""" - if (device_prop := await self.api.get_prop()) is not None: - self.roborock_device_info.props.update(device_prop) + await _refresh_traits( + [ + trait + for trait in ( + self.properties_api.status, + self.properties_api.consumables, + self.properties_api.clean_summary, + self.properties_api.dnd, + self.properties_api.dust_collection_mode, + self.properties_api.wash_towel_mode, + self.properties_api.smart_wash_params, + self.properties_api.sound_volume, + self.properties_api.child_lock, + self.properties_api.dust_collection_mode, + self.properties_api.flow_led_status, + self.properties_api.valley_electricity_timer, + ) + if trait is not None + ] + ) + _LOGGER.debug("Updated device properties") - async def _async_update_data(self) -> DeviceProp: + async def _async_update_data(self) -> DeviceState: """Update data via library.""" try: # Update device props and standard api information await self._update_device_prop() - # Set the new map id from the updated device props - self._set_current_map() - # Get the rooms for that map id. - - # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL - # since the last map update, you can update the map. - new_status = self.roborock_device_info.props.status - if ( - self.current_map is not None - and (current_map := self.maps.get(self.current_map)) - and ( - ( - new_status.in_cleaning - and (dt_util.utcnow() - current_map.last_updated) - > IMAGE_CACHE_INTERVAL - ) - or self.last_update_state != new_status.state_name + except RoborockException as ex: + _LOGGER.debug("Failed to update data: %s", ex) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) from ex + + # Set the new map id from the updated device props + self._set_current_map() + # Get the rooms for that map id. + + # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL + # since the last map update, you can update the map. + new_status = self.properties_api.status + if ( + self.current_map is not None + and (current_map := self.maps.get(self.current_map)) + and ( + ( + new_status.in_cleaning + and (dt_util.utcnow() - current_map.last_updated) + > IMAGE_CACHE_INTERVAL ) - ): - try: - await self.update_map() - except HomeAssistantError as err: - _LOGGER.debug("Failed to update map: %s", err) - await self.set_current_map_rooms() + or self.last_update_state != new_status.state_name + ) + ): + _LOGGER.debug("Updating map for map id %s", self.current_map) + try: + await self.update_map() + except HomeAssistantError as err: + _LOGGER.debug("Failed to update map: %s", err) + + try: + await self.properties_api.home.discover_home() + await self.properties_api.home.refresh() + except RoborockDeviceBusy as ex: + _LOGGER.debug("Not refreshing home while device is busy: %s", ex) except RoborockException as ex: _LOGGER.debug("Failed to update data: %s", ex) raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_data_fail", ) from ex - if self.roborock_device_info.props.status.in_cleaning: - if self._is_cloud_api: - self.update_interval = V1_CLOUD_IN_CLEANING_INTERVAL - else: + + if self.properties_api.status.in_cleaning: + if self._device.is_local_connected: self.update_interval = V1_LOCAL_IN_CLEANING_INTERVAL - elif self._is_cloud_api: - self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL - else: + else: + self.update_interval = V1_CLOUD_IN_CLEANING_INTERVAL + elif self._device.is_local_connected: self.update_interval = V1_LOCAL_NOT_CLEANING_INTERVAL - self.last_update_state = self.roborock_device_info.props.status.state_name - return self.roborock_device_info.props + else: + self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL + self.last_update_state = self.properties_api.status.state_name + return DeviceState( + status=self.properties_api.status, + dnd_timer=self.properties_api.dnd, + consumable=self.properties_api.consumables, + clean_summary=self.properties_api.clean_summary, + ) def _set_current_map(self) -> None: if ( - self.roborock_device_info.props.status is not None - and self.roborock_device_info.props.status.current_map is not None + self.properties_api.status is not None + and self.properties_api.status.current_map is not None ): - self.current_map = self.roborock_device_info.props.status.current_map + self.current_map = self.properties_api.status.current_map - async def set_current_map_rooms(self) -> None: - """Fetch all of the rooms for the current map and set on RoborockMapInfo.""" - # The api is only able to access rooms for the currently selected map - # So it is important this is only called when you have the map you care - # about selected. - if self.current_map is None or self.current_map not in self.maps: - return - room_mapping = await self.api.get_room_mapping() - self.maps[self.current_map].rooms = { - room.segment_id: self._home_data_rooms.get(room.iot_id, "Unknown") - for room in room_mapping or () - } + @cached_property + def duid(self) -> str: + """Get the unique id of the device as specified by Roborock.""" + return self._device.duid + + @cached_property + def duid_slug(self) -> str: + """Get the slug of the duid.""" + return slugify(self.duid) - async def get_routines(self) -> list[HomeDataScene]: + @property + def device(self) -> RoborockDevice: + """Get the RoborockDevice.""" + return self._device + + +async def _refresh_traits(traits: list[Any]) -> None: + """Refresh multiple traits concurrently.""" + for trait in traits: + try: + # await asyncio.gather(*[trait.refresh() for trait in traits]) + await trait.refresh() + except RoborockException as ex: + _LOGGER.debug( + "Failed to update data (%s): %s", trait.__class__.__name__, ex + ) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) from ex + + +class UserApiClient: + """Wrapper around the Roborock API client.""" + + def __init__( + self, + api_client: RoborockApiClient, + user_data: UserData, + ) -> None: + """Initialize.""" + self._api_client = api_client + self._user_data = user_data + + async def get_routines(self, duid: str) -> list[HomeDataScene]: """Get routines.""" try: - return await self._api_client.get_scenes(self._user_data, self.duid) + return await self._api_client.get_scenes(self._user_data, duid) except RoborockException as err: _LOGGER.error("Failed to get routines %s", err) raise HomeAssistantError( @@ -417,88 +362,11 @@ async def execute_routines(self, routine_id: int) -> None: }, ) from err - @cached_property - def duid(self) -> str: - """Get the unique id of the device as specified by Roborock.""" - return self.roborock_device_info.device.duid - @cached_property - def duid_slug(self) -> str: - """Get the slug of the duid.""" - return slugify(self.duid) - - async def refresh_coordinator_map(self) -> None: - """Get the starting map information for all maps for this device. - - The following steps must be done synchronously. - Only one map can be loaded at a time per device. - """ - cur_map = self.current_map - # This won't be None at this point as the coordinator will have run first. - if cur_map is None: - # If we don't have a cur map(shouldn't happen) just - # return as we can't do anything. - return - if self.data.status.in_cleaning: - # If the vacuum is cleaning, we cannot change maps - # as it will interrupt the cleaning. - _LOGGER.info( - "Vacuum is cleaning, not switching to other maps to fetch rooms" - ) - # Since this is hitting the cloud api, we want to be careful and will just - # stop here rather than retrying in the future. - map_flags = [cur_map] - else: - map_flags = sorted( - self.maps, key=lambda data: data == cur_map, reverse=True - ) - for map_flag in map_flags: - if map_flag != cur_map: - # Only change the map and sleep if we have multiple maps. - try: - await self.cloud_api.load_multi_map(map_flag) - except RoborockException as ex: - _LOGGER.debug( - "Failed to change to map %s when refreshing maps: %s", - map_flag, - ex, - ) - continue - else: - self.current_map = map_flag - # We cannot get the map until the roborock servers fully process the - # map change. If the above command fails, we should still sleep, just - # in case it executes delayed. - await asyncio.sleep(MAP_SLEEP) - tasks = [self.set_current_map_rooms()] - # The image is set within async_setup, so if it exists, we have it here. - if self.maps[map_flag].image is None: - # If we don't have a cached map, let's update it here so that it can be - # cached in the future. - tasks.append(self.update_map()) - # If either of these fail, we don't care, and we want to continue. - await asyncio.gather(*tasks, return_exceptions=True) - - if len(self.maps) > 1 and not self.data.status.in_cleaning: - # Set the map back to the map the user previously had selected so that it - # does not change the end user's app. - # Only needs to happen when we changed maps above. - try: - await self.cloud_api.load_multi_map(cur_map) - except RoborockException as ex: - _LOGGER.warning( - "Failed to change back to map %s when refreshing maps: %s", - cur_map, - ex, - ) - self.current_map = cur_map +_V = TypeVar("_V", bound=RoborockDyadDataProtocol | RoborockZeoProtocol) -class RoborockDataUpdateCoordinatorA01( - DataUpdateCoordinator[ - dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType] - ] -): +class RoborockDataUpdateCoordinatorA01(DataUpdateCoordinator[dict[_V, StateType]]): """Class to manage fetching data from the API for A01 devices.""" config_entry: RoborockConfigEntry @@ -507,9 +375,7 @@ def __init__( self, hass: HomeAssistant, config_entry: RoborockConfigEntry, - device: HomeDataDevice, - product_info: HomeDataProduct, - api: RoborockClientA01, + device: RoborockDevice, ) -> None: """Initialize.""" super().__init__( @@ -519,27 +385,49 @@ def __init__( name=DOMAIN, update_interval=A01_UPDATE_INTERVAL, ) - self.api = api + self._device = device self.device_info = DeviceInfo( name=device.name, identifiers={(DOMAIN, device.duid)}, manufacturer="Roborock", - model=product_info.model, - sw_version=device.fv, + model=device.product.model, + sw_version=device.device_info.fv, ) - self.request_protocols: list[ - RoborockDyadDataProtocol | RoborockZeoProtocol - ] = [] - if product_info.category == RoborockCategory.WET_DRY_VAC: - self.request_protocols = [ - RoborockDyadDataProtocol.STATUS, - RoborockDyadDataProtocol.POWER, - RoborockDyadDataProtocol.MESH_LEFT, - RoborockDyadDataProtocol.BRUSH_LEFT, - RoborockDyadDataProtocol.ERROR, - RoborockDyadDataProtocol.TOTAL_RUN_TIME, - ] - elif product_info.category == RoborockCategory.WASHING_MACHINE: + self.request_protocols: list[_V] = [] + + @cached_property + def duid(self) -> str: + """Get the unique id of the device as specified by Roborock.""" + return self._device.duid + + @cached_property + def duid_slug(self) -> str: + """Get the slug of the duid.""" + return slugify(self.duid) + + @property + def device(self) -> RoborockDevice: + """Get the RoborockDevice.""" + return self._device + + +class RoborockZeoUpdateCoordinator( + RoborockDataUpdateCoordinatorA01[RoborockZeoProtocol] +): + """Coordinator for Zeo devices.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: RoborockConfigEntry, + device: RoborockDevice, + api: ZeoApi, + ) -> None: + """Initialize.""" + super().__init__(hass, config_entry, device) + self.api = api + self.request_protocols: list[RoborockZeoProtocol] = [] + if device.product.category == RoborockCategory.WASHING_MACHINE: self.request_protocols = [ RoborockZeoProtocol.STATE, RoborockZeoProtocol.COUNTDOWN, @@ -548,24 +436,42 @@ def __init__( ] else: _LOGGER.warning("The device you added is not yet supported") - 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) + ) -> dict[RoborockZeoProtocol, StateType]: + return await self.api.query_values(self.request_protocols) - async def async_shutdown(self) -> None: - """Shutdown the coordinator on config entry unload.""" - await super().async_shutdown() - await self.api.async_release() - @cached_property - def duid(self) -> str: - """Get the unique id of the device as specified by Roborock.""" - return self.roborock_device_info.device.duid +class RoborockDyadUpdateCoordinator( + RoborockDataUpdateCoordinatorA01[RoborockDyadDataProtocol] +): + """Coordinator for Dyad devices.""" - @cached_property - def duid_slug(self) -> str: - """Get the slug of the duid.""" - return slugify(self.duid) + def __init__( + self, + hass: HomeAssistant, + config_entry: RoborockConfigEntry, + device: RoborockDevice, + api: DyadApi, + ) -> None: + """Initialize.""" + super().__init__(hass, config_entry, device) + self.api = api + self.request_protocols: list[RoborockDyadDataProtocol] = [] + if device.product.category == RoborockCategory.WET_DRY_VAC: + self.request_protocols = [ + RoborockDyadDataProtocol.STATUS, + RoborockDyadDataProtocol.POWER, + RoborockDyadDataProtocol.MESH_LEFT, + RoborockDyadDataProtocol.BRUSH_LEFT, + RoborockDyadDataProtocol.ERROR, + RoborockDyadDataProtocol.TOTAL_RUN_TIME, + ] + else: + _LOGGER.warning("The device you added is not yet supported") + + async def _async_update_data( + self, + ) -> dict[RoborockDyadDataProtocol, StateType]: + return await self.api.query_values(self.request_protocols) diff --git a/homeassistant/components/roborock/diagnostics.py b/homeassistant/components/roborock/diagnostics.py index 4602b4bd02a99e..02231909908cbb 100644 --- a/homeassistant/components/roborock/diagnostics.py +++ b/homeassistant/components/roborock/diagnostics.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -10,9 +11,9 @@ from .coordinator import RoborockConfigEntry -TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"] +_LOGGER = logging.getLogger(__name__) -TO_REDACT_COORD = ["duid", "localKey", "mac", "bssid"] +TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"] async def async_get_config_entry_diagnostics( @@ -24,12 +25,7 @@ async def async_get_config_entry_diagnostics( return { "config_entry": async_redact_data(config_entry.data, TO_REDACT_CONFIG), "coordinators": { - f"**REDACTED-{i}**": { - "roborock_device_info": async_redact_data( - coordinator.roborock_device_info.as_dict(), TO_REDACT_COORD - ), - "api": coordinator.api.diagnostic_data, - } + f"**REDACTED-{i}**": coordinator.device.diagnostic_data() for i, coordinator in enumerate(coordinators.values()) }, } diff --git a/homeassistant/components/roborock/entity.py b/homeassistant/components/roborock/entity.py index cfc8a98dfe7572..07b4d7ae91e496 100644 --- a/homeassistant/components/roborock/entity.py +++ b/homeassistant/components/roborock/entity.py @@ -2,19 +2,10 @@ from typing import Any -from roborock.api import RoborockClient -from roborock.command_cache import CacheableAttribute -from roborock.data import Consumable, Status +from roborock.data import Status +from roborock.devices.traits.v1.command import CommandTrait from roborock.exceptions import RoborockException -from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand -from roborock.version_1_apis.roborock_client_v1 import ( - CLOUD_REQUIRED, - 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 @@ -34,39 +25,30 @@ def __init__( self, unique_id: str, device_info: DeviceInfo, - api: RoborockClient, ) -> None: """Initialize the Roborock Device.""" self._attr_unique_id = unique_id self._attr_device_info = device_info - self._api = api class RoborockEntityV1(RoborockEntity): """Representation of a base Roborock V1 Entity.""" - _api: RoborockClientV1 - def __init__( - self, unique_id: str, device_info: DeviceInfo, api: RoborockClientV1 + self, unique_id: str, device_info: DeviceInfo, api: CommandTrait ) -> 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.""" - return self._api.cache[attribute] + super().__init__(unique_id, device_info) + self._api = api - @classmethod - async def _send_command( - cls, + async def send( + self, command: RoborockCommand | str, - api: RoborockClientV1, params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Send a Roborock command with params to a given api.""" try: - response: dict = await api.send_command(command, params) + response: dict = await self._api.send(command, params=params) except RoborockException as err: if isinstance(command, RoborockCommand): command_name = command.name @@ -81,31 +63,6 @@ async def _send_command( ) from err return response - async def send( - self, - command: RoborockCommand | str, - params: dict[str, Any] | list[Any] | int | None = None, - ) -> dict: - """Send a command to a vacuum cleaner.""" - return await self._send_command(command, self._api, params) - - @property - def api(self) -> RoborockClientV1: - """Returns the api.""" - return self._api - - -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] @@ -118,9 +75,6 @@ def __init__( self, unique_id: str, coordinator: RoborockDataUpdateCoordinator, - listener_request: list[RoborockDataProtocol] - | RoborockDataProtocol - | None = None, is_dock_entity: bool = False, ) -> None: """Initialize the coordinated Roborock Device.""" @@ -130,27 +84,10 @@ def __init__( device_info=coordinator.device_info if not is_dock_entity else coordinator.dock_device_info, - api=coordinator.api, + api=coordinator.properties_api.command, ) CoordinatorEntity.__init__(self, coordinator=coordinator) self._attr_unique_id = unique_id - if isinstance(listener_request, RoborockDataProtocol): - listener_request = [listener_request] - self.listener_requests = listener_request or [] - - async def async_added_to_hass(self) -> None: - """Add listeners when the device is added to hass.""" - await super().async_added_to_hass() - for listener_request in self.listener_requests: - self.api.add_listener( - listener_request, self._update_from_listener, cache=self.api.cache - ) - - async def async_will_remove_from_hass(self) -> None: - """Remove listeners when the device is removed from hass.""" - for listener_request in self.listener_requests: - self.api.remove_listener(listener_request, self._update_from_listener) - await super().async_will_remove_from_hass() @property def _device_status(self) -> Status: @@ -158,36 +95,19 @@ def _device_status(self) -> Status: data = self.coordinator.data return data.status - @property - def cloud_api(self) -> RoborockMqttClientV1: - """Return the cloud api.""" - return self.coordinator.cloud_api - async def send( self, command: RoborockCommand | str, params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Overloads normal send command but refreshes coordinator.""" - if command in CLOUD_REQUIRED: - res = await self._send_command(command, self.coordinator.cloud_api, params) - else: - res = await self._send_command(command, self._api, params) + res = await super().send(command, params) await self.coordinator.async_refresh() return res - def _update_from_listener(self, value: Status | Consumable) -> None: - """Update the status or consumable data from a listener and then write the new entity state.""" - if isinstance(value, Status): - self.coordinator.roborock_device_info.props.status = value - else: - 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] + RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinatorA01] ): """Representation of a base a coordinated Roborock Entity.""" @@ -197,11 +117,10 @@ def __init__( coordinator: RoborockDataUpdateCoordinatorA01, ) -> None: """Initialize the coordinated Roborock Device.""" - RoborockEntityA01.__init__( + RoborockEntity.__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/image.py b/homeassistant/components/roborock/image.py index d1c19331ba45cd..e046f47120fd44 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -30,11 +30,12 @@ async def async_setup_entry( config_entry, f"{coord.duid_slug}_map_{map_info.name}", coord, - map_info.flag, + map_info.map_flag, map_info.name, ) for coord in config_entry.runtime_data.v1 - for map_info in coord.maps.values() + if coord.properties_api.home.home_cache is not None + for map_info in coord.properties_api.home.home_cache.values() ), ) @@ -86,4 +87,6 @@ def _handle_coordinator_update(self) -> None: async def async_image(self) -> bytes | None: """Get the cached image.""" - return self.coordinator.maps[self.map_flag].image + if (map_info := self.coordinator.maps.get(self.map_flag)) is None: + raise ValueError("Map flag not found in coordinator maps") + return map_info.image diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index 53260ff4984365..6715e370a5d6fb 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -2,12 +2,32 @@ from dataclasses import dataclass from datetime import datetime +import logging from typing import Any -from roborock.data import HomeDataDevice, HomeDataProduct, NetworkInfo -from roborock.roborock_typing import DeviceProp +from roborock.data import ( + CleanSummaryWithDetail, + Consumable, + DnDTimer, + HomeDataDevice, + HomeDataProduct, + NetworkInfo, + Status, +) from vacuum_map_parser_base.map_data import MapData +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DeviceState: + """Data about the current state of a device.""" + + status: Status + dnd_timer: DnDTimer + consumable: Consumable + clean_summary: CleanSummaryWithDetail + @dataclass class RoborockHassDeviceInfo: @@ -16,7 +36,6 @@ class RoborockHassDeviceInfo: device: HomeDataDevice network_info: NetworkInfo product: HomeDataProduct - props: DeviceProp def as_dict(self) -> dict[str, dict[str, Any]]: """Turn RoborockHassDeviceInfo into a dictionary.""" @@ -24,7 +43,6 @@ def as_dict(self) -> dict[str, dict[str, Any]]: "device": self.device.as_dict(), "network_info": self.network_info.as_dict(), "product": self.product.as_dict(), - "props": self.props.as_dict(), } @@ -49,14 +67,6 @@ class RoborockMapInfo: flag: int name: str - rooms: dict[int, str] image: bytes | None last_updated: datetime map_data: MapData | None - - @property - def current_room(self) -> str | None: - """Get the currently active room for this map if any.""" - if self.map_data is None or self.map_data.vacuum_room is None: - return None - return self.rooms.get(self.map_data.vacuum_room) diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 73ac14fca71591..749a49518e89ee 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -1,14 +1,12 @@ """Support for Roborock number.""" -import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging from typing import Any -from roborock.command_cache import CacheableAttribute +from roborock.devices.traits.v1 import PropertiesApi from roborock.exceptions import RoborockException -from roborock.version_1_apis.roborock_client_v1 import AttributeCache from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory @@ -29,10 +27,14 @@ class RoborockNumberDescription(NumberEntityDescription): """Class to describe a Roborock number entity.""" - # Gets the status of the switch - cache_key: CacheableAttribute - # Sets the status of the switch - update_value: Callable[[AttributeCache, float], Coroutine[Any, Any, None]] + trait: Callable[[PropertiesApi], Any | None] + """Function to determine if number entity is supported by the device.""" + + get_value: Callable[[Any], float] + """Function to get the value from the trait.""" + + set_value: Callable[[Any, float], Coroutine[Any, Any, None]] + """Function to set the value on the trait.""" NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [ @@ -42,9 +44,10 @@ class RoborockNumberDescription(NumberEntityDescription): 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)]), + trait=lambda api: api.sound_volume, + get_value=lambda trait: float(trait.volume), + set_value=lambda trait, value: trait.set_volume(int(value)), ) ] @@ -55,36 +58,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock number platform.""" - possible_entities: list[ - tuple[RoborockDataUpdateCoordinator, RoborockNumberDescription] - ] = [ - (coordinator, description) - for coordinator in config_entry.runtime_data.v1 - for description in NUMBER_DESCRIPTIONS - ] - # We need to check if this function is supported by the device. - results = await asyncio.gather( - *( - coordinator.api.get_from_cache(description.cache_key) - for coordinator, description in possible_entities - ), - return_exceptions=True, - ) - valid_entities: list[RoborockNumberEntity] = [] - for (coordinator, description), result in zip( - possible_entities, results, strict=False - ): - 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}_{coordinator.duid_slug}", - coordinator, - description, - ) + async_add_entities( + [ + RoborockNumberEntity( + f"{description.key}_{coordinator.duid_slug}", + coordinator=coordinator, + entity_description=description, + trait=trait, ) - async_add_entities(valid_entities) + for coordinator in config_entry.runtime_data.v1 + for description in NUMBER_DESCRIPTIONS + if (trait := description.trait(coordinator.properties_api)) is not None + ] + ) class RoborockNumberEntity(RoborockEntityV1, NumberEntity): @@ -97,23 +83,24 @@ def __init__( unique_id: str, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockNumberDescription, + trait: Any, ) -> None: """Create a number entity.""" self.entity_description = entity_description - super().__init__(unique_id, coordinator.device_info, coordinator.api) + super().__init__( + unique_id, coordinator.device_info, api=coordinator.properties_api.command + ) + self._trait = trait @property def native_value(self) -> float | None: """Get native value.""" - val: float = self.get_cache(self.entity_description.cache_key).value - return val + return self.entity_description.get_value(self._trait) async def async_set_native_value(self, value: float) -> None: """Set number value.""" try: - await self.entity_description.update_value( - self.get_cache(self.entity_description.cache_key), value - ) + await self.entity_description.set_value(self._trait, value) except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 4d3b7076ae5f15..1204f109f1fcda 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -5,8 +5,8 @@ from dataclasses import dataclass from roborock.data import RoborockDockDustCollectionModeCode -from roborock.roborock_message import RoborockDataProtocol -from roborock.roborock_typing import DeviceProp, RoborockCommand +from roborock.devices.traits.v1 import PropertiesApi +from roborock.roborock_typing import RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -24,18 +24,20 @@ class RoborockSelectDescription(SelectEntityDescription): """Class to describe a Roborock select entity.""" - # The command that the select entity will send to the api. api_command: RoborockCommand - # Gets the current value of the select entity. - value_fn: Callable[[DeviceProp], str | None] - # Gets all options of the select entity. - options_lambda: Callable[[DeviceProp], list[str] | None] - # Takes the value from the select entity and converts it for the api. - parameter_lambda: Callable[[str, DeviceProp], list[int]] - - protocol_listener: RoborockDataProtocol | None = None - # If it is a dock entity + """The command that the select entity will send to the API.""" + + value_fn: Callable[[PropertiesApi], str | None] + """Function to get the current value of the select entity.""" + + options_lambda: Callable[[PropertiesApi], list[str] | None] + """Function to get all options of the select entity or returns None if not supported.""" + + parameter_lambda: Callable[[str, PropertiesApi], list[int]] + """Function to get the parameters for the api command.""" + is_dock_entity: bool = False + """Whether this entity is for the dock.""" SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ @@ -43,33 +45,32 @@ class RoborockSelectDescription(SelectEntityDescription): key="water_box_mode", translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, - value_fn=lambda data: data.status.water_box_mode_name, + value_fn=lambda api: api.status.water_box_mode.name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda data: data.status.water_box_mode.keys() - if data.status.water_box_mode is not None + options_lambda=lambda api: api.status.water_box_mode.keys() + if api.status.water_box_mode is not None else None, - parameter_lambda=lambda key, prop: [prop.status.get_mop_intensity_code(key)], - protocol_listener=RoborockDataProtocol.WATER_BOX_MODE, + parameter_lambda=lambda key, api: [api.status.get_mop_intensity_code(key)], ), RoborockSelectDescription( key="mop_mode", translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, - value_fn=lambda data: data.status.mop_mode_name, + value_fn=lambda api: api.status.mop_mode_name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda data: data.status.mop_mode.keys() - if data.status.mop_mode is not None + options_lambda=lambda api: api.status.mop_mode.keys() + if api.status.mop_mode is not None else None, - parameter_lambda=lambda key, prop: [prop.status.get_mop_mode_code(key)], + parameter_lambda=lambda key, api: [api.status.get_mop_mode_code(key)], ), RoborockSelectDescription( key="dust_collection_mode", translation_key="dust_collection_mode", api_command=RoborockCommand.SET_DUST_COLLECTION_MODE, - value_fn=lambda data: data.dust_collection_mode_name, + value_fn=lambda api: api.dust_collection_mode.mode.name, # type: ignore[attr-defined] entity_category=EntityCategory.CONFIG, - options_lambda=lambda data: RoborockDockDustCollectionModeCode.keys() - if data.dust_collection_mode_name is not None + options_lambda=lambda api: RoborockDockDustCollectionModeCode.keys() + if api.dust_collection_mode is not None else None, parameter_lambda=lambda key, _: [ RoborockDockDustCollectionModeCode.as_dict().get(key) @@ -90,11 +91,7 @@ async def async_setup_entry( RoborockSelectEntity(coordinator, description, options) for coordinator in config_entry.runtime_data.v1 for description in SELECT_DESCRIPTIONS - if ( - options := description.options_lambda( - coordinator.roborock_device_info.props - ) - ) + if (options := description.options_lambda(coordinator.properties_api)) is not None ) async_add_entities( @@ -121,7 +118,6 @@ def __init__( super().__init__( f"{entity_description.key}_{coordinator.duid_slug}", coordinator, - entity_description.protocol_listener, is_dock_entity=entity_description.is_dock_entity, ) self._attr_options = options @@ -130,13 +126,15 @@ async def async_select_option(self, option: str) -> None: """Set the option.""" await self.send( self.entity_description.api_command, - self.entity_description.parameter_lambda(option, self.coordinator.data), + self.entity_description.parameter_lambda( + option, self.coordinator.properties_api + ), ) @property def current_option(self) -> str | None: """Get the current status of the select entity from device props.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn(self.coordinator.properties_api) class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): @@ -147,13 +145,10 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): async def async_select_option(self, option: str) -> None: """Set the option.""" + maps_trait = self.coordinator.properties_api.maps for map_id, map_ in self.coordinator.maps.items(): if map_.name == option: - await self._send_command( - RoborockCommand.LOAD_MULTI_MAP, - self.cloud_api, - [map_id], - ) + await maps_trait.set_current_map(map_id) # Update the current map id manually so that nothing gets broken # if another service hits the api. self.coordinator.current_map = map_id diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 3c530fd4192ee2..4482d7ea81ae46 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -5,6 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass import datetime +import logging from roborock.data import ( DyadError, @@ -16,12 +17,7 @@ ZeoError, ZeoState, ) -from roborock.roborock_message import ( - RoborockDataProtocol, - RoborockDyadDataProtocol, - RoborockZeoProtocol, -) -from roborock.roborock_typing import DeviceProp +from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from homeassistant.components.sensor import ( SensorDeviceClass, @@ -44,6 +40,9 @@ RoborockCoordinatedEntityV1, RoborockEntity, ) +from .models import DeviceState + +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -52,9 +51,7 @@ class RoborockSensorDescription(SensorEntityDescription): """A class that describes Roborock sensors.""" - value_fn: Callable[[DeviceProp], StateType | datetime.datetime] - - protocol_listener: RoborockDataProtocol | None = None + value_fn: Callable[[DeviceState], StateType | datetime.datetime] # If it is a dock entity is_dock_entity: bool = False @@ -67,10 +64,10 @@ class RoborockSensorDescriptionA01(SensorEntityDescription): data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol -def _dock_error_value_fn(properties: DeviceProp) -> str | None: +def _dock_error_value_fn(state: DeviceState) -> str | None: if ( - status := properties.status.dock_error_status - ) is not None and properties.status.dock_type != RoborockDockTypeCode.no_dock: + status := state.status.dock_error_status + ) is not None and state.status.dock_type != RoborockDockTypeCode.no_dock: return status.name return None @@ -85,7 +82,6 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: translation_key="main_brush_time_left", value_fn=lambda data: data.consumable.main_brush_time_left, entity_category=EntityCategory.DIAGNOSTIC, - protocol_listener=RoborockDataProtocol.MAIN_BRUSH_WORK_TIME, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -95,7 +91,6 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: translation_key="side_brush_time_left", value_fn=lambda data: data.consumable.side_brush_time_left, entity_category=EntityCategory.DIAGNOSTIC, - protocol_listener=RoborockDataProtocol.SIDE_BRUSH_WORK_TIME, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -105,7 +100,6 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: translation_key="filter_time_left", value_fn=lambda data: data.consumable.filter_time_left, entity_category=EntityCategory.DIAGNOSTIC, - protocol_listener=RoborockDataProtocol.FILTER_WORK_TIME, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.HOURS, @@ -166,7 +160,6 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: value_fn=lambda data: data.status.state_name, entity_category=EntityCategory.DIAGNOSTIC, options=RoborockStateCode.keys(), - protocol_listener=RoborockDataProtocol.STATE, ), RoborockSensorDescription( key="cleaning_area", @@ -189,7 +182,6 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: value_fn=lambda data: data.status.error_code_name, entity_category=EntityCategory.DIAGNOSTIC, options=RoborockErrorCode.keys(), - protocol_listener=RoborockDataProtocol.ERROR_CODE, ), RoborockSensorDescription( key="battery", @@ -197,13 +189,12 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, - protocol_listener=RoborockDataProtocol.BATTERY, ), RoborockSensorDescription( key="last_clean_start", translation_key="last_clean_start", - value_fn=lambda data: data.last_clean_record.begin_datetime - if data.last_clean_record is not None + value_fn=lambda data: data.clean_summary.last_clean_record.begin_datetime + if data.clean_summary.last_clean_record is not None else None, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, @@ -211,8 +202,8 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: RoborockSensorDescription( key="last_clean_end", translation_key="last_clean_end", - value_fn=lambda data: data.last_clean_record.end_datetime - if data.last_clean_record is not None + value_fn=lambda data: data.clean_summary.last_clean_record.end_datetime + if data.clean_summary.last_clean_record is not None else None, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, @@ -246,7 +237,6 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: ), ] - A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [ RoborockSensorDescriptionA01( key="status", @@ -340,6 +330,7 @@ async def async_setup_entry( ) -> None: """Set up the Roborock vacuum sensors.""" coordinators = config_entry.runtime_data + entities: list[RoborockEntity] = [ RoborockSensorEntity( coordinator, @@ -347,7 +338,7 @@ async def async_setup_entry( ) for coordinator in coordinators.v1 for description in SENSOR_DESCRIPTIONS - if description.value_fn(coordinator.roborock_device_info.props) is not None + if description.value_fn(coordinator.data) is not None ] entities.extend(RoborockCurrentRoom(coordinator) for coordinator in coordinators.v1) entities.extend( @@ -357,7 +348,7 @@ async def async_setup_entry( ) for coordinator in coordinators.a01 for description in A01_SENSOR_DESCRIPTIONS - if description.data_protocol in coordinator.data + if description.data_protocol in coordinator.request_protocols ) async_add_entities(entities) @@ -377,16 +368,13 @@ def __init__( super().__init__( f"{description.key}_{coordinator.duid_slug}", coordinator, - description.protocol_listener, is_dock_entity=description.is_dock_entity, ) @property def native_value(self) -> StateType | datetime.datetime: """Return the value reported by the sensor.""" - return self.entity_description.value_fn( - self.coordinator.roborock_device_info.props - ) + return self.entity_description.value_fn(self.coordinator.data) class RoborockCurrentRoom(RoborockCoordinatedEntityV1, SensorEntity): @@ -404,30 +392,29 @@ def __init__( super().__init__( f"current_room_{coordinator.duid_slug}", coordinator, - None, is_dock_entity=False, ) + self._home_trait = coordinator.properties_api.home + self._map_content_trait = coordinator.properties_api.map_content @property def options(self) -> list[str]: """Return the currently valid rooms.""" - if ( - self.coordinator.current_map is not None - and self.coordinator.current_map in self.coordinator.maps - ): - return list( - self.coordinator.maps[self.coordinator.current_map].rooms.values() - ) + if self._home_trait.current_map_data is not None: + return [room.name for room in self._home_trait.current_map_data.rooms] return [] @property def native_value(self) -> str | None: """Return the value reported by the sensor.""" if ( - self.coordinator.current_map is not None - and self.coordinator.current_map in self.coordinator.maps + self._home_trait.current_map_data is not None + and self._map_content_trait.map_data is not None + and self._map_content_trait.map_data.vacuum_room is not None ): - return self.coordinator.maps[self.coordinator.current_map].current_room + for room in self._home_trait.current_map_data.rooms: + if room.segment_id == self._map_content_trait.map_data.vacuum_room: + return room.name return None diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 44feccdebacbc7..cf5427269b7a56 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -2,15 +2,14 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable, Coroutine +from collections.abc import Callable from dataclasses import dataclass import logging from typing import Any -from roborock.command_cache import CacheableAttribute +from roborock.devices.traits.v1 import PropertiesApi +from roborock.devices.traits.v1.common import RoborockSwitchBase from roborock.exceptions import RoborockException -from roborock.version_1_apis.roborock_client_v1 import AttributeCache from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -31,69 +30,35 @@ class RoborockSwitchDescription(SwitchEntityDescription): """Class to describe a Roborock switch entity.""" - # Gets the status of the switch - cache_key: CacheableAttribute - # Sets the status of the switch - update_value: Callable[[AttributeCache, bool], Coroutine[Any, Any, None]] - # Attribute from cache - attribute: str + trait: Callable[[PropertiesApi], RoborockSwitchBase | None] + # If it is a dock entity is_dock_entity: bool = False SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ RoborockSwitchDescription( - cache_key=CacheableAttribute.child_lock_status, - update_value=lambda cache, value: cache.update_value( - {"lock_status": 1 if value else 0} - ), - attribute="lock_status", + trait=lambda traits: traits.child_lock, key="child_lock", translation_key="child_lock", entity_category=EntityCategory.CONFIG, is_dock_entity=True, ), RoborockSwitchDescription( - cache_key=CacheableAttribute.flow_led_status, - update_value=lambda cache, value: cache.update_value( - {"status": 1 if value else 0} - ), - attribute="status", + trait=lambda traits: traits.flow_led_status, key="status_indicator", translation_key="status_indicator", entity_category=EntityCategory.CONFIG, is_dock_entity=True, ), RoborockSwitchDescription( - cache_key=CacheableAttribute.dnd_timer, - update_value=lambda cache, value: cache.update_value( - [ - cache.value.get("start_hour"), - cache.value.get("start_minute"), - cache.value.get("end_hour"), - cache.value.get("end_minute"), - ] - ) - if value - else cache.close_value(), - attribute="enabled", + trait=lambda traits: traits.dnd, key="dnd_switch", translation_key="dnd_switch", entity_category=EntityCategory.CONFIG, ), RoborockSwitchDescription( - cache_key=CacheableAttribute.valley_electricity_timer, - update_value=lambda cache, value: cache.update_value( - [ - cache.value.get("start_hour"), - cache.value.get("start_minute"), - cache.value.get("end_hour"), - cache.value.get("end_minute"), - ] - ) - if value - else cache.close_value(), - attribute="enabled", + trait=lambda traits: traits.valley_electricity_timer, key="off_peak_switch", translation_key="off_peak_switch", entity_category=EntityCategory.CONFIG, @@ -108,36 +73,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock switch platform.""" - possible_entities: list[ - tuple[RoborockDataUpdateCoordinator, RoborockSwitchDescription] - ] = [ - (coordinator, description) - for coordinator in config_entry.runtime_data.v1 - for description in SWITCH_DESCRIPTIONS - ] - # We need to check if this function is supported by the device. - results = await asyncio.gather( - *( - coordinator.api.get_from_cache(description.cache_key) - for coordinator, description in possible_entities - ), - return_exceptions=True, - ) - valid_entities: list[RoborockSwitch] = [] - for (coordinator, description), result in zip( - possible_entities, results, strict=False - ): - if result is None or isinstance(result, Exception): - _LOGGER.debug("Not adding entity because of %s", result) - else: - valid_entities.append( - RoborockSwitch( - f"{description.key}_{coordinator.duid_slug}", - coordinator, - description, - ) + async_add_entities( + [ + RoborockSwitch( + f"{description.key}_{coordinator.duid_slug}", + coordinator, + description, + trait, ) - async_add_entities(valid_entities) + for coordinator in config_entry.runtime_data.v1 + for description in SWITCH_DESCRIPTIONS + if (trait := description.trait(coordinator.properties_api)) is not None + ] + ) class RoborockSwitch(RoborockEntityV1, SwitchEntity): @@ -150,6 +98,7 @@ def __init__( unique_id: str, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockSwitchDescription, + trait: RoborockSwitchBase, ) -> None: """Initialize the entity.""" self.entity_description = entity_description @@ -158,15 +107,14 @@ def __init__( coordinator.device_info if not entity_description.is_dock_entity else coordinator.dock_device_info, - coordinator.api, + coordinator.properties_api.command, ) + self._trait = trait async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" try: - await self.entity_description.update_value( - self.get_cache(self.entity_description.cache_key), False - ) + await self._trait.disable() except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -176,9 +124,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" try: - await self.entity_description.update_value( - self.get_cache(self.entity_description.cache_key), True - ) + await self._trait.enable() except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -188,9 +134,4 @@ async def async_turn_on(self, **kwargs: Any) -> None: @property def is_on(self) -> bool | None: """Return True if entity is on.""" - status = self.get_cache(self.entity_description.cache_key).value.get( - self.entity_description.attribute - ) - if status is None: - return status - return bool(status) + return self._trait.is_on diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 83d341fa2dd40c..1464db6e398e40 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -1,6 +1,5 @@ """Support for Roborock time.""" -import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass import datetime @@ -8,9 +7,8 @@ import logging from typing import Any -from roborock.command_cache import CacheableAttribute +from roborock.data import DnDTimer from roborock.exceptions import RoborockException -from roborock.version_1_apis.roborock_client_v1 import AttributeCache from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.const import EntityCategory @@ -31,63 +29,67 @@ class RoborockTimeDescription(TimeEntityDescription): """Class to describe a Roborock time entity.""" - # Gets the status of the switch - cache_key: CacheableAttribute - # Sets the status of the switch - update_value: Callable[[AttributeCache, datetime.time], Coroutine[Any, Any, None]] - # Attribute from cache - get_value: Callable[[AttributeCache], datetime.time] + trait: Callable[[Any], Any | None] + """Function to determine if time entity is supported by the device.""" + + get_value: Callable[[Any], datetime.time] + """Function to get the value from the trait.""" + + update_value: Callable[[Any, datetime.time], Coroutine[Any, Any, None]] + """Function to set the value on the trait.""" TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ RoborockTimeDescription( key="dnd_start_time", translation_key="dnd_start_time", - 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"), - ] + trait=lambda api: api.dnd, + update_value=lambda trait, desired_time: trait.set_dnd_timer( + DnDTimer( + enabled=trait.enabled, + start_hour=desired_time.hour, + start_minute=desired_time.minute, + end_hour=trait.end_hour, + end_minute=trait.end_minute, + ) ), - get_value=lambda cache: datetime.time( - hour=cache.value.get("start_hour"), minute=cache.value.get("start_minute") + get_value=lambda trait: datetime.time( + hour=trait.start_hour, minute=trait.start_minute ), entity_category=EntityCategory.CONFIG, ), RoborockTimeDescription( key="dnd_end_time", translation_key="dnd_end_time", - 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, - ] + trait=lambda api: api.dnd, + update_value=lambda trait, desired_time: trait.set_dnd_timer( + DnDTimer( + enabled=trait.enabled, + start_hour=trait.start_hour, + start_minute=trait.start_minute, + end_hour=desired_time.hour, + end_minute=desired_time.minute, + ) ), - get_value=lambda cache: datetime.time( - hour=cache.value.get("end_hour"), minute=cache.value.get("end_minute") + get_value=lambda trait: datetime.time( + hour=trait.end_hour, minute=trait.end_minute ), entity_category=EntityCategory.CONFIG, ), RoborockTimeDescription( key="off_peak_start", translation_key="off_peak_start", - cache_key=CacheableAttribute.valley_electricity_timer, - update_value=lambda cache, desired_time: cache.update_value( + trait=lambda api: api.valley_electricity_timer, + update_value=lambda trait, desired_time: trait.update_value( [ desired_time.hour, desired_time.minute, - cache.value.get("end_hour"), - cache.value.get("end_minute"), + trait.end_hour, + trait.end_minute, ] ), - get_value=lambda cache: datetime.time( - hour=cache.value.get("start_hour"), minute=cache.value.get("start_minute") + get_value=lambda trait: datetime.time( + hour=trait.start_hour, minute=trait.start_minute ), entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -95,17 +97,17 @@ class RoborockTimeDescription(TimeEntityDescription): RoborockTimeDescription( key="off_peak_end", translation_key="off_peak_end", - cache_key=CacheableAttribute.valley_electricity_timer, - update_value=lambda cache, desired_time: cache.update_value( + trait=lambda api: api.valley_electricity_timer, + update_value=lambda trait, desired_time: trait.update_value( [ - cache.value.get("start_hour"), - cache.value.get("start_minute"), + trait.start_hour, + trait.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") + get_value=lambda trait: datetime.time( + hour=trait.end_hour, minute=trait.end_minute ), entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -119,36 +121,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock time platform.""" - possible_entities: list[ - tuple[RoborockDataUpdateCoordinator, RoborockTimeDescription] - ] = [ - (coordinator, description) - for coordinator in config_entry.runtime_data.v1 - for description in TIME_DESCRIPTIONS - ] - # We need to check if this function is supported by the device. - results = await asyncio.gather( - *( - coordinator.api.get_from_cache(description.cache_key) - for coordinator, description in possible_entities - ), - return_exceptions=True, - ) - valid_entities: list[RoborockTimeEntity] = [] - for (coordinator, description), result in zip( - possible_entities, results, strict=False - ): - 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}_{coordinator.duid_slug}", - coordinator, - description, - ) + async_add_entities( + [ + RoborockTimeEntity( + f"{description.key}_{coordinator.duid_slug}", + coordinator, + description, + trait, ) - async_add_entities(valid_entities) + for coordinator in config_entry.runtime_data.v1 + for description in TIME_DESCRIPTIONS + if (trait := description.trait(coordinator.properties_api)) is not None + ] + ) class RoborockTimeEntity(RoborockEntityV1, TimeEntity): @@ -161,24 +146,24 @@ def __init__( unique_id: str, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockTimeDescription, + trait: Any, ) -> None: """Create a time entity.""" self.entity_description = entity_description - super().__init__(unique_id, coordinator.device_info, coordinator.api) + super().__init__( + unique_id, coordinator.device_info, api=coordinator.properties_api.command + ) + self._trait = trait @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) - ) + return self.entity_description.get_value(self._trait) async def async_set_value(self, value: time) -> None: """Set the time.""" try: - await self.entity_description.update_value( - self.get_cache(self.entity_description.cache_key), value - ) + await self.entity_description.update_value(self._trait, value) except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 8d45686340ea2e..529c8c08f82c44 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -1,9 +1,10 @@ """Support for Roborock vacuum class.""" +import logging from typing import Any from roborock.data import RoborockStateCode -from roborock.roborock_message import RoborockDataProtocol +from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand import voluptuous as vol @@ -26,6 +27,8 @@ from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +_LOGGER = logging.getLogger(__name__) + STATE_CODE_TO_STATE = { RoborockStateCode.starting: VacuumActivity.IDLE, # "Starting" RoborockStateCode.charger_disconnected: VacuumActivity.IDLE, # "Charger disconnected" @@ -62,11 +65,8 @@ async def async_setup_entry( ) -> None: """Set up the Roborock sensor.""" async_add_entities( - RoborockVacuum(coordinator) - for coordinator in config_entry.runtime_data.v1 - if isinstance(coordinator, RoborockDataUpdateCoordinator) + RoborockVacuum(coordinator) for coordinator in config_entry.runtime_data.v1 ) - platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -124,12 +124,12 @@ def __init__( self, coordinator.duid_slug, coordinator, - listener_request=[ - RoborockDataProtocol.FAN_POWER, - RoborockDataProtocol.STATE, - ], ) - self._attr_fan_speed_list = self._device_status.fan_power_options + + @property + def fan_speed_list(self) -> list[str]: + """Get the list of available fan speeds.""" + return self._device_status.fan_power_options @property def activity(self) -> VacuumActivity | None: @@ -197,32 +197,40 @@ async def async_send_command( async def get_maps(self) -> ServiceResponse: """Get map information such as map id and room ids.""" + home_trait = self.coordinator.properties_api.home return { "maps": [ { - "flag": vacuum_map.flag, + "flag": vacuum_map.map_flag, "name": vacuum_map.name, - # JsonValueType does not accept a int as a key - was not a - # issue with previous asdict() implementation. - "rooms": vacuum_map.rooms, # type: ignore[dict-item] + "rooms": { + # JsonValueType does not accept a int as a key - was not a + # issue with previous asdict() implementation. + room.segment_id: room.name # type: ignore[misc] + for room in vacuum_map.rooms + }, } - for vacuum_map in self.coordinator.maps.values() + for vacuum_map in (home_trait.home_cache or {}).values() ] } async def get_vacuum_current_position(self) -> ServiceResponse: """Get the current position of the vacuum from the map.""" - - map_data = await self.coordinator.cloud_api.get_map_v1() - if not isinstance(map_data, bytes): + map_content_trait = self.coordinator.properties_api.map_content + try: + await map_content_trait.refresh() + except RoborockException as err: + _LOGGER.debug("Failed to refresh map content: %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) from err + if map_content_trait.map_data is None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="map_failure", ) - parsed_map = self.coordinator.map_parser.parse(map_data) - robot_position = parsed_map.vacuum_position - - if robot_position is None: + if (robot_position := map_content_trait.map_data.vacuum_position) is None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="position_not_found" ) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 25583a186c8b95..2729c02d223e27 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -3,16 +3,28 @@ import asyncio from collections.abc import Generator from copy import deepcopy +import logging import pathlib import tempfile from typing import Any -from unittest.mock import Mock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest -from roborock import RoborockCategory, RoomMapping -from roborock.data import DyadError, RoborockDyadStateCode, ZeoError, ZeoState +from roborock import RoborockCategory +from roborock.data import ( + CombinedMapInfo, + DyadError, + HomeDataDevice, + HomeDataProduct, + NamedRoomMapping, + NetworkInfo, + RoborockDyadStateCode, + ZeoError, + ZeoState, +) +from roborock.devices.device import RoborockDevice +from roborock.devices.traits.v1.volume import SoundVolume from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol -from roborock.version_a01_apis import RoborockMqttClientA01 from homeassistant.components.roborock.const import ( CONF_BASE_URL, @@ -24,48 +36,51 @@ from .mock_data import ( BASE_URL, + CLEAN_RECORD, + CLEAN_SUMMARY, + CONSUMABLE, + DND_TIMER, HOME_DATA, MAP_DATA, MULTI_MAP_LIST, - NETWORK_INFO, - PROP, + NETWORK_INFO_BY_DEVICE, ROBOROCK_RRUID, + ROOM_MAPPING, SCENES, + STATUS, USER_DATA, USER_EMAIL, ) from tests.common import MockConfigEntry +_LOGGER = logging.getLogger(__name__) -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) - if category == RoborockCategory.WET_DRY_VAC: - 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, - } - elif category == RoborockCategory.WASHING_MACHINE: - self.protocol_responses: list[RoborockZeoProtocol] = { - RoborockZeoProtocol.STATE: ZeoState.drying.name, - RoborockZeoProtocol.COUNTDOWN: 0, - RoborockZeoProtocol.WASHING_LEFT: 253, - RoborockZeoProtocol.ERROR: ZeoError.none.name, - } - - 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} + +def create_dyad_trait() -> Mock: + """Create dyad trait for A01 devices.""" + dyad_trait = AsyncMock() + dyad_trait.query_values.return_value = { + 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, + } + return dyad_trait + + +def create_zeo_trait() -> Mock: + """Create zeo trait for A01 devices.""" + zeo_trait = AsyncMock() + zeo_trait.query_values.return_value = { + RoborockZeoProtocol.STATE: ZeoState.drying.name, + RoborockZeoProtocol.COUNTDOWN: 0, + RoborockZeoProtocol.WASHING_LEFT: 253, + RoborockZeoProtocol.ERROR: ZeoError.none.name, + } + return zeo_trait @pytest.fixture(name="bypass_api_client_fixture") @@ -76,16 +91,13 @@ def bypass_api_client_fixture() -> None: with ( patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", + "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", return_value=HOME_DATA, ), patch( "homeassistant.components.roborock.RoborockApiClient.get_scenes", return_value=SCENES, ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.load_multi_map" - ), patch( "homeassistant.components.roborock.config_flow.RoborockApiClient.base_url", new_callable=PropertyMock, @@ -95,82 +107,148 @@ def bypass_api_client_fixture() -> None: yield -@pytest.fixture(name="bypass_api_fixture") -def bypass_api_fixture(bypass_api_client_fixture: Any, mock_send_message: Mock) -> None: - """Skip calls to the API.""" - with ( - patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), - patch("homeassistant.components.roborock.RoborockMqttClientV1._send_command"), - patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1._send_command" - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.async_connect" - ), - patch( - "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - return_value=NETWORK_INFO, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=PROP, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_multi_maps_list", - return_value=MULTI_MAP_LIST, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", - return_value=MULTI_MAP_LIST, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - return_value=MAP_DATA, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_message" - ), - patch("homeassistant.components.roborock.RoborockMqttClientV1._wait_response"), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._wait_response" - ), - patch( - "roborock.version_1_apis.AttributeCache.async_value", - ), - patch( - "roborock.version_1_apis.AttributeCache.value", - ), - patch( - "homeassistant.components.roborock.coordinator.MAP_SLEEP", - 0, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_room_mapping", - return_value=[ - RoomMapping(16, "2362048"), - RoomMapping(17, "2362044"), - RoomMapping(18, "2362041"), +class FakeDevice(RoborockDevice): + """A fake device that returns a list of devices.""" + + def __init__( + self, + device_info: HomeDataDevice, + product: HomeDataProduct, + ) -> None: + """Initialize the FakeDevice.""" + super().__init__(device_info, product, Mock(), Mock()) + + async def close(self) -> None: + """Close the device.""" + + +class FakeDeviceManager: + """A fake device manager that returns a list of devices.""" + + def __init__(self, devices: list[RoborockDevice]) -> None: + """Initialize the fake device manager.""" + self._devices = devices + + async def get_devices(self) -> list[RoborockDevice]: + """Return the list of devices.""" + return self._devices + + +def create_v1_properties(network_info: NetworkInfo) -> Mock: + """Create v1 properties for each fake device.""" + v1_properties = Mock() + v1_properties.status: Any = deepcopy(STATUS) + v1_properties.status.refresh = AsyncMock() + v1_properties.dnd: Any = deepcopy(DND_TIMER) + v1_properties.dnd.is_on = True + v1_properties.dnd.refresh = AsyncMock() + v1_properties.dnd.enable = AsyncMock() + v1_properties.dnd.disable = AsyncMock() + v1_properties.dnd.set_dnd_timer = AsyncMock() + v1_properties.clean_summary: Any = deepcopy(CLEAN_SUMMARY) + v1_properties.clean_summary.last_clean_record = deepcopy(CLEAN_RECORD) + v1_properties.clean_summary.refresh = AsyncMock() + v1_properties.consumables = deepcopy(CONSUMABLE) + v1_properties.consumables.refresh = AsyncMock() + v1_properties.consumables.reset_consumable = AsyncMock() + v1_properties.sound_volume = SoundVolume(volume=50) + v1_properties.sound_volume.set_volume = AsyncMock() + v1_properties.sound_volume.refresh = AsyncMock() + v1_properties.command = AsyncMock() + v1_properties.command.send = AsyncMock() + v1_properties.maps = AsyncMock() + v1_properties.maps.current_map = MULTI_MAP_LIST.map_info[1].map_flag + v1_properties.maps.refresh = AsyncMock() + v1_properties.maps.set_current_map = AsyncMock() + v1_properties.map_content = AsyncMock() + v1_properties.map_content.image_content = b"\x89PNG-001" + v1_properties.map_content.map_data = deepcopy(MAP_DATA) + v1_properties.map_content.refresh = AsyncMock() + v1_properties.child_lock = AsyncMock() + v1_properties.child_lock.is_on = True + v1_properties.child_lock.enable = AsyncMock() + v1_properties.child_lock.disable = AsyncMock() + v1_properties.child_lock.refresh = AsyncMock() + v1_properties.led_status = AsyncMock() + v1_properties.led_status.is_on = True + v1_properties.led_status.enable = AsyncMock() + v1_properties.led_status.disable = AsyncMock() + v1_properties.led_status.refresh = AsyncMock() + v1_properties.flow_led_status = AsyncMock() + v1_properties.flow_led_status.is_on = True + v1_properties.flow_led_status.enable = AsyncMock() + v1_properties.flow_led_status.disable = AsyncMock() + v1_properties.flow_led_status.refresh = AsyncMock() + v1_properties.valley_electricity_timer = AsyncMock() + v1_properties.valley_electricity_timer.is_on = True + v1_properties.valley_electricity_timer.enable = AsyncMock() + v1_properties.valley_electricity_timer.disable = AsyncMock() + v1_properties.valley_electricity_timer.refresh = AsyncMock() + v1_properties.dust_collection_mode = AsyncMock() + v1_properties.dust_collection_mode.refresh = AsyncMock() + v1_properties.wash_towel_mode = AsyncMock() + v1_properties.wash_towel_mode.refresh = AsyncMock() + v1_properties.smart_wash_params = AsyncMock() + v1_properties.smart_wash_params.refresh = AsyncMock() + v1_properties.home = AsyncMock() + home_cache = { + map_data.map_flag: CombinedMapInfo( + name=map_data.name, + map_flag=map_data.map_flag, + rooms=[ + NamedRoomMapping( + segment_id=ROOM_MAPPING[room.id], + iot_id=room.id, + name=room.name, + ) + for room in HOME_DATA.rooms ], - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_room_mapping", - return_value=[ - RoomMapping(16, "2362048"), - RoomMapping(17, "2362044"), - RoomMapping(18, "2362041"), - ], - ), - patch( - "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 + ) + for map_data in MULTI_MAP_LIST.map_info + } + v1_properties.home.home_cache = home_cache + v1_properties.home.current_map_data = home_cache[STATUS.current_map] + v1_properties.home.refresh = AsyncMock() + v1_properties.network_info = deepcopy(network_info) + v1_properties.network_info.refresh = AsyncMock() + # Mock diagnostics for a subset of properties + v1_properties.as_dict.return_value = { + "status": STATUS.as_dict(), + "dnd": DND_TIMER.as_dict(), + } + return v1_properties + + +@pytest.fixture(name="fake_devices", autouse=True) +def fake_devices_fixture() -> list[FakeDevice]: + """Fixture to mock the device manager.""" + devices = [] + for device_data, device_product_data in HOME_DATA.device_products.values(): + fake_device = FakeDevice( + device_info=deepcopy(device_data), + product=deepcopy(device_product_data), + ) + if device_data.pv == "1.0": + fake_device.v1_properties = create_v1_properties( + NETWORK_INFO_BY_DEVICE[device_data.duid] + ) + elif device_data.pv == "A01": + if device_product_data.category == RoborockCategory.WET_DRY_VAC: + fake_device.dyad = create_dyad_trait() + elif device_product_data.category == RoborockCategory.WASHING_MACHINE: + fake_device.zeo = create_zeo_trait() + else: + raise ValueError("Unknown A01 category in test HOME_DATA") + else: + raise ValueError("Unknown pv in test HOME_DATA") + devices.append(fake_device) + return devices + + +@pytest.fixture(name="fake_vacuum") +def fake_vacuum_fixture(fake_devices: list[FakeDevice]) -> FakeDevice: + """Get the fake vacuum device.""" + return fake_devices[0] @pytest.fixture(name="send_message_side_effect") @@ -179,18 +257,45 @@ def send_message_side_effect_fixture() -> Any: return None -@pytest.fixture(name="mock_send_message") -def mock_send_message_fixture(send_message_side_effect: Any) -> Mock: - """Fixture to mock the send_message method.""" +@pytest.fixture(name="vacuum_command", autouse=True) +def fake_vacuum_command_fixture( + fake_vacuum: FakeDevice, send_message_side_effect: Any +) -> Mock: + """Get the fake vacuum device command trait for asserting that commands happened.""" + assert fake_vacuum.v1_properties is not None + command_trait = fake_vacuum.v1_properties.command + if send_message_side_effect is not None: + command_trait.send.side_effect = send_message_side_effect + return command_trait + + +@pytest.fixture(name="fake_create_device_manager", autouse=True) +def fake_create_device_manager_fixture( + fake_devices: list[FakeDevice], +) -> Generator[Mock]: + """Fixture to create a fake device manager.""" with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", - side_effect=send_message_side_effect, - ) as mock_send_message: - yield mock_send_message + "homeassistant.components.roborock.create_device_manager", + ) as mock_create_device_manager: + mock_create_device_manager.return_value = FakeDeviceManager(fake_devices) + yield mock_create_device_manager + + +@pytest.fixture(name="bypass_device_manager", autouse=True) +def bypass_device_manager_fixture() -> None: + """Bypass the device manager network connection.""" + with ( + patch("roborock.devices.device_manager.create_lazy_mqtt_session"), + patch( + "roborock.devices.device_manager.create_v1_channel" + ) as mock_create_v1_channel, + ): + mock_create_v1_channel.return_value = AsyncMock() + yield @pytest.fixture -def bypass_api_fixture_v1_only(bypass_api_fixture) -> None: +def bypass_api_fixture_v1_only() -> None: """Bypass api for tests that require only having v1 devices.""" home_data_copy = deepcopy(HOME_DATA) home_data_copy.received_devices = [] @@ -247,7 +352,6 @@ async def mock_patforms_fixture( @pytest.fixture async def setup_entry( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 4fba791938b47e..4e2599b56431fa 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -15,7 +15,6 @@ S7Status, UserData, ) -from roborock.roborock_typing import DeviceProp from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.map_data import ImageData from vacuum_map_parser_roborock.map_data_parser import MapData @@ -1011,6 +1010,12 @@ ], } +ROOM_MAPPING = { + 2362048: 16, + 2362044: 17, + 2362041: 18, +} + HOME_DATA: HomeData = HomeData.from_dict(HOME_DATA_RAW) CLEAN_RECORD = CleanRecord.from_dict( @@ -1113,12 +1118,6 @@ "unsave_map_flag": 0, } ) -PROP = DeviceProp( - status=STATUS, - clean_summary=CLEAN_SUMMARY, - consumable=CONSUMABLE, - last_clean_record=CLEAN_RECORD, -) NETWORK_INFO = NetworkInfo( ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc:cc", bssid="bssid", rssi=90 @@ -1126,6 +1125,10 @@ NETWORK_INFO_2 = NetworkInfo( ip="123.232.12.2", ssid="wifi", mac="ac:cc:cc:cc:cd:cc", bssid="bssid", rssi=90 ) +NETWORK_INFO_BY_DEVICE = { + "abc123": NETWORK_INFO, + "device_2": NETWORK_INFO_2, +} MULTI_MAP_LIST = MultiMapsList.from_dict( { diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 52969cec4b3743..55e8af1f8595e9 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -31,1231 +31,1145 @@ }), 'coordinators': dict({ '**REDACTED-0**': dict({ - 'api': dict({ - }), - 'roborock_device_info': dict({ - 'device': dict({ - 'activeTime': 1672364449, - 'deviceStatus': dict({ - '120': 0, - '121': 8, - '122': 100, - '123': 102, - '124': 203, - '125': 94, - '126': 90, - '127': 87, - '128': 0, - '133': 1, - }), - 'duid': '**REDACTED**', - 'extra': '{"RRPhotoPrivacyVersion": "1"}', - 'featureSet': '2234201184108543', - 'fv': '02.56.02', - 'iconUrl': '', - 'localKey': '**REDACTED**', - 'name': 'Roborock S7 MaxV', - 'newFeatureSet': '0000000000002041', - 'online': True, - 'productId': 's7_product', - 'pv': '1.0', - 'roomId': 2362003, - 'share': False, - 'silentOtaSwitch': True, - 'sn': 'abc123', - 'timeZoneId': 'America/Los_Angeles', - 'tuyaMigrated': False, + 'device': dict({ + 'activeTime': 1672364449, + 'deviceStatus': dict({ + '120': 0, + '121': 8, + '122': 100, + '123': 102, + '124': 203, + '125': 94, + '126': 90, + '127': 87, + '128': 0, + '133': 1, }), - 'network_info': dict({ - 'bssid': '**REDACTED**', - 'ip': '123.232.12.1', - 'mac': '**REDACTED**', - 'rssi': 90, - 'ssid': 'wifi', + 'duid': '**REDACTED**', + 'extra': '{"RRPhotoPrivacyVersion": "1"}', + 'featureSet': '2234201184108543', + 'fv': '02.56.02', + 'iconUrl': '', + 'localKey': '**REDACTED**', + 'name': 'Roborock S7 MaxV', + 'newFeatureSet': '0000000000002041', + 'online': True, + 'productId': 's7_product', + 'pv': '1.0', + 'roomId': 2362003, + 'share': False, + 'silentOtaSwitch': True, + 'sn': '**REDACTED**', + 'timeZoneId': 'America/Los_Angeles', + 'tuyaMigrated': False, + }), + 'product': dict({ + 'capability': 0, + 'category': 'robot.vacuum.cleaner', + 'code': 'a27', + 'id': 's7_product', + 'model': 'roborock.vacuum.a27', + 'name': 'Roborock S7 MaxV', + 'schema': list([ + dict({ + 'code': 'rpc_request', + 'id': '101', + 'mode': 'rw', + 'name': 'rpc_request', + 'type': 'RAW', + }), + dict({ + 'code': 'rpc_response', + 'id': '102', + 'mode': 'rw', + 'name': 'rpc_response', + 'type': 'RAW', + }), + dict({ + 'code': 'error_code', + 'id': '120', + 'mode': 'ro', + 'name': '错误代码', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'state', + 'id': '121', + 'mode': 'ro', + 'name': '设备状态', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'battery', + 'id': '122', + 'mode': 'ro', + 'name': '设备电量', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'fan_power', + 'id': '123', + 'mode': 'rw', + 'name': '清扫模式', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'water_box_mode', + 'id': '124', + 'mode': 'rw', + 'name': '拖地模式', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'main_brush_life', + 'id': '125', + 'mode': 'rw', + 'name': '主刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + 'type': 'VALUE', + }), + dict({ + 'code': 'side_brush_life', + 'id': '126', + 'mode': 'rw', + 'name': '边刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + 'type': 'VALUE', + }), + dict({ + 'code': 'filter_life', + 'id': '127', + 'mode': 'rw', + 'name': '滤网寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + 'type': 'VALUE', + }), + dict({ + 'code': 'additional_props', + 'id': '128', + 'mode': 'ro', + 'name': '额外状态', + 'type': 'RAW', + }), + dict({ + 'code': 'task_complete', + 'id': '130', + 'mode': 'ro', + 'name': '完成事件', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_low_power', + 'id': '131', + 'mode': 'ro', + 'name': '电量不足任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_in_motion', + 'id': '132', + 'mode': 'ro', + 'name': '运动中任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'charge_status', + 'id': '133', + 'mode': 'ro', + 'name': '充电状态', + 'type': 'RAW', + }), + dict({ + 'code': 'drying_status', + 'id': '134', + 'mode': 'ro', + 'name': '烘干状态', + 'type': 'RAW', + }), + ]), + }), + 'traits': dict({ + 'dnd': dict({ + 'enabled': 1, + 'endHour': 7, + 'endMinute': 0, + 'startHour': 22, + 'startMinute': 0, }), - 'product': dict({ - 'capability': 0, - 'category': 'robot.vacuum.cleaner', - 'code': 'a27', - 'id': 's7_product', - 'model': 'roborock.vacuum.a27', - 'name': 'Roborock S7 MaxV', - 'schema': list([ - dict({ - 'code': 'rpc_request', - 'id': '101', - 'mode': 'rw', - 'name': 'rpc_request', - 'type': 'RAW', - }), - dict({ - 'code': 'rpc_response', - 'id': '102', - 'mode': 'rw', - 'name': 'rpc_response', - 'type': 'RAW', - }), - dict({ - 'code': 'error_code', - 'id': '120', - 'mode': 'ro', - 'name': '错误代码', - 'property': '{"range": []}', - 'type': 'ENUM', - }), - dict({ - 'code': 'state', - 'id': '121', - 'mode': 'ro', - 'name': '设备状态', - 'property': '{"range": []}', - 'type': 'ENUM', - }), - dict({ - 'code': 'battery', - 'id': '122', - 'mode': 'ro', - 'name': '设备电量', - 'property': '{"range": []}', - 'type': 'ENUM', - }), - dict({ - 'code': 'fan_power', - 'id': '123', - 'mode': 'rw', - 'name': '清扫模式', - 'property': '{"range": []}', - 'type': 'ENUM', - }), - dict({ - 'code': 'water_box_mode', - 'id': '124', - 'mode': 'rw', - 'name': '拖地模式', - 'property': '{"range": []}', - 'type': 'ENUM', - }), - dict({ - 'code': 'main_brush_life', - 'id': '125', - 'mode': 'rw', - 'name': '主刷寿命', - 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', - 'type': 'VALUE', - }), - dict({ - 'code': 'side_brush_life', - 'id': '126', - 'mode': 'rw', - 'name': '边刷寿命', - 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', - 'type': 'VALUE', - }), - dict({ - 'code': 'filter_life', - 'id': '127', - 'mode': 'rw', - 'name': '滤网寿命', - 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', - 'type': 'VALUE', - }), - dict({ - 'code': 'additional_props', - 'id': '128', - 'mode': 'ro', - 'name': '额外状态', - 'type': 'RAW', - }), - dict({ - 'code': 'task_complete', - 'id': '130', - 'mode': 'ro', - 'name': '完成事件', - 'type': 'RAW', - }), - dict({ - 'code': 'task_cancel_low_power', - 'id': '131', - 'mode': 'ro', - 'name': '电量不足任务取消', - 'type': 'RAW', - }), - dict({ - 'code': 'task_cancel_in_motion', - 'id': '132', - 'mode': 'ro', - 'name': '运动中任务取消', - 'type': 'RAW', - }), - dict({ - 'code': 'charge_status', - 'id': '133', - 'mode': 'ro', - 'name': '充电状态', - 'type': 'RAW', - }), - dict({ - 'code': 'drying_status', - 'id': '134', - 'mode': 'ro', - 'name': '烘干状态', - 'type': 'RAW', - }), + 'status': dict({ + 'adbumperStatus': list([ + 0, + 0, + 0, ]), - }), - 'props': dict({ - 'cleanSummary': dict({ - 'cleanArea': 1159182500, - 'cleanCount': 31, - 'cleanTime': 74382, - 'dustCollectionCount': 25, - 'records': list([ - 1672543330, - 1672458041, - ]), - }), - 'consumable': dict({ - 'cleaningBrushWorkTimes': 65, - 'dustCollectionWorkTimes': 25, - 'filterElementWorkTime': 0, - 'filterWorkTime': 74382, - 'mainBrushWorkTime': 74382, - 'sensorDirtyTime': 74382, - 'sideBrushWorkTime': 74382, - 'strainerWorkTimes': 65, - }), - 'lastCleanRecord': dict({ - 'area': 20965000, - 'avoidCount': 19, - 'begin': 1672543330, - 'cleanType': 3, - 'complete': 1, - 'duration': 1176, - 'dustCollectionStatus': 1, - 'end': 1672544638, - 'error': 0, - 'finishReason': 56, - 'mapFlag': 0, - 'startType': 2, - 'washCount': 2, - }), - 'status': dict({ - 'adbumperStatus': list([ - 0, - 0, - 0, - ]), - 'autoDustCollection': 1, - 'avoidCount': 19, - 'backType': -1, - 'battery': 100, - 'cameraStatus': 3457, - 'chargeStatus': 1, - 'cleanArea': 20965000, - 'cleanTime': 1176, - 'collisionAvoidStatus': 1, - 'debugMode': 0, - 'dndEnabled': 0, - 'dockErrorStatus': 0, - 'dockType': 3, - 'dustCollectionStatus': 0, - 'errorCode': 0, - 'fanPower': 102, - 'homeSecEnablePassword': 0, - 'homeSecStatus': 0, - 'inCleaning': 0, - 'inFreshState': 1, - 'inReturning': 0, - 'isExploring': 0, - 'isLocating': 0, - 'labStatus': 1, - 'lockStatus': 0, - 'mapPresent': 1, - 'mapStatus': 3, - 'mopForbiddenEnable': 1, - 'mopMode': 300, - 'msgSeq': 458, - 'msgVer': 2, - 'state': 8, - 'switchMapMode': 0, - 'unsaveMapFlag': 0, - 'unsaveMapReason': 0, - 'washPhase': 0, - 'washReady': 0, - 'waterBoxCarriageStatus': 1, - 'waterBoxMode': 203, - 'waterBoxStatus': 1, - 'waterShortageStatus': 0, - }), + 'autoDustCollection': 1, + 'avoidCount': 19, + 'backType': -1, + 'battery': 100, + 'cameraStatus': 3457, + 'chargeStatus': 1, + 'cleanArea': 20965000, + 'cleanTime': 1176, + 'collisionAvoidStatus': 1, + 'debugMode': 0, + 'dndEnabled': 0, + 'dockErrorStatus': 0, + 'dockType': 3, + 'dustCollectionStatus': 0, + 'errorCode': 0, + 'fanPower': 102, + 'homeSecEnablePassword': 0, + 'homeSecStatus': 0, + 'inCleaning': 0, + 'inFreshState': 1, + 'inReturning': 0, + 'isExploring': 0, + 'isLocating': 0, + 'labStatus': 1, + 'lockStatus': 0, + 'mapPresent': 1, + 'mapStatus': 3, + 'mopForbiddenEnable': 1, + 'mopMode': 300, + 'msgSeq': 458, + 'msgVer': 2, + 'state': 8, + 'switchMapMode': 0, + 'unsaveMapFlag': 0, + 'unsaveMapReason': 0, + 'washPhase': 0, + 'washReady': 0, + 'waterBoxCarriageStatus': 1, + 'waterBoxMode': 203, + 'waterBoxStatus': 1, + 'waterShortageStatus': 0, }), }), }), '**REDACTED-1**': dict({ - 'api': dict({ - }), - 'roborock_device_info': dict({ - 'device': dict({ - 'activeTime': 1672364449, - 'deviceStatus': dict({ - '120': 0, - '121': 8, - '122': 100, - '123': 102, - '124': 203, - '125': 94, - '126': 90, - '127': 87, - '128': 0, - '133': 1, - }), - 'duid': '**REDACTED**', - 'extra': '{"RRPhotoPrivacyVersion": "1"}', - 'featureSet': '2234201184108543', - 'fv': '02.56.02', - 'iconUrl': '', - 'localKey': '**REDACTED**', - 'name': 'Roborock S7 2', - 'newFeatureSet': '0000000000002041', - 'online': True, - 'productId': 's7_product', - 'pv': '1.0', - 'roomId': 2362003, - 'share': False, - 'silentOtaSwitch': True, - 'sn': 'abc123', - 'timeZoneId': 'America/Los_Angeles', - 'tuyaMigrated': False, + 'device': dict({ + 'activeTime': 1672364449, + 'deviceStatus': dict({ + '120': 0, + '121': 8, + '122': 100, + '123': 102, + '124': 203, + '125': 94, + '126': 90, + '127': 87, + '128': 0, + '133': 1, }), - 'network_info': dict({ - 'bssid': '**REDACTED**', - 'ip': '123.232.12.1', - 'mac': '**REDACTED**', - 'rssi': 90, - 'ssid': 'wifi', + 'duid': '**REDACTED**', + 'extra': '{"RRPhotoPrivacyVersion": "1"}', + 'featureSet': '2234201184108543', + 'fv': '02.56.02', + 'iconUrl': '', + 'localKey': '**REDACTED**', + 'name': 'Roborock S7 2', + 'newFeatureSet': '0000000000002041', + 'online': True, + 'productId': 's7_product', + 'pv': '1.0', + 'roomId': 2362003, + 'share': False, + 'silentOtaSwitch': True, + 'sn': '**REDACTED**', + 'timeZoneId': 'America/Los_Angeles', + 'tuyaMigrated': False, + }), + 'product': dict({ + 'capability': 0, + 'category': 'robot.vacuum.cleaner', + 'code': 'a27', + 'id': 's7_product', + 'model': 'roborock.vacuum.a27', + 'name': 'Roborock S7 MaxV', + 'schema': list([ + dict({ + 'code': 'rpc_request', + 'id': '101', + 'mode': 'rw', + 'name': 'rpc_request', + 'type': 'RAW', + }), + dict({ + 'code': 'rpc_response', + 'id': '102', + 'mode': 'rw', + 'name': 'rpc_response', + 'type': 'RAW', + }), + dict({ + 'code': 'error_code', + 'id': '120', + 'mode': 'ro', + 'name': '错误代码', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'state', + 'id': '121', + 'mode': 'ro', + 'name': '设备状态', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'battery', + 'id': '122', + 'mode': 'ro', + 'name': '设备电量', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'fan_power', + 'id': '123', + 'mode': 'rw', + 'name': '清扫模式', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'water_box_mode', + 'id': '124', + 'mode': 'rw', + 'name': '拖地模式', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'main_brush_life', + 'id': '125', + 'mode': 'rw', + 'name': '主刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + 'type': 'VALUE', + }), + dict({ + 'code': 'side_brush_life', + 'id': '126', + 'mode': 'rw', + 'name': '边刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + 'type': 'VALUE', + }), + dict({ + 'code': 'filter_life', + 'id': '127', + 'mode': 'rw', + 'name': '滤网寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', + 'type': 'VALUE', + }), + dict({ + 'code': 'additional_props', + 'id': '128', + 'mode': 'ro', + 'name': '额外状态', + 'type': 'RAW', + }), + dict({ + 'code': 'task_complete', + 'id': '130', + 'mode': 'ro', + 'name': '完成事件', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_low_power', + 'id': '131', + 'mode': 'ro', + 'name': '电量不足任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_in_motion', + 'id': '132', + 'mode': 'ro', + 'name': '运动中任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'charge_status', + 'id': '133', + 'mode': 'ro', + 'name': '充电状态', + 'type': 'RAW', + }), + dict({ + 'code': 'drying_status', + 'id': '134', + 'mode': 'ro', + 'name': '烘干状态', + 'type': 'RAW', + }), + ]), + }), + 'traits': dict({ + 'dnd': dict({ + 'enabled': 1, + 'endHour': 7, + 'endMinute': 0, + 'startHour': 22, + 'startMinute': 0, }), - 'product': dict({ - 'capability': 0, - 'category': 'robot.vacuum.cleaner', - 'code': 'a27', - 'id': 's7_product', - 'model': 'roborock.vacuum.a27', - 'name': 'Roborock S7 MaxV', - 'schema': list([ - dict({ - 'code': 'rpc_request', - 'id': '101', - 'mode': 'rw', - 'name': 'rpc_request', - 'type': 'RAW', - }), - dict({ - 'code': 'rpc_response', - 'id': '102', - 'mode': 'rw', - 'name': 'rpc_response', - 'type': 'RAW', - }), - dict({ - 'code': 'error_code', - 'id': '120', - 'mode': 'ro', - 'name': '错误代码', - 'property': '{"range": []}', - 'type': 'ENUM', - }), - dict({ - 'code': 'state', - 'id': '121', - 'mode': 'ro', - 'name': '设备状态', - 'property': '{"range": []}', - 'type': 'ENUM', - }), - dict({ - 'code': 'battery', - 'id': '122', - 'mode': 'ro', - 'name': '设备电量', - 'property': '{"range": []}', - 'type': 'ENUM', - }), - dict({ - 'code': 'fan_power', - 'id': '123', - 'mode': 'rw', - 'name': '清扫模式', - 'property': '{"range": []}', - 'type': 'ENUM', - }), - dict({ - 'code': 'water_box_mode', - 'id': '124', - 'mode': 'rw', - 'name': '拖地模式', - 'property': '{"range": []}', - 'type': 'ENUM', - }), - dict({ - 'code': 'main_brush_life', - 'id': '125', - 'mode': 'rw', - 'name': '主刷寿命', - 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', - 'type': 'VALUE', - }), - dict({ - 'code': 'side_brush_life', - 'id': '126', - 'mode': 'rw', - 'name': '边刷寿命', - 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', - 'type': 'VALUE', - }), - dict({ - 'code': 'filter_life', - 'id': '127', - 'mode': 'rw', - 'name': '滤网寿命', - 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', - 'type': 'VALUE', - }), - dict({ - 'code': 'additional_props', - 'id': '128', - 'mode': 'ro', - 'name': '额外状态', - 'type': 'RAW', - }), - dict({ - 'code': 'task_complete', - 'id': '130', - 'mode': 'ro', - 'name': '完成事件', - 'type': 'RAW', - }), - dict({ - 'code': 'task_cancel_low_power', - 'id': '131', - 'mode': 'ro', - 'name': '电量不足任务取消', - 'type': 'RAW', - }), - dict({ - 'code': 'task_cancel_in_motion', - 'id': '132', - 'mode': 'ro', - 'name': '运动中任务取消', - 'type': 'RAW', - }), - dict({ - 'code': 'charge_status', - 'id': '133', - 'mode': 'ro', - 'name': '充电状态', - 'type': 'RAW', - }), - dict({ - 'code': 'drying_status', - 'id': '134', - 'mode': 'ro', - 'name': '烘干状态', - 'type': 'RAW', - }), + 'status': dict({ + 'adbumperStatus': list([ + 0, + 0, + 0, ]), - }), - 'props': dict({ - 'cleanSummary': dict({ - 'cleanArea': 1159182500, - 'cleanCount': 31, - 'cleanTime': 74382, - 'dustCollectionCount': 25, - 'records': list([ - 1672543330, - 1672458041, - ]), - }), - 'consumable': dict({ - 'cleaningBrushWorkTimes': 65, - 'dustCollectionWorkTimes': 25, - 'filterElementWorkTime': 0, - 'filterWorkTime': 74382, - 'mainBrushWorkTime': 74382, - 'sensorDirtyTime': 74382, - 'sideBrushWorkTime': 74382, - 'strainerWorkTimes': 65, - }), - 'lastCleanRecord': dict({ - 'area': 20965000, - 'avoidCount': 19, - 'begin': 1672543330, - 'cleanType': 3, - 'complete': 1, - 'duration': 1176, - 'dustCollectionStatus': 1, - 'end': 1672544638, - 'error': 0, - 'finishReason': 56, - 'mapFlag': 0, - 'startType': 2, - 'washCount': 2, - }), - 'status': dict({ - 'adbumperStatus': list([ - 0, - 0, - 0, - ]), - 'autoDustCollection': 1, - 'avoidCount': 19, - 'backType': -1, - 'battery': 100, - 'cameraStatus': 3457, - 'chargeStatus': 1, - 'cleanArea': 20965000, - 'cleanTime': 1176, - 'collisionAvoidStatus': 1, - 'debugMode': 0, - 'dndEnabled': 0, - 'dockErrorStatus': 0, - 'dockType': 3, - 'dustCollectionStatus': 0, - 'errorCode': 0, - 'fanPower': 102, - 'homeSecEnablePassword': 0, - 'homeSecStatus': 0, - 'inCleaning': 0, - 'inFreshState': 1, - 'inReturning': 0, - 'isExploring': 0, - 'isLocating': 0, - 'labStatus': 1, - 'lockStatus': 0, - 'mapPresent': 1, - 'mapStatus': 3, - 'mopForbiddenEnable': 1, - 'mopMode': 300, - 'msgSeq': 458, - 'msgVer': 2, - 'state': 8, - 'switchMapMode': 0, - 'unsaveMapFlag': 0, - 'unsaveMapReason': 0, - 'washPhase': 0, - 'washReady': 0, - 'waterBoxCarriageStatus': 1, - 'waterBoxMode': 203, - 'waterBoxStatus': 1, - 'waterShortageStatus': 0, - }), + 'autoDustCollection': 1, + 'avoidCount': 19, + 'backType': -1, + 'battery': 100, + 'cameraStatus': 3457, + 'chargeStatus': 1, + 'cleanArea': 20965000, + 'cleanTime': 1176, + 'collisionAvoidStatus': 1, + 'debugMode': 0, + 'dndEnabled': 0, + 'dockErrorStatus': 0, + 'dockType': 3, + 'dustCollectionStatus': 0, + 'errorCode': 0, + 'fanPower': 102, + 'homeSecEnablePassword': 0, + 'homeSecStatus': 0, + 'inCleaning': 0, + 'inFreshState': 1, + 'inReturning': 0, + 'isExploring': 0, + 'isLocating': 0, + 'labStatus': 1, + 'lockStatus': 0, + 'mapPresent': 1, + 'mapStatus': 3, + 'mopForbiddenEnable': 1, + 'mopMode': 300, + 'msgSeq': 458, + 'msgVer': 2, + 'state': 8, + 'switchMapMode': 0, + 'unsaveMapFlag': 0, + 'unsaveMapReason': 0, + 'washPhase': 0, + 'washReady': 0, + 'waterBoxCarriageStatus': 1, + 'waterBoxMode': 203, + 'waterBoxStatus': 1, + 'waterShortageStatus': 0, }), }), }), '**REDACTED-2**': dict({ - 'api': 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', - }), - ]), + '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', + }), + ]), }), }), '**REDACTED-3**': dict({ - 'api': dict({ - }), - 'roborock_device_info': dict({ - 'device': dict({ - 'activeTime': 1699964128, - 'deviceStatus': dict({ - '10001': '{"f":"t"}', - '10005': '{"sn":"zeo_sn","ssid":"internet","timezone":"Europe/Berlin","posix_timezone":"CET-1CEST,M3.5.0,M10.5.0/3","ip":"192.111.11.11","mac":"b0:4a:00:00:00:00","rssi":-57,"oba":{"language":"en","name":"A.03.0403_CE","bom":"A.03.0403","location":"de","wifiplan":"EU","timezone":"CET-1CEST,M3.5.0,M10.5.0/3;Europe/Berlin","logserver":"awsde0","loglevel":"4","featureset":"0"}}', - '10007': '{"mqttOtaData":{"mqttOtaStatus":{"status":"IDLE"}}}', - '200': 1, - '201': 0, - '202': 1, - '203': 7, - '204': 1, - '205': 33, - '206': 0, - '207': 4, - '208': 2, - '209': 7, - '210': 1, - '211': 1, - '212': 1, - '213': 2, - '214': 2, - '217': 0, - '218': 227, - '219': 0, - '220': 0, - '221': 0, - '222': 347414, - '223': 0, - '224': 21, - '225': 0, - '226': 0, - '227': 1, - '232': 0, - }), - 'duid': '**REDACTED**', - 'f': False, - 'featureSet': '0', - 'fv': '01.00.94', - 'iconUrl': '', - 'localKey': '**REDACTED**', - 'name': 'Zeo One', - 'newFeatureSet': '40', - 'online': True, - 'productId': 'zeo_id', - 'pv': 'A01', - 'share': True, - 'shareTime': 1712763572, - 'silentOtaSwitch': False, - 'sn': 'zeo_sn', - 'timeZoneId': 'Europe/Berlin', - 'tuyaMigrated': False, - }), - 'product': dict({ - 'capability': 2, - 'category': 'roborock.wm', - 'id': 'zeo_id', - 'model': 'roborock.wm.a102', - 'name': 'Zeo One', - 'schema': list([ - dict({ - 'code': 'drying_status', - 'id': '134', - 'mode': 'ro', - 'name': '烘干状态', - 'type': 'RAW', - }), - dict({ - 'code': 'start', - 'id': '200', - 'mode': 'rw', - 'name': '启动', - 'type': 'BOOL', - }), - dict({ - 'code': 'pause', - 'id': '201', - 'mode': 'rw', - 'name': '暂停', - 'type': 'BOOL', - }), - dict({ - 'code': 'shutdown', - 'id': '202', - 'mode': 'rw', - 'name': '关机', - 'type': 'BOOL', - }), - dict({ - 'code': 'status', - 'id': '203', - 'mode': 'ro', - 'name': '状态', - 'type': 'VALUE', - }), - dict({ - 'code': 'mode', - 'id': '204', - 'mode': 'rw', - 'name': '模式', - 'type': 'VALUE', - }), - dict({ - 'code': 'program', - 'id': '205', - 'mode': 'rw', - 'name': '程序', - 'type': 'VALUE', - }), - dict({ - 'code': 'child_lock', - 'id': '206', - 'mode': 'rw', - 'name': '童锁', - 'type': 'BOOL', - }), - dict({ - 'code': 'temp', - 'id': '207', - 'mode': 'rw', - 'name': '洗涤温度', - 'type': 'VALUE', - }), - dict({ - 'code': 'rinse_times', - 'id': '208', - 'mode': 'rw', - 'name': '漂洗次数', - 'type': 'VALUE', - }), - dict({ - 'code': 'spin_level', - 'id': '209', - 'mode': 'rw', - 'name': '滚筒转速', - 'type': 'VALUE', - }), - dict({ - 'code': 'drying_mode', - 'id': '210', - 'mode': 'rw', - 'name': '干燥度', - 'type': 'VALUE', - }), - dict({ - 'code': 'detergent_set', - 'id': '211', - 'mode': 'rw', - 'name': '自动投放-洗衣液', - 'type': 'BOOL', - }), - dict({ - 'code': 'softener_set', - 'id': '212', - 'mode': 'rw', - 'name': '自动投放-柔顺剂', - 'type': 'BOOL', - }), - dict({ - 'code': 'detergent_type', - 'id': '213', - 'mode': 'rw', - 'name': '洗衣液投放量', - 'type': 'VALUE', - }), - dict({ - 'code': 'softener_type', - 'id': '214', - 'mode': 'rw', - 'name': '柔顺剂投放量', - 'type': 'VALUE', - }), - dict({ - 'code': 'countdown', - 'id': '217', - 'mode': 'rw', - 'name': '预约时间', - 'type': 'VALUE', - }), - dict({ - 'code': 'washing_left', - 'id': '218', - 'mode': 'ro', - 'name': '洗衣剩余时间', - 'type': 'VALUE', - }), - dict({ - 'code': 'doorlock_state', - 'id': '219', - 'mode': 'ro', - 'name': '门锁状态', - 'type': 'BOOL', - }), - dict({ - 'code': 'error', - 'id': '220', - 'mode': 'ro', - 'name': '故障', - 'type': 'VALUE', - }), - dict({ - 'code': 'custom_param_save', - 'id': '221', - 'mode': 'rw', - 'name': '云程序设置', - 'type': 'VALUE', - }), - dict({ - 'code': 'custom_param_get', - 'id': '222', - 'mode': 'ro', - 'name': '云程序读取', - 'type': 'VALUE', - }), - dict({ - 'code': 'sound_set', - 'id': '223', - 'mode': 'rw', - 'name': '提示音', - 'type': 'BOOL', - }), - dict({ - 'code': 'times_after_clean', - 'id': '224', - 'mode': 'ro', - 'name': '距离上次筒自洁次数', - 'type': 'VALUE', - }), - dict({ - 'code': 'default_setting', - 'id': '225', - 'mode': 'rw', - 'name': '记忆洗衣偏好开关', - 'type': 'BOOL', - }), - dict({ - 'code': 'detergent_empty', - 'id': '226', - 'mode': 'ro', - 'name': '洗衣液用尽', - 'type': 'BOOL', - }), - dict({ - 'code': 'softener_empty', - 'id': '227', - 'mode': 'ro', - 'name': '柔顺剂用尽', - 'type': 'BOOL', - }), - dict({ - 'code': 'light_setting', - 'id': '229', - 'mode': 'rw', - 'name': '筒灯设定', - 'type': 'BOOL', - }), - dict({ - 'code': 'detergent_volume', - 'id': '230', - 'mode': 'rw', - 'name': '洗衣液投放量(单次)', - 'type': 'VALUE', - }), - dict({ - 'code': 'softener_volume', - 'id': '231', - 'mode': 'rw', - 'name': '柔顺剂投放量(单次)', - 'type': 'VALUE', - }), - dict({ - 'code': 'app_authorization', - 'id': '232', - 'mode': 'rw', - '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': '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': 'rw', - 'name': 'OTA info', - 'type': 'STRING', - }), - dict({ - 'code': 'washing_log', - 'id': '10008', - 'mode': 'ro', - 'name': '洗衣记录', - 'type': 'BOOL', - }), - 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', - }), - ]), + 'device': dict({ + 'activeTime': 1699964128, + 'deviceStatus': dict({ + '10001': '{"f":"t"}', + '10005': '{"sn":"zeo_sn","ssid":"internet","timezone":"Europe/Berlin","posix_timezone":"CET-1CEST,M3.5.0,M10.5.0/3","ip":"192.111.11.11","mac":"b0:4a:00:00:00:00","rssi":-57,"oba":{"language":"en","name":"A.03.0403_CE","bom":"A.03.0403","location":"de","wifiplan":"EU","timezone":"CET-1CEST,M3.5.0,M10.5.0/3;Europe/Berlin","logserver":"awsde0","loglevel":"4","featureset":"0"}}', + '10007': '{"mqttOtaData":{"mqttOtaStatus":{"status":"IDLE"}}}', + '200': 1, + '201': 0, + '202': 1, + '203': 7, + '204': 1, + '205': 33, + '206': 0, + '207': 4, + '208': 2, + '209': 7, + '210': 1, + '211': 1, + '212': 1, + '213': 2, + '214': 2, + '217': 0, + '218': 227, + '219': 0, + '220': 0, + '221': 0, + '222': 347414, + '223': 0, + '224': 21, + '225': 0, + '226': 0, + '227': 1, + '232': 0, }), + 'duid': '**REDACTED**', + 'f': False, + 'featureSet': '0', + 'fv': '01.00.94', + 'iconUrl': '', + 'localKey': '**REDACTED**', + 'name': 'Zeo One', + 'newFeatureSet': '40', + 'online': True, + 'productId': 'zeo_id', + 'pv': 'A01', + 'share': True, + 'shareTime': 1712763572, + 'silentOtaSwitch': False, + 'sn': '**REDACTED**', + 'timeZoneId': 'Europe/Berlin', + 'tuyaMigrated': False, + }), + 'product': dict({ + 'capability': 2, + 'category': 'roborock.wm', + 'id': 'zeo_id', + 'model': 'roborock.wm.a102', + 'name': 'Zeo One', + 'schema': list([ + dict({ + 'code': 'drying_status', + 'id': '134', + 'mode': 'ro', + 'name': '烘干状态', + 'type': 'RAW', + }), + dict({ + 'code': 'start', + 'id': '200', + 'mode': 'rw', + 'name': '启动', + 'type': 'BOOL', + }), + dict({ + 'code': 'pause', + 'id': '201', + 'mode': 'rw', + 'name': '暂停', + 'type': 'BOOL', + }), + dict({ + 'code': 'shutdown', + 'id': '202', + 'mode': 'rw', + 'name': '关机', + 'type': 'BOOL', + }), + dict({ + 'code': 'status', + 'id': '203', + 'mode': 'ro', + 'name': '状态', + 'type': 'VALUE', + }), + dict({ + 'code': 'mode', + 'id': '204', + 'mode': 'rw', + 'name': '模式', + 'type': 'VALUE', + }), + dict({ + 'code': 'program', + 'id': '205', + 'mode': 'rw', + 'name': '程序', + 'type': 'VALUE', + }), + dict({ + 'code': 'child_lock', + 'id': '206', + 'mode': 'rw', + 'name': '童锁', + 'type': 'BOOL', + }), + dict({ + 'code': 'temp', + 'id': '207', + 'mode': 'rw', + 'name': '洗涤温度', + 'type': 'VALUE', + }), + dict({ + 'code': 'rinse_times', + 'id': '208', + 'mode': 'rw', + 'name': '漂洗次数', + 'type': 'VALUE', + }), + dict({ + 'code': 'spin_level', + 'id': '209', + 'mode': 'rw', + 'name': '滚筒转速', + 'type': 'VALUE', + }), + dict({ + 'code': 'drying_mode', + 'id': '210', + 'mode': 'rw', + 'name': '干燥度', + 'type': 'VALUE', + }), + dict({ + 'code': 'detergent_set', + 'id': '211', + 'mode': 'rw', + 'name': '自动投放-洗衣液', + 'type': 'BOOL', + }), + dict({ + 'code': 'softener_set', + 'id': '212', + 'mode': 'rw', + 'name': '自动投放-柔顺剂', + 'type': 'BOOL', + }), + dict({ + 'code': 'detergent_type', + 'id': '213', + 'mode': 'rw', + 'name': '洗衣液投放量', + 'type': 'VALUE', + }), + dict({ + 'code': 'softener_type', + 'id': '214', + 'mode': 'rw', + 'name': '柔顺剂投放量', + 'type': 'VALUE', + }), + dict({ + 'code': 'countdown', + 'id': '217', + 'mode': 'rw', + 'name': '预约时间', + 'type': 'VALUE', + }), + dict({ + 'code': 'washing_left', + 'id': '218', + 'mode': 'ro', + 'name': '洗衣剩余时间', + 'type': 'VALUE', + }), + dict({ + 'code': 'doorlock_state', + 'id': '219', + 'mode': 'ro', + 'name': '门锁状态', + 'type': 'BOOL', + }), + dict({ + 'code': 'error', + 'id': '220', + 'mode': 'ro', + 'name': '故障', + 'type': 'VALUE', + }), + dict({ + 'code': 'custom_param_save', + 'id': '221', + 'mode': 'rw', + 'name': '云程序设置', + 'type': 'VALUE', + }), + dict({ + 'code': 'custom_param_get', + 'id': '222', + 'mode': 'ro', + 'name': '云程序读取', + 'type': 'VALUE', + }), + dict({ + 'code': 'sound_set', + 'id': '223', + 'mode': 'rw', + 'name': '提示音', + 'type': 'BOOL', + }), + dict({ + 'code': 'times_after_clean', + 'id': '224', + 'mode': 'ro', + 'name': '距离上次筒自洁次数', + 'type': 'VALUE', + }), + dict({ + 'code': 'default_setting', + 'id': '225', + 'mode': 'rw', + 'name': '记忆洗衣偏好开关', + 'type': 'BOOL', + }), + dict({ + 'code': 'detergent_empty', + 'id': '226', + 'mode': 'ro', + 'name': '洗衣液用尽', + 'type': 'BOOL', + }), + dict({ + 'code': 'softener_empty', + 'id': '227', + 'mode': 'ro', + 'name': '柔顺剂用尽', + 'type': 'BOOL', + }), + dict({ + 'code': 'light_setting', + 'id': '229', + 'mode': 'rw', + 'name': '筒灯设定', + 'type': 'BOOL', + }), + dict({ + 'code': 'detergent_volume', + 'id': '230', + 'mode': 'rw', + 'name': '洗衣液投放量(单次)', + 'type': 'VALUE', + }), + dict({ + 'code': 'softener_volume', + 'id': '231', + 'mode': 'rw', + 'name': '柔顺剂投放量(单次)', + 'type': 'VALUE', + }), + dict({ + 'code': 'app_authorization', + 'id': '232', + 'mode': 'rw', + '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': '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': 'rw', + 'name': 'OTA info', + 'type': 'STRING', + }), + dict({ + 'code': 'washing_log', + 'id': '10008', + 'mode': 'ro', + 'name': '洗衣记录', + 'type': 'BOOL', + }), + 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_button.py b/tests/components/roborock/test_button.py index 7dc15c02bc4472..2d08e2202e8d5d 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -11,11 +11,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from .conftest import FakeDevice + from tests.common import MockConfigEntry @pytest.fixture -def bypass_api_client_get_scenes_fixture(bypass_api_fixture) -> None: +def get_scenes_failure_fixture() -> None: """Fixture to raise when getting scenes.""" with ( patch( @@ -32,6 +34,13 @@ def platforms() -> list[Platform]: return [Platform.BUTTON] +@pytest.fixture(name="consumeables_trait", autouse=True) +def consumeables_trait_fixture(fake_vacuum: FakeDevice) -> Mock: + """Get the fake vacuum device command trait for asserting that commands happened.""" + assert fake_vacuum.v1_properties is not None + return fake_vacuum.v1_properties.consumables + + @pytest.mark.parametrize( ("entity_id"), [ @@ -45,10 +54,10 @@ def platforms() -> list[Platform]: @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_success( hass: HomeAssistant, - bypass_api_fixture, + bypass_api_client_fixture: None, setup_entry: MockConfigEntry, entity_id: str, - mock_send_message: Mock, + consumeables_trait: Mock, ) -> None: """Test pressing the button entities.""" # Ensure that the entity exist, as these test can pass even if there is no entity. @@ -59,7 +68,7 @@ async def test_update_success( blocking=True, target={"entity_id": entity_id}, ) - assert mock_send_message.assert_called_once + assert consumeables_trait.reset_consumable.assert_called_once assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" @@ -71,15 +80,16 @@ async def test_update_success( ) @pytest.mark.freeze_time("2023-10-30 08:50:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("send_message_side_effect", [RoborockTimeout]) async def test_update_failure( hass: HomeAssistant, - bypass_api_fixture, + bypass_api_client_fixture: None, setup_entry: MockConfigEntry, entity_id: str, - mock_send_message: Mock, + consumeables_trait: Mock, ) -> None: """Test failure while pressing the button entity.""" + consumeables_trait.reset_consumable.side_effect = RoborockTimeout + # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id).state == "unknown" with pytest.raises( @@ -91,7 +101,7 @@ async def test_update_failure( blocking=True, target={"entity_id": entity_id}, ) - assert mock_send_message.assert_called_once + assert consumeables_trait.reset_consumable.assert_called_once assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" @@ -105,7 +115,7 @@ async def test_update_failure( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_get_button_routines_failure( hass: HomeAssistant, - bypass_api_client_get_scenes_fixture, + get_scenes_failure_fixture: None, setup_entry: MockConfigEntry, entity_id: str, ) -> None: @@ -125,7 +135,7 @@ async def test_get_button_routines_failure( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_press_routine_button_success( hass: HomeAssistant, - bypass_api_fixture, + bypass_api_client_fixture: None, setup_entry: MockConfigEntry, entity_id: str, routine_id: int, @@ -154,7 +164,7 @@ async def test_press_routine_button_success( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_press_routine_button_failure( hass: HomeAssistant, - bypass_api_fixture, + bypass_api_client_fixture: None, setup_entry: MockConfigEntry, entity_id: str, routine_id: int, diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 125476b0eddc4f..56889c84a82822 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -32,9 +32,14 @@ def cleanup_map_storage(): return +@pytest.fixture(autouse=True) +def bypass_api_fixture(bypass_api_client_fixture: None) -> None: + """Bypass the API calls fixture.""" + return + + async def test_config_flow_success( hass: HomeAssistant, - bypass_api_fixture, ) -> None: """Handle the config flow and make sure it succeeds.""" with patch( @@ -87,7 +92,6 @@ async def test_config_flow_success( ) async def test_config_flow_failures_request_code( hass: HomeAssistant, - bypass_api_fixture, request_code_side_effect: Exception | None, request_code_errors: dict[str, str], ) -> None: @@ -149,7 +153,6 @@ async def test_config_flow_failures_request_code( ) async def test_config_flow_failures_code_login( hass: HomeAssistant, - bypass_api_fixture, code_login_side_effect: Exception | None, code_login_errors: dict[str, str], ) -> None: @@ -199,7 +202,7 @@ async def test_config_flow_failures_code_login( async def test_options_flow_drawables( - hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> None: """Test that the options flow works.""" with patch("homeassistant.components.roborock.roborock_storage"): @@ -227,7 +230,7 @@ async def test_options_flow_drawables( async def test_reauth_flow( - hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> None: """Test reauth flow.""" result = await mock_roborock_entry.start_reauth_flow(hass) @@ -266,7 +269,6 @@ async def test_reauth_flow( async def test_account_already_configured( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, ) -> None: """Ensure the same account cannot be setup twice.""" @@ -301,7 +303,6 @@ async def test_account_already_configured( async def test_reauth_wrong_account( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, ) -> None: """Ensure that reauthentication must use the same account.""" @@ -336,7 +337,6 @@ async def test_reauth_wrong_account( async def test_discovery_not_setup( hass: HomeAssistant, - bypass_api_fixture, ) -> None: """Handle the config flow and make sure it succeeds.""" with ( @@ -381,7 +381,6 @@ async def test_discovery_not_setup( @pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_discovery_already_setup( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, ) -> None: """Handle aborting if the device is already setup.""" diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py deleted file mode 100644 index 77b87752dc6b71..00000000000000 --- a/tests/components/roborock/test_coordinator.py +++ /dev/null @@ -1,319 +0,0 @@ -"""Test Roborock Coordinator specific logic.""" - -import asyncio -import copy -from datetime import timedelta -from unittest.mock import patch - -import pytest -from roborock import MultiMapsList -from roborock.exceptions import RoborockException -from vacuum_map_parser_base.config.color import SupportedColor - -from homeassistant.components.roborock.const import ( - CONF_SHOW_BACKGROUND, - DOMAIN, - GET_MAPS_SERVICE_NAME, - V1_CLOUD_IN_CLEANING_INTERVAL, - V1_CLOUD_NOT_CLEANING_INTERVAL, - V1_LOCAL_IN_CLEANING_INTERVAL, - V1_LOCAL_NOT_CLEANING_INTERVAL, -) -from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoordinator -from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.util import dt as dt_util - -from .mock_data import PROP - -from tests.common import MockConfigEntry, async_fire_time_changed - - -@pytest.fixture -def platforms() -> list[Platform]: - """Fixture to set platforms used in the test.""" - return [Platform.SENSOR, Platform.VACUUM] - - -@pytest.mark.parametrize( - ("interval", "in_cleaning"), - [ - (V1_CLOUD_IN_CLEANING_INTERVAL, 1), - (V1_CLOUD_NOT_CLEANING_INTERVAL, 0), - ], -) -async def test_dynamic_cloud_scan_interval( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture_v1_only, - interval: timedelta, - in_cleaning: int, -) -> None: - """Test dynamic scan interval.""" - prop = copy.deepcopy(PROP) - prop.status.in_cleaning = in_cleaning - with ( - # Force the system to use the cloud api. - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.ping", - side_effect=RoborockException(), - ), - patch( - "homeassistant.components.roborock.RoborockMqttClientV1.get_prop", - return_value=prop, - ), - ): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" - prop = copy.deepcopy(prop) - prop.status.battery = 20 - with patch( - "homeassistant.components.roborock.RoborockMqttClientV1.get_prop", - return_value=prop, - ): - async_fire_time_changed( - hass, dt_util.utcnow() + interval - timedelta(seconds=5) - ) - assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" - async_fire_time_changed(hass, dt_util.utcnow() + interval) - - assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" - - -async def test_visible_background( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture: None, -) -> None: - """Test that a visible background is handled correctly.""" - hass.config_entries.async_update_entry( - mock_roborock_entry, - options={ - CONF_SHOW_BACKGROUND: True, - }, - ) - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - await hass.async_block_till_done() - coordinator: RoborockDataUpdateCoordinator = mock_roborock_entry.runtime_data.v1[0] - assert coordinator.map_parser._palette.get_color(SupportedColor.MAP_OUTSIDE) != ( - 0, - 0, - 0, - 0, - ) - - -@pytest.mark.parametrize( - ("interval", "in_cleaning"), - [ - (V1_LOCAL_IN_CLEANING_INTERVAL, 1), - (V1_LOCAL_NOT_CLEANING_INTERVAL, 0), - ], -) -async def test_dynamic_local_scan_interval( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture_v1_only, - interval: timedelta, - in_cleaning: int, -) -> None: - """Test dynamic scan interval.""" - prop = copy.deepcopy(PROP) - prop.status.in_cleaning = in_cleaning - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=prop, - ), - ): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" - prop = copy.deepcopy(prop) - prop.status.battery = 20 - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=prop, - ): - async_fire_time_changed( - hass, dt_util.utcnow() + interval - timedelta(seconds=5) - ) - assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" - - async_fire_time_changed(hass, dt_util.utcnow() + interval) - - assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" - - -async def test_no_maps( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture: None, -) -> None: - """Test that a device with no maps is handled correctly.""" - prop = copy.deepcopy(PROP) - prop.status.map_status = 252 - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=prop, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", - return_value=MultiMapsList( - max_multi_map=1, max_bak_map=1, multi_map_count=0, map_info=[] - ), - ), - patch( - "homeassistant.components.roborock.RoborockMqttClientV1.load_multi_map" - ) as load_map, - ): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - assert load_map.call_count == 0 - - -async def test_cloud_api_repair( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture_v1_only, -) -> None: - """Test that a repair is created when we use the cloud api.""" - # Force the system to use the cloud api. - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.ping", - side_effect=RoborockException(), - ): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - await hass.async_block_till_done() - - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 2 - # Check that both expected device names are present, regardless of order - assert all( - issue.translation_key == "cloud_api_used" - for issue in issue_registry.issues.values() - ) - names = { - issue.translation_placeholders["device_name"] - for issue in issue_registry.issues.values() - } - assert names == {"Roborock S7 MaxV", "Roborock S7 2"} - await hass.config_entries.async_unload(mock_roborock_entry.entry_id) - # Now change to using the local api - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.ping" - ): - # Set it back up - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 0 - - -async def test_two_maps_in_cleaning( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture: None, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that we gracefully handle having two maps but we are in cleaning.""" - prop = copy.deepcopy(PROP) - prop.status.in_cleaning = True - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=prop, - ), - patch( - "homeassistant.components.roborock.RoborockMqttClientV1.load_multi_map" - ) as load_map, - ): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - # We should not try to load any maps as we should just get the information for our - # current map and move on. - assert load_map.call_count == 0 - assert ( - "Vacuum is cleaning, not switching to other maps to fetch rooms" in caplog.text - ) - - -async def test_failed_load_multi_map( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture: None, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that we gracefully handle one map failing to load.""" - with ( - patch( - "homeassistant.components.roborock.RoborockMqttClientV1.load_multi_map", - side_effect=[RoborockException(), None, None, None], - ) as load_map, - ): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - assert "Failed to change to map 1 when refreshing maps" in caplog.text - # We continue to try and load the next map so we we should have multiple load maps. - # 2 for both devices, even though one for one of the devices failed. - assert load_map.call_count == 4 - # Just to be safe since we load the maps asynchronously, lets make sure that only - # one map out of the four didn't get called. - responses = await asyncio.gather( - *( - hass.services.async_call( - DOMAIN, - GET_MAPS_SERVICE_NAME, - {ATTR_ENTITY_ID: dev}, - blocking=True, - return_response=True, - ) - for dev in ("vacuum.roborock_s7_maxv", "vacuum.roborock_s7_2") - ) - ) - num_no_rooms = sum( - 1 - for res in responses - for data in res.values() - for m in data["maps"] - if not m["rooms"] - ) - assert num_no_rooms == 1 - - -async def test_failed_reset_map( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture: None, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that we gracefully handle not being able to revert back to the original map.""" - with ( - patch( - "homeassistant.components.roborock.RoborockMqttClientV1.load_multi_map", - side_effect=[None, None, None, RoborockException()], - ) as load_map, - ): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - assert "Failed to change back to map 0 when refreshing maps" in caplog.text - # 2 for both devices, even though one for one of the devices failed. - assert load_map.call_count == 4 - responses = await asyncio.gather( - *( - hass.services.async_call( - DOMAIN, - GET_MAPS_SERVICE_NAME, - {ATTR_ENTITY_ID: dev}, - blocking=True, - return_response=True, - ) - for dev in ("vacuum.roborock_s7_maxv", "vacuum.roborock_s7_2") - ) - ) - num_no_rooms = sum( - 1 - for res in responses - for data in res.values() - for m in data["maps"] - if not m["rooms"] - ) - # No maps should be missing information, as we just couldn't go back to the original. - assert num_no_rooms == 0 diff --git a/tests/components/roborock/test_diagnostics.py b/tests/components/roborock/test_diagnostics.py index cc02fff3edcf1c..f7b16266bf5fd0 100644 --- a/tests/components/roborock/test_diagnostics.py +++ b/tests/components/roborock/test_diagnostics.py @@ -13,7 +13,6 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - bypass_api_fixture, setup_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 93234d084a65b9..96396fd79e4596 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -3,13 +3,12 @@ import copy from datetime import timedelta from http import HTTPStatus -from unittest.mock import patch +import logging +from unittest.mock import AsyncMock, patch -from PIL import Image import pytest from roborock import RoborockException from roborock.data import RoborockStateCode -from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN from homeassistant.components.roborock.const import V1_LOCAL_NOT_CLEANING_INTERVAL @@ -19,11 +18,14 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from .mock_data import MAP_DATA, PROP +from .conftest import FakeDevice +from .mock_data import MAP_DATA, STATUS from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator +_LOGGER = logging.getLogger(__name__) + @pytest.fixture def platforms() -> list[Platform]: @@ -35,6 +37,7 @@ async def test_floorplan_image( hass: HomeAssistant, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, + fake_devices: list[FakeDevice], ) -> None: """Test floor plan map image is correctly set up.""" assert len(hass.states.async_all("image")) == 4 @@ -46,88 +49,47 @@ async def test_floorplan_image( assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None - assert body[0:4] == b"\x89PNG" + assert body == b"\x89PNG-001" # Call a second time - this time forcing it to update - and save new image now = dt_util.utcnow() + timedelta(minutes=61) - # Copy the device prop so we don't override it - prop = copy.deepcopy(PROP) - prop.status.in_cleaning = 1 - new_map_data = copy.deepcopy(MAP_DATA) - new_map_data.image = ImageData( - 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (2, 2)), lambda p: p - ) + # Update maps for all v1 devices + for fake_vacuum in fake_devices: + if fake_vacuum.v1_properties is None: + continue + assert fake_vacuum.v1_properties + fake_vacuum.v1_properties.status.in_cleaning = 1 + assert fake_vacuum.v1_properties.map_content + fake_vacuum.v1_properties.map_content.image_content = b"\x89PNG-002" + with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=prop, - ), patch( "homeassistant.components.roborock.coordinator.dt_util.utcnow", return_value=now, ), - patch( - "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - return_value=MAP_DATA, - ) as parse_map, ): # This should call parse_map twice as the both devices are in cleaning. async_fire_time_changed(hass, now) + # Refresh device in the background + await hass.async_block_till_done() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK resp = await client.get("/api/image_proxy/image.roborock_s7_2_upstairs") assert resp.status == HTTPStatus.OK - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_downstairs") - assert resp.status == HTTPStatus.OK - body = await resp.read() - assert body is not None - assert parse_map.call_count == 2 - - -async def test_floorplan_image_failed_parse( - hass: HomeAssistant, - setup_entry: MockConfigEntry, - hass_client: ClientSessionGenerator, -) -> None: - """Test that we correctly handle getting None from the image parser.""" - client = await hass_client() - map_data = copy.deepcopy(MAP_DATA) - map_data.image = None - now = dt_util.utcnow() + timedelta(seconds=91) - # Copy the device prop so we don't override it - prop = copy.deepcopy(PROP) - prop.status.in_cleaning = 1 - previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state - # Update image, but get none for parse image. - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - return_value=map_data, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=prop, - ), - patch( - "homeassistant.components.roborock.coordinator.dt_util.utcnow", - return_value=now, - ), - ): - async_fire_time_changed(hass, now) - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - # The map should load fine from the coordinator, but it should not update the - # last_updated timestamp. - assert resp.ok - assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state + # This image has not been loaded yet since it has never been an + # active map. + # XXX: Load this image the first time, but not after, and revert this. + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_downstairs") + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR async def test_fail_to_save_image( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_roborock_entry: MockConfigEntry, - bypass_api_fixture, caplog: pytest.LogCaptureFixture, ) -> None: """Test that we gracefully handle a oserror on saving an image.""" @@ -168,9 +130,6 @@ async def test_fail_to_load_image( "homeassistant.components.roborock.roborock_storage.Path.read_bytes", side_effect=OSError, ) as read_bytes, - patch( - "homeassistant.components.roborock.coordinator.RoborockDataUpdateCoordinator.refresh_coordinator_map" - ), ): # Reload the config entry so that the map is saved in storage and entities exist. await hass.config_entries.async_reload(setup_entry.entry_id) @@ -179,42 +138,17 @@ async def test_fail_to_load_image( assert "Unable to read map file" in caplog.text -async def test_fail_parse_on_startup( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture, -) -> None: - """Test that if we fail parsing on startup, we still create the entity.""" - map_data = copy.deepcopy(MAP_DATA) - map_data.image = None - with patch( - "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - return_value=map_data, - ): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert ( - image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") - ) is not None - assert image_entity.state - - async def test_fail_get_map_on_startup( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_roborock_entry: MockConfigEntry, - bypass_api_fixture, + fake_vacuum: FakeDevice, ) -> None: """Test that if we fail getting map on startup, we can still create the entity.""" - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", - return_value=None, - ), - ): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + assert fake_vacuum.v1_properties + fake_vacuum.v1_properties.map_content.refresh.side_effect = RoborockException + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() assert ( image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") ) is not None @@ -225,71 +159,31 @@ async def test_fail_updating_image( hass: HomeAssistant, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, + fake_vacuum: FakeDevice, ) -> None: """Test that we handle failing getting the image after it has already been setup..""" client = await hass_client() - map_data = copy.deepcopy(MAP_DATA) - map_data.image = None - now = dt_util.utcnow() + timedelta(seconds=91) - # Copy the device prop so we don't override it - prop = copy.deepcopy(PROP) - prop.status.in_cleaning = 1 - # Update image, but get none for parse image. - previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - return_value=map_data, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=prop, - ), - patch( - "homeassistant.components.roborock.coordinator.dt_util.utcnow", - return_value=now, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", - side_effect=RoborockException, - ), - ): - async_fire_time_changed(hass, now) - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - # The map should load fine from the coordinator, but it should not update the - # last_updated timestamp. - assert resp.ok - assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state + assert fake_vacuum.v1_properties + fake_vacuum.v1_properties.map_content.refresh.side_effect = RoborockException + # Copy the device status so we don't override it + fake_vacuum.v1_properties.status = copy.deepcopy(STATUS) + fake_vacuum.v1_properties.status.in_cleaning = 1 + fake_vacuum.v1_properties.status.refresh = AsyncMock() -async def test_index_error_map( - hass: HomeAssistant, - setup_entry: MockConfigEntry, - hass_client: ClientSessionGenerator, -) -> None: - """Test that we handle failing getting the image after it has already been setup with a indexerror.""" - client = await hass_client() now = dt_util.utcnow() + timedelta(seconds=91) - # Copy the device prop so we don't override it - prop = copy.deepcopy(PROP) - prop.status.in_cleaning = 1 + # Update image, but get none for parse image. previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state - # Update image, but get IndexError for image. with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - side_effect=IndexError, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=prop, - ), patch( "homeassistant.components.roborock.coordinator.dt_util.utcnow", return_value=now, ), ): async_fire_time_changed(hass, now) + # Refresh device in the background + await hass.async_block_till_done() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") # The map should load fine from the coordinator, but it should not update the # last_updated timestamp. @@ -301,6 +195,7 @@ async def test_map_status_change( hass: HomeAssistant, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, + fake_vacuum: FakeDevice, ) -> None: """Test floor plan map image is correctly updated on status change.""" assert len(hass.states.async_all("image")) == 4 @@ -310,37 +205,34 @@ async def test_map_status_change( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK old_body = await resp.read() - assert old_body[0:4] == b"\x89PNG" + assert old_body == b"\x89PNG-001" + + _LOGGER.debug("First image fetch complete") # Call a second time. This interval does not directly trigger a map update, but does # trigger a status update which detects the state has changed and uddates the map now = dt_util.utcnow() + V1_LOCAL_NOT_CLEANING_INTERVAL + assert fake_vacuum.v1_properties # Copy the device prop so we don't override it - prop = copy.deepcopy(PROP) - prop.status.state = RoborockStateCode.docking - new_map_data = copy.deepcopy(MAP_DATA) - new_map_data.image = ImageData( - 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (2, 2)), lambda p: p - ) - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=prop, - ), - patch( - "homeassistant.components.roborock.coordinator.dt_util.utcnow", - return_value=now, - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - return_value=new_map_data, - ), + fake_vacuum.v1_properties.status = copy.deepcopy(STATUS) + fake_vacuum.v1_properties.status.state = RoborockStateCode.returning_home + fake_vacuum.v1_properties.status.refresh = AsyncMock() + fake_vacuum.v1_properties.map_content.map_data = copy.deepcopy(MAP_DATA) + fake_vacuum.v1_properties.map_content.image_content = b"\x89PNG-003" + fake_vacuum.v1_properties.map_content.refresh = AsyncMock() + + with patch( + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ): async_fire_time_changed(hass, now) + # Refresh device in the background + await hass.async_block_till_done() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK - assert resp.status == HTTPStatus.OK - body = await resp.read() - assert body is not None - assert body != old_body + body = await resp.read() + assert body is not None + assert body != old_body diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 01a8aa26de7b10..97daaddec4b420 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,6 +1,5 @@ """Test for Roborock init.""" -from copy import deepcopy from http import HTTPStatus import pathlib from typing import Any @@ -8,7 +7,6 @@ import pytest from roborock import ( - RoborockException, RoborockInvalidCredentials, RoborockInvalidUserAgreement, RoborockNoUserAgreement, @@ -21,150 +19,28 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component -from .mock_data import ( - HOME_DATA, - NETWORK_INFO, - NETWORK_INFO_2, - ROBOROCK_RRUID, - USER_EMAIL, -) +from .conftest import FakeDevice +from .mock_data import ROBOROCK_RRUID, USER_EMAIL from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator -async def test_unload_entry( - hass: HomeAssistant, bypass_api_fixture, setup_entry: MockConfigEntry -) -> None: +async def test_unload_entry(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test unloading roboorck integration.""" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert setup_entry.state is ConfigEntryState.LOADED - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.async_release" - ) as mock_disconnect: - assert await hass.config_entries.async_unload(setup_entry.entry_id) - await hass.async_block_till_done() - assert mock_disconnect.call_count == 2 - assert setup_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_config_entry_not_ready( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry -) -> None: - """Test that when coordinator update fails, entry retries.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - side_effect=RoborockException(), - ), - ): - await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_config_entry_not_ready_home_data( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry -) -> None: - """Test that when we fail to get home data, entry retries.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", - side_effect=RoborockException(), - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - side_effect=RoborockException(), - ), - ): - await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_get_networking_fails( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture_v1_only, -) -> None: - """Test that when networking fails, we attempt to retry.""" - with patch( - "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - side_effect=RoborockException(), - ): - await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_get_networking_fails_none( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture_v1_only, -) -> None: - """Test that when networking returns None, we attempt to retry.""" - with patch( - "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - return_value=None, - ): - await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_cloud_client_fails_props( - 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 ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.ping", - side_effect=RoborockException(), - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_prop", - side_effect=RoborockException(), - ), - ): - await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_local_client_fails_props( - 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( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - side_effect=RoborockException(), - ): - await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_fail_maps( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - bypass_api_fixture_v1_only, -) -> None: - """Test that the integration fails to load if we fail to get the maps.""" - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", - side_effect=RoborockException(), - ): - await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + assert await hass.config_entries.async_unload(setup_entry.entry_id) + await hass.async_block_till_done() + assert setup_entry.state is ConfigEntryState.NOT_LOADED async def test_reauth_started( - hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> None: """Test reauth flow started.""" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", + "homeassistant.components.roborock.create_device_manager", side_effect=RoborockInvalidCredentials(), ): await async_setup_component(hass, DOMAIN, {}) @@ -178,7 +54,6 @@ async def test_reauth_started( @pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) async def test_remove_from_hass( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, storage_path: pathlib.Path, @@ -208,7 +83,6 @@ async def test_remove_from_hass( @pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) async def test_oserror_remove_image( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, storage_path: pathlib.Path, hass_client: ClientSessionGenerator, @@ -241,48 +115,39 @@ async def test_oserror_remove_image( async def test_not_supported_protocol( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, + fake_devices: list[FakeDevice], ) -> 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_v3", - return_value=home_data_copy, - ): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - await hass.async_block_till_done() - assert "because its protocol version random" in caplog.text + fake_devices[0].v1_properties = None + fake_devices[0].zeo = None + fake_devices[0].dyad = None + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + assert "because its protocol version " in caplog.text async def test_not_supported_a01_device( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, + fake_devices: list[FakeDevice], ) -> None: """Test that we output a message on incorrect category.""" - home_data_copy = deepcopy(HOME_DATA) - home_data_copy.products[2].category = "random" - with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", - return_value=home_data_copy, - ): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + fake_devices[2].product.category = "random" + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() assert "The device you added is not yet supported" in caplog.text async def test_invalid_user_agreement( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, ) -> None: """Test that we fail setting up if the user agreement is out of date.""" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", + "homeassistant.components.roborock.create_device_manager", side_effect=RoborockInvalidUserAgreement(), ): await hass.config_entries.async_setup(mock_roborock_entry.entry_id) @@ -294,12 +159,11 @@ async def test_invalid_user_agreement( async def test_no_user_agreement( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, ) -> None: """Test that we fail setting up if the user has no agreement.""" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", + "homeassistant.components.roborock.create_device_manager", side_effect=RoborockNoUserAgreement(), ): await hass.config_entries.async_setup(mock_roborock_entry.entry_id) @@ -310,79 +174,78 @@ async def test_no_user_agreement( @pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_stale_device( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, device_registry: DeviceRegistry, + fake_devices: list[FakeDevice], ) -> None: """Test that we remove a device if it no longer is given by home_data.""" - with patch( - "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - side_effect=[NETWORK_INFO, NETWORK_INFO_2], - ): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) assert mock_roborock_entry.state is ConfigEntryState.LOADED existing_devices = device_registry.devices.get_devices_for_config_entry_id( mock_roborock_entry.entry_id ) - assert len(existing_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo - hd = deepcopy(HOME_DATA) - hd.devices = [hd.devices[0]] - - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", - return_value=hd, - ), - patch( - "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - side_effect=[NETWORK_INFO, NETWORK_INFO_2], - ), - ): - await hass.config_entries.async_reload(mock_roborock_entry.entry_id) - await hass.async_block_till_done() + assert {device.name for device in existing_devices} == { + "Roborock S7 MaxV", + "Roborock S7 MaxV Dock", + "Roborock S7 2", + "Roborock S7 2 Dock", + "Dyad Pro", + "Zeo One", + } + fake_devices.pop(0) # Remove one robot + + await hass.config_entries.async_reload(mock_roborock_entry.entry_id) + await hass.async_block_till_done() new_devices = device_registry.devices.get_devices_for_config_entry_id( mock_roborock_entry.entry_id ) - assert ( - len(new_devices) == 4 - ) # 2 for the one remaining robot. 1 for both the A01s which are shared and - # therefore not deleted. + assert {device.name for device in new_devices} == { + "Roborock S7 2", + "Roborock S7 2 Dock", + "Dyad Pro", + "Zeo One", + } @pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_no_stale_device( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, device_registry: DeviceRegistry, + fake_devices: list[FakeDevice], ) -> None: """Test that we don't remove a device if fails to setup.""" - with patch( - "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - side_effect=[NETWORK_INFO, NETWORK_INFO_2], - ): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) assert mock_roborock_entry.state is ConfigEntryState.LOADED existing_devices = device_registry.devices.get_devices_for_config_entry_id( mock_roborock_entry.entry_id ) - assert len(existing_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo - - with patch( - "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - side_effect=[NETWORK_INFO, RoborockException], - ): - await hass.config_entries.async_reload(mock_roborock_entry.entry_id) - await hass.async_block_till_done() + assert {device.name for device in existing_devices} == { + "Roborock S7 MaxV", + "Roborock S7 MaxV Dock", + "Roborock S7 2", + "Roborock S7 2 Dock", + "Dyad Pro", + "Zeo One", + } + + await hass.config_entries.async_reload(mock_roborock_entry.entry_id) + await hass.async_block_till_done() new_devices = device_registry.devices.get_devices_for_config_entry_id( mock_roborock_entry.entry_id ) - assert len(new_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo + assert {device.name for device in new_devices} == { + "Roborock S7 MaxV", + "Roborock S7 MaxV Dock", + "Roborock S7 2", + "Roborock S7 2 Dock", + "Dyad Pro", + "Zeo One", + } async def test_migrate_config_entry_unique_id( hass: HomeAssistant, - bypass_api_fixture, config_entry_data: dict[str, Any], ) -> None: """Test migrating the config entry unique id.""" diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py index c4809a71b6e79b..6413395cd02758 100644 --- a/tests/components/roborock/test_number.py +++ b/tests/components/roborock/test_number.py @@ -1,7 +1,5 @@ """Test Roborock Number platform.""" -from unittest.mock import Mock - import pytest from roborock.exceptions import RoborockTimeout @@ -11,6 +9,7 @@ from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +from tests.components.roborock.test_vacuum import FakeDevice @pytest.fixture @@ -19,57 +18,49 @@ def platforms() -> list[Platform]: return [Platform.NUMBER] -@pytest.mark.parametrize( - ("entity_id", "value"), - [ - ("number.roborock_s7_maxv_volume", 3.0), - ], -) -async def test_update_success( +async def test_update_sound_volume( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, - entity_id: str, - value: float, - mock_send_message: Mock, + fake_vacuum: FakeDevice, ) -> 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 + assert hass.states.get("number.roborock_s7_maxv_volume") is not None + await hass.services.async_call( "number", SERVICE_SET_VALUE, - service_data={ATTR_VALUE: value}, + service_data={ATTR_VALUE: 3.0}, blocking=True, - target={"entity_id": entity_id}, + target={"entity_id": "number.roborock_s7_maxv_volume"}, ) - assert mock_send_message.assert_called_once + + assert fake_vacuum.v1_properties is not None + assert fake_vacuum.v1_properties.sound_volume.set_volume.call_count == 1 + assert fake_vacuum.v1_properties.sound_volume.set_volume.call_args[0] == (3.0,) -@pytest.mark.parametrize( - ("entity_id", "value"), - [ - ("number.roborock_s7_maxv_volume", 3.0), - ], -) -@pytest.mark.parametrize("send_message_side_effect", [RoborockTimeout]) -async def test_update_failed( +async def test_volume_update_failed( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, - entity_id: str, - value: float, - mock_send_message: Mock, + fake_vacuum: FakeDevice, ) -> None: """Test allowed changing values for number entities.""" + assert fake_vacuum.v1_properties is not None + fake_vacuum.v1_properties.sound_volume.set_volume.side_effect = RoborockTimeout + # 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 + assert hass.states.get("number.roborock_s7_maxv_volume") is not None + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): await hass.services.async_call( "number", SERVICE_SET_VALUE, - service_data={ATTR_VALUE: value}, + service_data={ATTR_VALUE: 3.0}, blocking=True, - target={"entity_id": entity_id}, + target={"entity_id": "number.roborock_s7_maxv_volume"}, ) - assert mock_send_message.assert_called_once + + assert fake_vacuum.v1_properties.sound_volume.set_volume.call_count == 1 + assert fake_vacuum.v1_properties.sound_volume.set_volume.call_args[0] == (3.0,) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 04b3be995751fa..f08521daf7e5e3 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -1,9 +1,10 @@ """Test Roborock Select platform.""" -import copy -from unittest.mock import Mock, patch +from typing import Any +from unittest.mock import AsyncMock, call import pytest +from roborock import RoborockCommand from roborock.exceptions import RoborockException from homeassistant.components.roborock import DOMAIN @@ -12,7 +13,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from .mock_data import MULTI_MAP_LIST, PROP +from .conftest import FakeDevice from tests.common import MockConfigEntry @@ -23,21 +24,61 @@ def platforms() -> list[Platform]: return [Platform.SELECT] +@pytest.mark.parametrize( + ("entity_id", "value", "expected_command", "expected_params"), + [ + ( + "select.roborock_s7_maxv_mop_mode", + "deep", + RoborockCommand.SET_MOP_MODE, + [301], + ), + ( + "select.roborock_s7_maxv_mop_intensity", + "mild", + RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, + [201], + ), + ], +) +async def test_update_success( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, + value: str, + expected_command: RoborockCommand, + expected_params: Any, + fake_vacuum: FakeDevice, +) -> None: + """Test allowed changing values for select 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 + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert fake_vacuum.v1_properties + assert fake_vacuum.v1_properties.command.send.call_count == 1 + assert fake_vacuum.v1_properties.command.send.call_args == ( + call(expected_command, params=expected_params) + ) + + @pytest.mark.parametrize( ("entity_id", "value"), [ - ("select.roborock_s7_maxv_mop_mode", "deep"), - ("select.roborock_s7_maxv_mop_intensity", "mild"), ("select.roborock_s7_maxv_selected_map", "Downstairs"), ], ) -async def test_update_success( +async def test_update_success_selected_map( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, value: str, - mock_send_message: Mock, + fake_vacuum: FakeDevice, ) -> None: """Test allowed changing values for select entities.""" # Ensure that the entity exist, as these test can pass even if there is no entity. @@ -49,17 +90,20 @@ async def test_update_success( blocking=True, target={"entity_id": entity_id}, ) - assert mock_send_message.assert_called_once + assert fake_vacuum.v1_properties + assert fake_vacuum.v1_properties.maps.set_current_map.call_count == 1 + assert fake_vacuum.v1_properties.maps.set_current_map.call_args == [(1,)] -@pytest.mark.parametrize("send_message_side_effect", [RoborockException]) async def test_update_failure( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, - mock_send_message: Mock, + fake_vacuum: FakeDevice, ) -> None: """Test that changing a value will raise a homeassistanterror when it fails.""" + assert fake_vacuum.v1_properties + fake_vacuum.v1_properties.command.send.side_effect = RoborockException + with pytest.raises(HomeAssistantError, match="Error while calling SET_MOP_MOD"): await hass.services.async_call( "select", @@ -72,25 +116,20 @@ async def test_update_failure( async def test_none_map_select( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, + fake_vacuum: FakeDevice, ) -> None: """Test that the select entity correctly handles not having a current map.""" - prop = copy.deepcopy(PROP) # Set map status to None so that current map is never set - prop.status.map_status = None - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=prop, - ): - await async_setup_component(hass, DOMAIN, {}) + fake_vacuum.v1_properties.status.map_status = None + await async_setup_component(hass, DOMAIN, {}) select_entity = hass.states.get("select.roborock_s7_maxv_selected_map") + assert select_entity assert select_entity.state == STATE_UNKNOWN async def test_selected_map_name( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, ) -> None: """Test that the selected map is set to the correct map name.""" @@ -103,15 +142,15 @@ async def test_selected_map_without_name( hass: HomeAssistant, bypass_api_fixture_v1_only, mock_roborock_entry: MockConfigEntry, + fake_vacuum: FakeDevice, ) -> None: """Test that maps without a name are given a placeholder name.""" - map_list = copy.deepcopy(MULTI_MAP_LIST) - map_list.map_info[0].name = "" - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", - return_value=map_list, - ): - await async_setup_component(hass, DOMAIN, {}) + assert fake_vacuum.v1_properties + assert fake_vacuum.v1_properties.home.home_cache + fake_vacuum.v1_properties.home.home_cache[0].name = "" + fake_vacuum.v1_properties.home.refresh = AsyncMock() + + await async_setup_component(hass, DOMAIN, {}) select_entity = hass.states.get("select.roborock_s7_maxv_selected_map") assert select_entity diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 847623e2ba7345..322f6a3fbe9b2b 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -1,18 +1,18 @@ """Test Roborock Sensors.""" -from unittest.mock import patch - import pytest -from roborock import DeviceData, HomeDataDevice -from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol -from roborock.version_1_apis import RoborockMqttClientV1 -from syrupy.assertion import SnapshotAssertion +from roborock.const import ( + CLEANING_BRUSH_REPLACE_TIME, + FILTER_REPLACE_TIME, + MAIN_BRUSH_REPLACE_TIME, + SENSOR_DIRTY_REPLACE_TIME, + SIDE_BRUSH_REPLACE_TIME, + STRAINER_REPLACE_TIME, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .mock_data import CONSUMABLE, STATUS, USER_DATA - from tests.common import MockConfigEntry @@ -29,44 +29,3 @@ async def test_sensors( ) -> None: """Test sensors and check test values are correctly set.""" assert snapshot == hass.states.async_all("sensor") - - -async def test_listener_update( - hass: HomeAssistant, setup_entry: MockConfigEntry -) -> None: - """Test that when we receive a mqtt topic, we successfully update the entity.""" - assert hass.states.get("sensor.roborock_s7_maxv_status").state == "charging" - # Listeners are global based on uuid - so this is okay - client = RoborockMqttClientV1( - USER_DATA, DeviceData(device=HomeDataDevice("abc123", "", "", "", ""), model="") - ) - # Test Status - with patch("roborock.version_1_apis.AttributeCache.value", STATUS.as_dict()): - # Symbolizes a mqtt message coming in - client.on_message_received( - [ - RoborockMessage( - protocol=RoborockMessageProtocol.GENERAL_REQUEST, - payload=b'{"t": 1699464794, "dps": {"121": 5}}', - ) - ] - ) - # Test consumable - assert ( - hass.states.get("sensor.roborock_s7_maxv_filter_time_left").state - == "129.338333333333" - ) - with patch("roborock.version_1_apis.AttributeCache.value", CONSUMABLE.as_dict()): - client.on_message_received( - [ - RoborockMessage( - protocol=RoborockMessageProtocol.GENERAL_REQUEST, - payload=b'{"t": 1699464794, "dps": {"127": 743}}', - ) - ] - ) - await hass.async_block_till_done() - assert ( - hass.states.get("sensor.roborock_s7_maxv_filter_time_left").state - == "149.793611111111" - ) diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 120c4fc48604c4..bb0158a6353935 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -1,6 +1,7 @@ """Test Roborock Switch platform.""" -from unittest.mock import Mock +from collections.abc import Callable +from typing import Any import pytest import roborock @@ -10,6 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from .conftest import FakeDevice + from tests.common import MockConfigEntry @@ -20,21 +23,26 @@ def platforms() -> list[Platform]: @pytest.mark.parametrize( - ("entity_id"), + ("entity_id", "trait_fn"), [ - ("switch.roborock_s7_maxv_dock_child_lock"), - ("switch.roborock_s7_maxv_dock_status_indicator_light"), - ("switch.roborock_s7_maxv_do_not_disturb"), + ("switch.roborock_s7_maxv_dock_child_lock", lambda trait: trait.child_lock), + ( + "switch.roborock_s7_maxv_dock_status_indicator_light", + lambda trait: trait.flow_led_status, + ), + ("switch.roborock_s7_maxv_do_not_disturb", lambda trait: trait.dnd), ], ) async def test_update_success( hass: HomeAssistant, - mock_send_message: Mock, - bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, + fake_vacuum: FakeDevice, + trait_fn: Callable[[Any], Any], ) -> None: """Test turning switch entities on and off.""" + trait = trait_fn(fake_vacuum.v1_properties) + # 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 await hass.services.async_call( @@ -44,8 +52,10 @@ async def test_update_success( blocking=True, target={"entity_id": entity_id}, ) - assert mock_send_message.assert_called_once - mock_send_message.reset_mock() + assert len(trait.enable.mock_calls) == 1 + assert len(trait.disable.mock_calls) == 0 + trait.enable.reset_mock() + await hass.services.async_call( "switch", SERVICE_TURN_OFF, @@ -53,14 +63,23 @@ async def test_update_success( blocking=True, target={"entity_id": entity_id}, ) - assert mock_send_message.assert_called_once + assert len(trait.enable.mock_calls) == 0 + assert len(trait.disable.mock_calls) == 1 @pytest.mark.parametrize( - ("entity_id", "service"), + ("entity_id", "service", "expected_call_fn"), [ - ("switch.roborock_s7_maxv_dock_status_indicator_light", SERVICE_TURN_ON), - ("switch.roborock_s7_maxv_dock_status_indicator_light", SERVICE_TURN_OFF), + ( + "switch.roborock_s7_maxv_dock_status_indicator_light", + SERVICE_TURN_ON, + lambda trait: trait.flow_led_status.enable, + ), + ( + "switch.roborock_s7_maxv_dock_status_indicator_light", + SERVICE_TURN_OFF, + lambda trait: trait.flow_led_status.disable, + ), ], ) @pytest.mark.parametrize( @@ -68,13 +87,17 @@ async def test_update_success( ) async def test_update_failed( hass: HomeAssistant, - mock_send_message: Mock, - bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, service: str, + fake_vacuum: FakeDevice, + expected_call_fn: Callable[[Any], Any], ) -> None: """Test a failure while updating a switch.""" + + expected_call = expected_call_fn(fake_vacuum.v1_properties) + expected_call.side_effect = roborock.exceptions.RoborockTimeout + # 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 ( @@ -87,4 +110,5 @@ async def test_update_failed( blocking=True, target={"entity_id": entity_id}, ) - assert mock_send_message.assert_called_once + + assert len(expected_call.mock_calls) == 1 diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index 9c0a53893edc53..ceae23705c08b3 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -1,16 +1,18 @@ """Test Roborock Time platform.""" from datetime import time -from unittest.mock import Mock import pytest import roborock +from roborock.data import DnDTimer from homeassistant.components.time import SERVICE_SET_VALUE from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from .conftest import FakeDevice + from tests.common import MockConfigEntry @@ -21,18 +23,26 @@ def platforms() -> list[Platform]: @pytest.mark.parametrize( - ("entity_id"), + ("entity_id", "expected_args"), [ - ("time.roborock_s7_maxv_do_not_disturb_begin"), - ("time.roborock_s7_maxv_do_not_disturb_end"), + ( + "time.roborock_s7_maxv_do_not_disturb_begin", + DnDTimer(start_hour=1, start_minute=1, end_hour=7, end_minute=0, enabled=1), + ), + ( + "time.roborock_s7_maxv_do_not_disturb_end", + DnDTimer( + start_hour=22, start_minute=0, end_hour=1, end_minute=1, enabled=1 + ), + ), ], ) async def test_update_success( hass: HomeAssistant, - mock_send_message: Mock, - bypass_api_fixture, setup_entry: MockConfigEntry, + fake_vacuum: FakeDevice, entity_id: str, + expected_args: DnDTimer, ) -> None: """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. @@ -44,7 +54,9 @@ async def test_update_success( blocking=True, target={"entity_id": entity_id}, ) - assert mock_send_message.assert_called_once + + assert fake_vacuum.v1_properties.dnd.set_dnd_timer.call_count == 1 + assert fake_vacuum.v1_properties.dnd.set_dnd_timer.call_args == ((expected_args,),) @pytest.mark.parametrize( @@ -53,19 +65,18 @@ async def test_update_success( ("time.roborock_s7_maxv_do_not_disturb_begin"), ], ) -@pytest.mark.parametrize( - "send_message_side_effect", [roborock.exceptions.RoborockTimeout] -) async def test_update_failure( hass: HomeAssistant, - mock_send_message: Mock, - bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, + fake_vacuum: FakeDevice, ) -> 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 + fake_vacuum.v1_properties.dnd.set_dnd_timer.side_effect = ( + roborock.exceptions.RoborockTimeout + ) with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): await hass.services.async_call( "time", @@ -74,4 +85,4 @@ async def test_update_failure( blocking=True, target={"entity_id": entity_id}, ) - assert mock_send_message.assert_called_once + assert fake_vacuum.v1_properties.dnd.set_dnd_timer.call_count == 1 diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index aa7da07d499e51..56895fab7c08a4 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -1,8 +1,7 @@ """Tests for Roborock vacuums.""" -import copy from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, call import pytest from roborock import RoborockException @@ -32,7 +31,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .mock_data import MAP_DATA, PROP +from .conftest import FakeDevice from tests.common import MockConfigEntry @@ -46,14 +45,13 @@ def platforms() -> list[Platform]: # Note: Currently the Image platform is required to make these tests pass since # some initialization of the coordinator happens as a side effect of loading # image platform. Fix that and remove IMAGE here. - return [Platform.VACUUM, Platform.IMAGE] + return [Platform.VACUUM] async def test_registry_entries( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, - bypass_api_fixture, setup_entry: MockConfigEntry, ) -> None: """Tests devices are registered in the entity registry.""" @@ -90,12 +88,12 @@ async def test_registry_entries( ) async def test_commands( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, service: str, command: str, service_params: dict[str, Any], called_params: list | None, + vacuum_command: Mock, ) -> None: """Test sending commands to the vacuum.""" @@ -103,42 +101,14 @@ async def test_commands( assert vacuum data = {ATTR_ENTITY_ID: ENTITY_ID, **(service_params or {})} - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_command" - ) as mock_send_command: - await hass.services.async_call( - Platform.VACUUM, - service, - data, - blocking=True, - ) - assert mock_send_command.call_count == 1 - assert mock_send_command.call_args[0][0] == command - assert mock_send_command.call_args[0][1] == called_params - - -async def test_cloud_command( - hass: HomeAssistant, - bypass_api_fixture, - setup_entry: MockConfigEntry, -) -> None: - """Test sending commands to the vacuum.""" - - vacuum = hass.states.get(ENTITY_ID) - assert vacuum - - data = {ATTR_ENTITY_ID: ENTITY_ID, "command": "get_map_v1"} - with patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.send_command" - ) as mock_send_command: - await hass.services.async_call( - Platform.VACUUM, - SERVICE_SEND_COMMAND, - data, - blocking=True, - ) - assert mock_send_command.call_count == 1 - assert mock_send_command.call_args[0][0] == RoborockCommand.GET_MAP_V1 + await hass.services.async_call( + Platform.VACUUM, + service, + data, + blocking=True, + ) + assert vacuum_command.send.call_count == 1 + assert vacuum_command.send.call_args == call(command, params=called_params) @pytest.mark.parametrize( @@ -154,50 +124,40 @@ async def test_cloud_command( ) async def test_resume_cleaning( hass: HomeAssistant, - bypass_api_fixture, mock_roborock_entry: MockConfigEntry, in_cleaning_int: int, in_returning_int: int, expected_command: RoborockCommand, + fake_vacuum: FakeDevice, + vacuum_command: Mock, ) -> None: """Test resuming clean on start button when a clean is paused.""" - prop = copy.deepcopy(PROP) - prop.status.in_cleaning = in_cleaning_int - prop.status.in_returning = in_returning_int - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", - return_value=prop, - ): - await async_setup_component(hass, DOMAIN, {}) + fake_vacuum.v1_properties.status.in_cleaning = in_cleaning_int + fake_vacuum.v1_properties.status.in_returning = in_returning_int + await async_setup_component(hass, DOMAIN, {}) vacuum = hass.states.get(ENTITY_ID) assert vacuum data = {ATTR_ENTITY_ID: ENTITY_ID} - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_command" - ) as mock_send_command: - await hass.services.async_call( - Platform.VACUUM, - SERVICE_START, - data, - blocking=True, - ) - assert mock_send_command.call_count == 1 - assert mock_send_command.call_args[0][0] == expected_command + await hass.services.async_call( + Platform.VACUUM, + SERVICE_START, + data, + blocking=True, + ) + assert vacuum_command.send.call_count == 1 + assert vacuum_command.send.call_args[0][0] == expected_command async def test_failed_user_command( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, + vacuum_command: Mock, ) -> None: """Test that when a user sends an invalid command, we raise HomeAssistantError.""" data = {ATTR_ENTITY_ID: ENTITY_ID, "command": "fake_command"} + vacuum_command.send.side_effect = RoborockException() with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_command", - side_effect=RoborockException(), - ), pytest.raises(HomeAssistantError, match="Error while calling fake_command"), ): await hass.services.async_call( @@ -210,7 +170,6 @@ async def test_failed_user_command( async def test_get_maps( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: @@ -227,74 +186,59 @@ async def test_get_maps( async def test_goto( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, + vacuum_command: Mock, ) -> None: """Test sending the vacuum to specific coordinates.""" vacuum = hass.states.get(ENTITY_ID) assert vacuum data = {ATTR_ENTITY_ID: ENTITY_ID, "x": 25500, "y": 25500} - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_command" - ) as mock_send_command: - await hass.services.async_call( - DOMAIN, - SET_VACUUM_GOTO_POSITION_SERVICE_NAME, - data, - blocking=True, - ) - assert mock_send_command.call_count == 1 - assert mock_send_command.call_args[0][0] == RoborockCommand.APP_GOTO_TARGET - assert mock_send_command.call_args[0][1] == [25500, 25500] + await hass.services.async_call( + DOMAIN, + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, + data, + blocking=True, + ) + assert vacuum_command.send.call_count == 1 + assert vacuum_command.send.call_args == ( + call(RoborockCommand.APP_GOTO_TARGET, params=[25500, 25500]) + ) async def test_get_current_position( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, snapshot: SnapshotAssertion, + fake_vacuum: FakeDevice, ) -> None: """Test that the service for getting the current position outputs the correct coordinates.""" - map_data = copy.deepcopy(MAP_DATA) - map_data.vacuum_position = Point(x=123, y=456) - map_data.image = None - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", - return_value=b"", - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - return_value=map_data, - ), - ): - response = await hass.services.async_call( - DOMAIN, - GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, - {ATTR_ENTITY_ID: ENTITY_ID}, - blocking=True, - return_response=True, - ) - assert response == { - "vacuum.roborock_s7_maxv": { - "x": 123, - "y": 456, - }, - } + fake_vacuum.v1_properties.map_content.map_data.vacuum_position = Point(x=123, y=456) + + response = await hass.services.async_call( + DOMAIN, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + return_response=True, + ) + assert response == { + "vacuum.roborock_s7_maxv": { + "x": 123, + "y": 456, + }, + } async def test_get_current_position_no_map_data( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, + fake_vacuum: FakeDevice, ) -> None: """Test that the service for getting the current position handles no map data error.""" + fake_vacuum.v1_properties.map_content.map_data = None + with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", - return_value=None, - ), pytest.raises( HomeAssistantError, match="Something went wrong creating the map" ), @@ -310,21 +254,13 @@ async def test_get_current_position_no_map_data( async def test_get_current_position_no_robot_position( hass: HomeAssistant, - bypass_api_fixture, setup_entry: MockConfigEntry, + fake_vacuum: FakeDevice, ) -> None: """Test that the service for getting the current position handles no robot position error.""" - map_data = copy.deepcopy(MAP_DATA) - map_data.vacuum_position = None + fake_vacuum.v1_properties.map_content.map_data.vacuum_position = None + with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", - return_value=b"", - ), - patch( - "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - return_value=map_data, - ), pytest.raises(HomeAssistantError, match="Robot position not found"), ): await hass.services.async_call( From 78f4199989fbc4310233fc6acf74e1a6c3b3d353 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 27 Oct 2025 02:21:25 +0000 Subject: [PATCH 02/23] Update tests after forwarding to head --- homeassistant/components/roborock/__init__.py | 6 ++-- .../roborock/snapshots/test_sensor.ambr | 30 +++++++++---------- tests/components/roborock/test_sensor.py | 9 +----- 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index ff24dc3f4cf1c4..b3dbd187e91fd3 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -13,8 +13,8 @@ RoborockInvalidCredentials, RoborockInvalidUserAgreement, RoborockNoUserAgreement, -)<<<<<<< HEAD -from roborock.data import DeviceData, HomeDataDevice, HomeDataProduct, UserData +) +from roborock.data import UserData from roborock.devices.cache import InMemoryCache from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import ( @@ -22,7 +22,7 @@ create_device_manager, create_home_data_from_api_client, ) ->rom roborock.web_api import RoborockApiClient +from roborock.web_api import RoborockApiClient from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant diff --git a/tests/components/roborock/snapshots/test_sensor.ambr b/tests/components/roborock/snapshots/test_sensor.ambr index 7d853caaa7310f..61f7a1066d77d7 100644 --- a/tests/components/roborock/snapshots/test_sensor.ambr +++ b/tests/components/roborock/snapshots/test_sensor.ambr @@ -4,7 +4,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Main brush time left', + 'friendly_name': 'Roborock S7 MaxV Main brush time left', 'unit_of_measurement': , }), 'context': , @@ -17,7 +17,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Side brush time left', + 'friendly_name': 'Roborock S7 MaxV Side brush time left', 'unit_of_measurement': , }), 'context': , @@ -30,7 +30,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Filter time left', + 'friendly_name': 'Roborock S7 MaxV Filter time left', 'unit_of_measurement': , }), 'context': , @@ -69,7 +69,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Sensor time left', + 'friendly_name': 'Roborock S7 MaxV Sensor time left', 'unit_of_measurement': , }), 'context': , @@ -82,7 +82,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Cleaning time', + 'friendly_name': 'Roborock S7 MaxV Cleaning time', 'unit_of_measurement': , }), 'context': , @@ -95,7 +95,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'Roborock S7 2 Total cleaning time', + 'friendly_name': 'Roborock S7 MaxV Total cleaning time', 'unit_of_measurement': , }), 'context': , @@ -107,7 +107,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Roborock S7 2 Total cleaning count', + 'friendly_name': 'Roborock S7 MaxV Total cleaning count', 'state_class': , }), 'context': , @@ -120,7 +120,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Roborock S7 2 Status', + 'friendly_name': 'Roborock S7 MaxV Status', 'options': list([ 'unknown', 'starting', @@ -176,7 +176,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Roborock S7 2 Cleaning area', + 'friendly_name': 'Roborock S7 MaxV Cleaning area', 'unit_of_measurement': , }), 'context': , @@ -188,7 +188,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Roborock S7 2 Total cleaning area', + 'friendly_name': 'Roborock S7 MaxV Total cleaning area', 'unit_of_measurement': , }), 'context': , @@ -201,7 +201,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Roborock S7 2 Vacuum error', + 'friendly_name': 'Roborock S7 MaxV Vacuum error', 'options': list([ 'none', 'lidar_blocked', @@ -268,7 +268,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Roborock S7 2 Battery', + 'friendly_name': 'Roborock S7 MaxV Battery', 'unit_of_measurement': '%', }), 'context': , @@ -281,7 +281,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'Roborock S7 2 Last clean begin', + 'friendly_name': 'Roborock S7 MaxV Last clean begin', }), 'context': , 'entity_id': 'sensor.roborock_s7_maxv_last_clean_begin', @@ -293,7 +293,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'Roborock S7 2 Last clean end', + 'friendly_name': 'Roborock S7 MaxV Last clean end', }), 'context': , 'entity_id': 'sensor.roborock_s7_maxv_last_clean_end', @@ -650,7 +650,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Roborock S7 2 Current room', + 'friendly_name': 'Roborock S7 MaxV Current room', 'options': list([ 'Example room 1', 'Example room 2', diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 322f6a3fbe9b2b..81fd1eb90e5725 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -1,14 +1,7 @@ """Test Roborock Sensors.""" import pytest -from roborock.const import ( - CLEANING_BRUSH_REPLACE_TIME, - FILTER_REPLACE_TIME, - MAIN_BRUSH_REPLACE_TIME, - SENSOR_DIRTY_REPLACE_TIME, - SIDE_BRUSH_REPLACE_TIME, - STRAINER_REPLACE_TIME, -) +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant From 8094714b81693f52ed2841576a96eafd7ae06bfc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 27 Oct 2025 02:54:37 +0000 Subject: [PATCH 03/23] Update to use newer APIs --- homeassistant/components/roborock/__init__.py | 27 ++++------ homeassistant/components/roborock/button.py | 29 +++++++---- .../components/roborock/coordinator.py | 52 +------------------ homeassistant/components/roborock/image.py | 4 +- homeassistant/components/roborock/vacuum.py | 2 +- tests/components/roborock/conftest.py | 15 +++--- tests/components/roborock/test_button.py | 48 ++++++++--------- tests/components/roborock/test_select.py | 4 +- 8 files changed, 62 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index b3dbd187e91fd3..fcaedd50a05922 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -17,12 +17,7 @@ from roborock.data import UserData from roborock.devices.cache import InMemoryCache from roborock.devices.device import RoborockDevice -from roborock.devices.device_manager import ( - HomeDataApi, - create_device_manager, - create_home_data_from_api_client, -) -from roborock.web_api import RoborockApiClient +from roborock.devices.device_manager import UserParams, create_device_manager from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -38,7 +33,6 @@ RoborockDataUpdateCoordinatorA01, RoborockDyadUpdateCoordinator, RoborockZeoUpdateCoordinator, - UserApiClient, ) from .roborock_storage import async_remove_map_storage @@ -51,19 +45,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> """Set up roborock from a config entry.""" user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) - api_client = RoborockApiClient( - entry.data[CONF_USERNAME], - entry.data[CONF_BASE_URL], - session=async_get_clientsession(hass), + user_params = UserParams( + username=entry.data[CONF_USERNAME], + user_data=user_data, + base_url=entry.data[CONF_BASE_URL], ) - home_data_api: HomeDataApi = create_home_data_from_api_client(api_client, user_data) try: device_manager = await create_device_manager( - user_data, - home_data_api, + user_params, # This can be improved with a local cache of network information and home # information to allow local-only startup in the future. - InMemoryCache(), + cache=InMemoryCache(), + session=async_get_clientsession(hass), ) devices = await device_manager.get_devices() except RoborockInvalidCredentials as err: @@ -95,7 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> entry.async_on_unload(device.close) coordinators = await asyncio.gather( - *build_setup_functions(hass, entry, devices, user_data, api_client), + *build_setup_functions(hass, entry, devices, user_data), return_exceptions=True, ) v1_coords = [ @@ -115,7 +108,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_key="no_coordinators", ) valid_coordinators = RoborockCoordinators( - api_client=UserApiClient(api_client, user_data), v1=v1_coords, a01=a01_coords, ) @@ -207,7 +199,6 @@ def build_setup_functions( entry: RoborockConfigEntry, devices: list[RoborockDevice], user_data: UserData, - api_client: RoborockApiClient, ) -> list[ Coroutine[ Any, diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index f36cb4b699edee..16080a26be87ab 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -9,6 +9,7 @@ from typing import Any from roborock.devices.traits.v1.consumeable import ConsumableAttribute +from roborock.devices.traits.v1.routines import RoutinesTrait from roborock.exceptions import RoborockException from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -18,11 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import ( - RoborockConfigEntry, - RoborockDataUpdateCoordinator, - UserApiClient, -) +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntity, RoborockEntityV1 _LOGGER = logging.getLogger(__name__) @@ -76,11 +73,11 @@ async def async_setup_entry( ) -> None: """Set up Roborock button platform.""" _LOGGER.debug("Setting up Roborock button platform") - api_client = config_entry.runtime_data.api_client routines_lists = await asyncio.gather( *[ - api_client.get_routines(coordinator.duid) + routines_trait.get_routines() for coordinator in config_entry.runtime_data.v1 + if (routines_trait := coordinator.properties_api.routines) is not None ], ) async_add_entities( @@ -101,11 +98,12 @@ async def async_setup_entry( key=str(routine.id), name=routine.name, ), - api_client=api_client, + routines_trait=routines_trait, ) for coordinator, routines in zip( config_entry.runtime_data.v1, routines_lists, strict=True ) + if (routines_trait := coordinator.properties_api.routines) is not None for routine in routines ), ) @@ -155,7 +153,7 @@ def __init__( self, coordinator: RoborockDataUpdateCoordinator, entity_description: ButtonEntityDescription, - api_client: UserApiClient, + routines_trait: RoutinesTrait, ) -> None: """Create a button entity.""" super().__init__( @@ -163,9 +161,18 @@ def __init__( coordinator.device_info, ) self._routine_id = int(entity_description.key) - self._api_client = api_client + self._routines_trait = routines_trait self.entity_description = entity_description async def async_press(self, **kwargs: Any) -> None: """Press the button.""" - await self._api_client.execute_routines(self._routine_id) + try: + await self._routines_trait.execute_routine(self._routine_id) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "execute_scene", + }, + ) from err diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 7a2c93bb01b1dc..f5554c3b58f86f 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -9,13 +9,12 @@ from typing import Any, TypeVar from propcache.api import cached_property -from roborock.data import HomeDataScene, RoborockCategory, UserData +from roborock.data import RoborockCategory from roborock.devices.device import RoborockDevice from roborock.devices.traits.a01 import DyadApi, ZeoApi from roborock.devices.traits.v1 import PropertiesApi from roborock.exceptions import RoborockDeviceBusy, RoborockException from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol -from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS @@ -23,11 +22,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util, slugify @@ -53,7 +47,6 @@ class RoborockCoordinators: """Roborock coordinators type.""" - api_client: UserApiClient v1: list[RoborockDataUpdateCoordinator] a01: list[RoborockDataUpdateCoordinatorA01] @@ -141,7 +134,7 @@ async def _async_setup(self) -> None: except RoborockDeviceBusy: _LOGGER.info("Home discovery skipped while device is busy/cleaning") - roborock_maps = list((self.properties_api.home.home_cache or {}).values()) + roborock_maps = list((self.properties_api.home.home_map_info or {}).values()) # Handle loading any stored images for the current or formerly active # maps here. A single active map for each device is refreshed regularly, # and the others maps are served from the cache. @@ -322,47 +315,6 @@ async def _refresh_traits(traits: list[Any]) -> None: ) from ex -class UserApiClient: - """Wrapper around the Roborock API client.""" - - def __init__( - self, - api_client: RoborockApiClient, - user_data: UserData, - ) -> None: - """Initialize.""" - self._api_client = api_client - self._user_data = user_data - - async def get_routines(self, duid: str) -> list[HomeDataScene]: - """Get routines.""" - try: - return await self._api_client.get_scenes(self._user_data, duid) - except RoborockException as err: - _LOGGER.error("Failed to get routines %s", err) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_failed", - translation_placeholders={ - "command": "get_scenes", - }, - ) from err - - async def execute_routines(self, routine_id: int) -> None: - """Execute routines.""" - try: - await self._api_client.execute_scene(self._user_data, routine_id) - except RoborockException as err: - _LOGGER.error("Failed to execute routines %s %s", routine_id, err) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_failed", - translation_placeholders={ - "command": "execute_scene", - }, - ) from err - - _V = TypeVar("_V", bound=RoborockDyadDataProtocol | RoborockZeoProtocol) diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index e046f47120fd44..efd3cb52f538b6 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -34,8 +34,8 @@ async def async_setup_entry( map_info.name, ) for coord in config_entry.runtime_data.v1 - if coord.properties_api.home.home_cache is not None - for map_info in coord.properties_api.home.home_cache.values() + if coord.properties_api.home.home_map_info is not None + for map_info in coord.properties_api.home.home_map_info.values() ), ) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 529c8c08f82c44..9c88af97bcb787 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -210,7 +210,7 @@ async def get_maps(self) -> ServiceResponse: for room in vacuum_map.rooms }, } - for vacuum_map in (home_trait.home_cache or {}).values() + for vacuum_map in (home_trait.home_map_info or {}).values() ] } diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 2729c02d223e27..f583bd406ac3c2 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -94,10 +94,6 @@ def bypass_api_client_fixture() -> None: "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", return_value=HOME_DATA, ), - patch( - "homeassistant.components.roborock.RoborockApiClient.get_scenes", - return_value=SCENES, - ), patch( "homeassistant.components.roborock.config_flow.RoborockApiClient.base_url", new_callable=PropertyMock, @@ -191,7 +187,7 @@ def create_v1_properties(network_info: NetworkInfo) -> Mock: v1_properties.smart_wash_params = AsyncMock() v1_properties.smart_wash_params.refresh = AsyncMock() v1_properties.home = AsyncMock() - home_cache = { + home_map_info = { map_data.map_flag: CombinedMapInfo( name=map_data.name, map_flag=map_data.map_flag, @@ -206,11 +202,14 @@ def create_v1_properties(network_info: NetworkInfo) -> Mock: ) for map_data in MULTI_MAP_LIST.map_info } - v1_properties.home.home_cache = home_cache - v1_properties.home.current_map_data = home_cache[STATUS.current_map] + v1_properties.home.home_map_info = home_map_info + v1_properties.home.current_map_data = home_map_info[STATUS.current_map] v1_properties.home.refresh = AsyncMock() v1_properties.network_info = deepcopy(network_info) v1_properties.network_info.refresh = AsyncMock() + v1_properties.routines = AsyncMock() + v1_properties.routines.get_routines = AsyncMock(return_value=SCENES) + v1_properties.routines.execute_routine = AsyncMock() # Mock diagnostics for a subset of properties v1_properties.as_dict.return_value = { "status": STATUS.as_dict(), @@ -300,7 +299,7 @@ def bypass_api_fixture_v1_only() -> None: home_data_copy = deepcopy(HOME_DATA) home_data_copy.received_devices = [] with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", + "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", return_value=home_data_copy, ): yield diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 2d08e2202e8d5d..296a8d33f1f244 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -1,6 +1,6 @@ """Test Roborock Button platform.""" -from unittest.mock import ANY, Mock, patch +from unittest.mock import Mock import pytest from roborock import RoborockException @@ -17,15 +17,9 @@ @pytest.fixture -def get_scenes_failure_fixture() -> None: +def get_scenes_failure_fixture(fake_vacuum: FakeDevice) -> None: """Fixture to raise when getting scenes.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.get_scenes", - side_effect=RoborockException(), - ), - ): - yield + fake_vacuum.v1_properties.routines.get_routines.side_effect = RoborockException @pytest.fixture @@ -118,6 +112,7 @@ async def test_get_button_routines_failure( get_scenes_failure_fixture: None, setup_entry: MockConfigEntry, entity_id: str, + fake_vacuum: FakeDevice, ) -> None: """Test that if routine retrieval fails, no entity is being created.""" # Ensure that the entity does not exist @@ -139,18 +134,19 @@ async def test_press_routine_button_success( setup_entry: MockConfigEntry, entity_id: str, routine_id: int, + fake_vacuum: FakeDevice, ) -> None: """Test pressing the button entities.""" - with patch( - "homeassistant.components.roborock.RoborockApiClient.execute_scene" - ) as mock_execute_scene: - await hass.services.async_call( - "button", - SERVICE_PRESS, - blocking=True, - target={"entity_id": entity_id}, - ) - mock_execute_scene.assert_called_once_with(ANY, routine_id) + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + + fake_vacuum.v1_properties.routines.execute_routine.assert_called_once_with( + routine_id + ) assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" @@ -168,20 +164,18 @@ async def test_press_routine_button_failure( setup_entry: MockConfigEntry, entity_id: str, routine_id: int, + fake_vacuum: FakeDevice, ) -> None: """Test failure while pressing the button entity.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.execute_scene", - side_effect=RoborockException, - ) as mock_execute_scene, - pytest.raises(HomeAssistantError, match="Error while calling execute_scene"), - ): + fake_vacuum.v1_properties.routines.execute_routine.side_effect = RoborockException + with pytest.raises(HomeAssistantError, match="Error while calling execute_scene"): await hass.services.async_call( "button", SERVICE_PRESS, blocking=True, target={"entity_id": entity_id}, ) - mock_execute_scene.assert_called_once_with(ANY, routine_id) + fake_vacuum.v1_properties.routines.execute_routine.assert_called_once_with( + routine_id + ) assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index f08521daf7e5e3..e657029b668fc2 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -146,8 +146,8 @@ async def test_selected_map_without_name( ) -> None: """Test that maps without a name are given a placeholder name.""" assert fake_vacuum.v1_properties - assert fake_vacuum.v1_properties.home.home_cache - fake_vacuum.v1_properties.home.home_cache[0].name = "" + assert fake_vacuum.v1_properties.home.home_map_info + fake_vacuum.v1_properties.home.home_map_info[0].name = "" fake_vacuum.v1_properties.home.refresh = AsyncMock() await async_setup_component(hass, DOMAIN, {}) From 04ee238ee4f420b64bf17bb09f216341d67fe0e2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Oct 2025 14:17:15 +0000 Subject: [PATCH 04/23] Use map content from the home trait --- homeassistant/components/roborock/__init__.py | 13 +- .../components/roborock/coordinator.py | 116 ++++-------------- homeassistant/components/roborock/image.py | 46 ++++--- .../components/roborock/roborock_storage.py | 114 ++++++++--------- homeassistant/components/roborock/select.py | 73 ++++++++--- tests/components/roborock/conftest.py | 8 ++ tests/components/roborock/test_image.py | 107 +++------------- tests/components/roborock/test_init.py | 61 ++------- tests/components/roborock/test_select.py | 2 +- 9 files changed, 201 insertions(+), 339 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index fcaedd50a05922..15820081d74065 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -15,7 +15,6 @@ RoborockNoUserAgreement, ) from roborock.data import UserData -from roborock.devices.cache import InMemoryCache from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import UserParams, create_device_manager @@ -34,7 +33,7 @@ RoborockDyadUpdateCoordinator, RoborockZeoUpdateCoordinator, ) -from .roborock_storage import async_remove_map_storage +from .roborock_storage import CacheStore, async_remove_map_storage SCAN_INTERVAL = timedelta(seconds=30) @@ -50,12 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> user_data=user_data, base_url=entry.data[CONF_BASE_URL], ) + cache = CacheStore(hass, entry.entry_id) try: device_manager = await create_device_manager( user_params, - # This can be improved with a local cache of network information and home - # information to allow local-only startup in the future. - cache=InMemoryCache(), + cache=cache, session=async_get_clientsession(hass), ) devices = await device_manager.get_devices() @@ -118,7 +116,8 @@ async def on_stop(_: Any) -> None: *( coordinator.async_shutdown() for coordinator in valid_coordinators.values() - ) + ), + cache.flush(), ) entry.async_on_unload( @@ -256,3 +255,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: """Handle removal of an entry.""" await async_remove_map_storage(hass, entry.entry_id) + store = CacheStore(hass, entry.entry_id) + await store.async_remove() diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index f5554c3b58f86f..001c77713e90cd 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -2,9 +2,8 @@ from __future__ import annotations -import asyncio from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import Any, TypeVar @@ -19,7 +18,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import StateType @@ -35,8 +33,7 @@ V1_LOCAL_IN_CLEANING_INTERVAL, V1_LOCAL_NOT_CLEANING_INTERVAL, ) -from .models import DeviceState, RoborockMapInfo -from .roborock_storage import RoborockMapStorage +from .models import DeviceState SCAN_INTERVAL = timedelta(seconds=30) @@ -94,17 +91,13 @@ def __init__( model_id=self._device.product.model, sw_version=self._device.device_info.fv, ) - self.current_map: int | None = None if mac := properties_api.network_info.mac: self.device_info[ATTR_CONNECTIONS] = { (dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)) } - # Maps from map flag to map name - self.maps: dict[int, RoborockMapInfo] = {} - self.map_storage = RoborockMapStorage( - hass, self.config_entry.entry_id, self.duid_slug - ) self.last_update_state: str | None = None + self._last_home_update_attempt: datetime | None = None + self.last_home_update: datetime | None = None @cached_property def dock_device_info(self) -> DeviceInfo: @@ -129,62 +122,17 @@ async def _async_setup(self) -> None: # home and cache the detail. The device can only load information for # the current map so from here forward. await self.properties_api.status.refresh() + self._last_home_update_attempt = dt_util.utcnow() try: await self.properties_api.home.discover_home() except RoborockDeviceBusy: _LOGGER.info("Home discovery skipped while device is busy/cleaning") - - roborock_maps = list((self.properties_api.home.home_map_info or {}).values()) - # Handle loading any stored images for the current or formerly active - # maps here. A single active map for each device is refreshed regularly, - # and the others maps are served from the cache. - stored_images = await asyncio.gather( - *[ - self.map_storage.async_load_map(roborock_map.map_flag) - for roborock_map in roborock_maps - ] - ) - self.maps = { - roborock_map.map_flag: RoborockMapInfo( - flag=roborock_map.map_flag, - name=roborock_map.name or f"Map {roborock_map.map_flag}", - image=image, - last_updated=dt_util.utcnow() - IMAGE_CACHE_INTERVAL, - map_data=None, - ) - for image, roborock_map in zip(stored_images, roborock_maps, strict=False) - } - - async def update_map(self) -> None: - """Update the currently selected map.""" - # The current map was set in the props update, so these can be done without - # worry of applying them to the wrong map. - if self.current_map is None or self.current_map not in self.maps: - # This exists as a safeguard/ to keep mypy happy. - return - try: - await self.properties_api.map_content.refresh() - except RoborockException as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="map_failure", - ) from ex - current_roborock_map_info = self.maps[self.current_map] - parsed_image = self.properties_api.map_content.image_content - parsed_map = self.properties_api.map_content.map_data - if parsed_image is not None and parsed_image != current_roborock_map_info.image: - await self.map_storage.async_save_map( - self.current_map, - parsed_image, - ) - current_roborock_map_info.image = parsed_image - current_roborock_map_info.last_updated = dt_util.utcnow() - current_roborock_map_info.map_data = parsed_map + else: + self.last_home_update = dt_util.utcnow() async def async_shutdown(self) -> None: """Shutdown the coordinator.""" await super().async_shutdown() - await self.map_storage.flush() async def _update_device_prop(self) -> None: """Update device properties.""" @@ -222,42 +170,27 @@ async def _async_update_data(self) -> DeviceState: translation_key="update_data_fail", ) from ex - # Set the new map id from the updated device props - self._set_current_map() - # Get the rooms for that map id. - # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL # since the last map update, you can update the map. new_status = self.properties_api.status if ( - self.current_map is not None - and (current_map := self.maps.get(self.current_map)) + new_status.in_cleaning and ( - ( - new_status.in_cleaning - and (dt_util.utcnow() - current_map.last_updated) - > IMAGE_CACHE_INTERVAL - ) - or self.last_update_state != new_status.state_name + self._last_home_update_attempt is None + or (dt_util.utcnow() - self._last_home_update_attempt) + > IMAGE_CACHE_INTERVAL ) - ): - _LOGGER.debug("Updating map for map id %s", self.current_map) + ) or self.last_update_state != new_status.state_name: + self._last_home_update_attempt = dt_util.utcnow() try: - await self.update_map() - except HomeAssistantError as err: - _LOGGER.debug("Failed to update map: %s", err) - - try: - await self.properties_api.home.discover_home() - await self.properties_api.home.refresh() - except RoborockDeviceBusy as ex: - _LOGGER.debug("Not refreshing home while device is busy: %s", ex) - except RoborockException as ex: - _LOGGER.debug("Failed to update data: %s", ex) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_data_fail", - ) from ex + await self.properties_api.home.discover_home() + await self.properties_api.home.refresh() + except RoborockDeviceBusy as ex: + _LOGGER.debug("Not refreshing home while device is busy: %s", ex) + except RoborockException as ex: + _LOGGER.debug("Failed to update map data: %s", ex) + else: + self.last_home_update = dt_util.utcnow() if self.properties_api.status.in_cleaning: if self._device.is_local_connected: @@ -276,13 +209,6 @@ async def _async_update_data(self) -> DeviceState: clean_summary=self.properties_api.clean_summary, ) - def _set_current_map(self) -> None: - if ( - self.properties_api.status is not None - and self.properties_api.status.current_map is not None - ): - self.current_map = self.properties_api.status.current_map - @cached_property def duid(self) -> str: """Get the unique id of the device as specified by Roborock.""" diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index efd3cb52f538b6..4a35c727446d1c 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -3,6 +3,9 @@ from datetime import datetime import logging +from roborock.devices.traits.v1.home import HomeTrait +from roborock.devices.traits.v1.map_content import MapContent + from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -30,12 +33,13 @@ async def async_setup_entry( config_entry, f"{coord.duid_slug}_map_{map_info.name}", coord, + coord.properties_api.home, map_info.map_flag, map_info.name, ) for coord in config_entry.runtime_data.v1 - if coord.properties_api.home.home_map_info is not None - for map_info in coord.properties_api.home.home_map_info.values() + if coord.properties_api.home is not None + for map_info in (coord.properties_api.home.home_map_info or {}).values() ), ) @@ -52,6 +56,7 @@ def __init__( config_entry: ConfigEntry, unique_id: str, coordinator: RoborockDataUpdateCoordinator, + home_trait: HomeTrait, map_flag: int, map_name: str, ) -> None: @@ -60,33 +65,40 @@ def __init__( ImageEntity.__init__(self, coordinator.hass) self.config_entry = config_entry self._attr_name = map_name + self._home_trait = home_trait self.map_flag = map_flag - self.cached_map = b"" + self.cached_map: bytes | None = None self._attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def is_selected(self) -> bool: - """Return if this map is the currently selected map.""" - return self.map_flag == self.coordinator.current_map - async def async_added_to_hass(self) -> None: """When entity is added to hass load any previously cached maps from disk.""" await super().async_added_to_hass() - self._attr_image_last_updated = self.coordinator.maps[ - self.map_flag - ].last_updated + self._attr_image_last_updated = self.coordinator.last_home_update self.async_write_ha_state() + @property + def _map_content(self) -> MapContent | None: + if self._home_trait.home_map_content and ( + map_content := self._home_trait.home_map_content.get(self.map_flag) + ): + return map_content + return None + def _handle_coordinator_update(self) -> None: - # If the coordinator has updated the map, we can update the image. - self._attr_image_last_updated = self.coordinator.maps[ - self.map_flag - ].last_updated + """Handle updated data from the coordinator. + + If the coordinator has updated the map, we can update the image. + """ + if (map_content := self._map_content) is None: + return + if self.cached_map != map_content.image_content: + self.cached_map = map_content.image_content + self._attr_image_last_updated = self.coordinator.last_home_update super()._handle_coordinator_update() async def async_image(self) -> bytes | None: """Get the cached image.""" - if (map_info := self.coordinator.maps.get(self.map_flag)) is None: + if (map_content := self._map_content) is None: raise ValueError("Map flag not found in coordinator maps") - return map_info.image + return map_content.image_content diff --git a/homeassistant/components/roborock/roborock_storage.py b/homeassistant/components/roborock/roborock_storage.py index 8a469b0a38e79f..de2bbee2d63c6b 100644 --- a/homeassistant/components/roborock/roborock_storage.py +++ b/homeassistant/components/roborock/roborock_storage.py @@ -1,95 +1,87 @@ """Roborock storage.""" +import dataclasses import logging from pathlib import Path import shutil +from typing import Any + +from roborock.devices.cache import Cache, CacheData from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store -from .const import DOMAIN, MAP_FILENAME_SUFFIX +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) STORAGE_PATH = f".storage/{DOMAIN}" MAPS_PATH = "maps" +CACHE_VERSION = 1 def _storage_path_prefix(hass: HomeAssistant, entry_id: str) -> Path: + """Storage path for the old map storage cache location.""" return Path(hass.config.path(STORAGE_PATH)) / entry_id -class RoborockMapStorage: - """Store and retrieve maps for a Roborock device. +async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None: + """Remove all map storage associated with a config entry. - An instance of RoborockMapStorage is created for each device and manages - local storage of maps for that device. + This removes all on-disk map files for the given config entry. This is the + old format that was replaced by the `CacheStore` implementation. """ - def __init__(self, hass: HomeAssistant, entry_id: str, device_id_slug: str) -> None: - """Initialize RoborockMapStorage.""" - self._hass = hass - self._path_prefix = ( - _storage_path_prefix(hass, entry_id) / MAPS_PATH / device_id_slug - ) - self._write_queue: dict[int, bytes] = {} - - async def async_load_map(self, map_flag: int) -> bytes | None: - """Load maps from disk.""" - filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" - return await self._hass.async_add_executor_job(self._load_map, filename) - - def _load_map(self, filename: Path) -> bytes | None: - """Load maps from disk.""" - if not filename.exists(): - return None + def remove(path_prefix: Path) -> None: try: - return filename.read_bytes() + if path_prefix.exists(): + shutil.rmtree(path_prefix, ignore_errors=True) except OSError as err: - _LOGGER.debug("Unable to read map file: %s %s", filename, err) - return None + _LOGGER.error("Unable to remove map files in %s: %s", path_prefix, err) - async def async_save_map(self, map_flag: int, content: bytes) -> None: - """Save the map to a pending write queue.""" - self._write_queue[map_flag] = content + path_prefix = _storage_path_prefix(hass, entry_id) + _LOGGER.debug("Removing maps from disk store: %s", path_prefix) + await hass.async_add_executor_job(remove, path_prefix) - async def flush(self) -> None: - """Flush all maps to disk.""" - _LOGGER.debug("Flushing %s maps to disk", len(self._write_queue)) - queue = self._write_queue.copy() +class CacheStore(Cache): + """Store and retrieve cache for a Roborock device. - def _flush_all() -> None: - for map_flag, content in queue.items(): - filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" - self._save_map(filename, content) + This implements the roborock Cache interface, backend by a Home Assistant + Store that can be flushed to disk. This also manages dispatching the + roborock map contents to separate on disk files via RoborockMapStorage + since maps can be large. + """ - await self._hass.async_add_executor_job(_flush_all) - self._write_queue.clear() + def __init__(self, hass: HomeAssistant, entry_id: str) -> None: + """Initialize CacheStore.""" + self._cache_store = Store[dict[str, Any]]( + hass, + version=CACHE_VERSION, + key=f"{DOMAIN}/{entry_id}", + private=True, + ) + self._cache_data: CacheData | None = None - def _save_map(self, filename: Path, content: bytes) -> None: - """Write the map to disk.""" - _LOGGER.debug("Saving map to disk: %s", filename) - try: - filename.parent.mkdir(parents=True, exist_ok=True) - except OSError as err: - _LOGGER.error("Unable to create map directory: %s %s", filename, err) - return - try: - filename.write_bytes(content) - except OSError as err: - _LOGGER.error("Unable to write map file: %s %s", filename, err) + async def get(self) -> CacheData: + """Retrieve cached metadata.""" + if self._cache_data is None: + if data := await self._cache_store.async_load(): + self._cache_data = CacheData(**data) + else: + self._cache_data = CacheData() + return self._cache_data -async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None: - """Remove all map storage associated with a config entry.""" + async def set(self, value: CacheData) -> None: + """Save cached metadata.""" + self._cache_data = value - def remove(path_prefix: Path) -> None: - try: - if path_prefix.exists(): - shutil.rmtree(path_prefix, ignore_errors=True) - except OSError as err: - _LOGGER.error("Unable to remove map files in %s: %s", path_prefix, err) + async def flush(self) -> None: + """Flush cached metadata to disk.""" + if self._cache_data is not None: + await self._cache_store.async_save(dataclasses.asdict(self._cache_data)) - path_prefix = _storage_path_prefix(hass, entry_id) - _LOGGER.debug("Removing maps from disk store: %s", path_prefix) - await hass.async_add_executor_job(remove, path_prefix) + async def async_remove(self) -> None: + """Remove cached metadata from disk.""" + await self._cache_store.async_remove() diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 1204f109f1fcda..e402e44cfc9633 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -3,21 +3,27 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass +import logging from roborock.data import RoborockDockDustCollectionModeCode from roborock.devices.traits.v1 import PropertiesApi +from roborock.devices.traits.v1.home import HomeTrait +from roborock.devices.traits.v1.maps import MapsTrait +from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import MAP_SLEEP +from .const import DOMAIN, MAP_SLEEP from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) @@ -45,7 +51,7 @@ class RoborockSelectDescription(SelectEntityDescription): key="water_box_mode", translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, - value_fn=lambda api: api.status.water_box_mode.name, + value_fn=lambda api: api.status.water_box_mode_name, entity_category=EntityCategory.CONFIG, options_lambda=lambda api: api.status.water_box_mode.keys() if api.status.water_box_mode is not None @@ -67,7 +73,7 @@ class RoborockSelectDescription(SelectEntityDescription): key="dust_collection_mode", translation_key="dust_collection_mode", api_command=RoborockCommand.SET_DUST_COLLECTION_MODE, - value_fn=lambda api: api.dust_collection_mode.mode.name, # type: ignore[attr-defined] + value_fn=lambda api: api.dust_collection_mode.mode.name, # type: ignore[union-attr] entity_category=EntityCategory.CONFIG, options_lambda=lambda api: RoborockDockDustCollectionModeCode.keys() if api.dust_collection_mode is not None @@ -96,9 +102,11 @@ async def async_setup_entry( ) async_add_entities( RoborockCurrentMapSelectEntity( - f"selected_map_{coordinator.duid_slug}", coordinator + f"selected_map_{coordinator.duid_slug}", coordinator, home_trait, map_trait ) for coordinator in config_entry.runtime_data.v1 + if (home_trait := coordinator.properties_api.home) is not None + if (map_trait := coordinator.properties_api.maps) is not None ) @@ -143,32 +151,61 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "selected_map" + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + home_trait: HomeTrait, + maps_trait: MapsTrait, + ) -> None: + """Create a select entity to choose the current map.""" + super().__init__(unique_id, coordinator) + self._home_trait = home_trait + self._maps_trait = maps_trait + + @property + def _available_map_names(self) -> dict[int, str]: + """Get the available maps by map id.""" + return { + map_id: map_.name or f"Map {map_id}" + for map_id, map_ in (self._home_trait.home_map_info or {}).items() + } + async def async_select_option(self, option: str) -> None: """Set the option.""" - maps_trait = self.coordinator.properties_api.maps - for map_id, map_ in self.coordinator.maps.items(): - if map_.name == option: - await maps_trait.set_current_map(map_id) - # Update the current map id manually so that nothing gets broken - # if another service hits the api. - self.coordinator.current_map = map_id + for map_id, map_name in self._available_map_names.items(): + if map_name == option: + try: + await self._maps_trait.set_current_map(map_id) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "load_multi_map", + }, + ) from err # We need to wait after updating the map # so that other commands will be executed correctly. await asyncio.sleep(MAP_SLEEP) - await self.coordinator.async_refresh() + try: + await self._home_trait.refresh() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) from err break @property def options(self) -> list[str]: """Gets all of the names of rooms that we are currently aware of.""" - return [roborock_map.name for roborock_map in self.coordinator.maps.values()] + return list(self._available_map_names.values()) @property def current_option(self) -> str | None: """Get the current status of the select entity from device_status.""" - if ( - (current_map := self.coordinator.current_map) is not None - and current_map in self.coordinator.maps - ): # 63 means it is searching for a map. - return self.coordinator.maps[current_map].name + _LOGGER.info("Current map data: %s", self._home_trait.current_map_data) + if current_map_info := self._home_trait.current_map_data: + return current_map_info.name or f"Map {current_map_info.map_flag}" return None diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index f583bd406ac3c2..54a02d79786af5 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -23,6 +23,7 @@ ZeoState, ) from roborock.devices.device import RoborockDevice +from roborock.devices.traits.v1.map_content import MapContent from roborock.devices.traits.v1.volume import SoundVolume from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol @@ -202,8 +203,15 @@ def create_v1_properties(network_info: NetworkInfo) -> Mock: ) for map_data in MULTI_MAP_LIST.map_info } + home_map_content = { + map_data.map_flag: MapContent( + image_content=b"\x89PNG-001", map_data=deepcopy(MAP_DATA) + ) + for map_data in MULTI_MAP_LIST.map_info + } v1_properties.home.home_map_info = home_map_info v1_properties.home.current_map_data = home_map_info[STATUS.current_map] + v1_properties.home.home_map_content = home_map_content v1_properties.home.refresh = AsyncMock() v1_properties.network_info = deepcopy(network_info) v1_properties.network_info.refresh = AsyncMock() diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 96396fd79e4596..ef6355b9adf41d 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -4,22 +4,20 @@ from datetime import timedelta from http import HTTPStatus import logging -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest from roborock import RoborockException from roborock.data import RoborockStateCode +from roborock.devices.traits.v1.map_content import MapContent -from homeassistant.components.roborock import DOMAIN from homeassistant.components.roborock.const import V1_LOCAL_NOT_CLEANING_INTERVAL -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .conftest import FakeDevice -from .mock_data import MAP_DATA, STATUS +from .mock_data import MAP_DATA from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -78,81 +76,10 @@ async def test_floorplan_image( assert resp.status == HTTPStatus.OK resp = await client.get("/api/image_proxy/image.roborock_s7_2_upstairs") assert resp.status == HTTPStatus.OK - - # This image has not been loaded yet since it has never been an - # active map. - # XXX: Load this image the first time, but not after, and revert this. resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_downstairs") - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR - - -async def test_fail_to_save_image( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_roborock_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that we gracefully handle a oserror on saving an image.""" - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - # Ensure that map is still working properly. - assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - # Test that we can get the image and it correctly serialized and unserialized. assert resp.status == HTTPStatus.OK - - with patch( - "homeassistant.components.roborock.roborock_storage.Path.write_bytes", - side_effect=OSError, - ): - await hass.config_entries.async_unload(mock_roborock_entry.entry_id) - assert "Unable to write map file" in caplog.text - - # Config entry is unloaded successfully - assert mock_roborock_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_fail_to_load_image( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - setup_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that we gracefully handle failing to load an image.""" - with ( - patch( - "homeassistant.components.roborock.roborock_storage.Path.exists", - return_value=True, - ), - patch( - "homeassistant.components.roborock.roborock_storage.Path.read_bytes", - side_effect=OSError, - ) as read_bytes, - ): - # Reload the config entry so that the map is saved in storage and entities exist. - await hass.config_entries.async_reload(setup_entry.entry_id) - await hass.async_block_till_done() - assert read_bytes.call_count == 4 - assert "Unable to read map file" in caplog.text - - -async def test_fail_get_map_on_startup( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_roborock_entry: MockConfigEntry, - fake_vacuum: FakeDevice, -) -> None: - """Test that if we fail getting map on startup, we can still create the entity.""" - assert fake_vacuum.v1_properties - fake_vacuum.v1_properties.map_content.refresh.side_effect = RoborockException - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert ( - image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") - ) is not None - assert image_entity.state + body = await resp.read() + assert body is not None async def test_fail_updating_image( @@ -161,19 +88,17 @@ async def test_fail_updating_image( hass_client: ClientSessionGenerator, fake_vacuum: FakeDevice, ) -> None: - """Test that we handle failing getting the image after it has already been setup..""" + """Test that we handle failing getting the image after it has already been setup.""" client = await hass_client() + previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state + + # Refreshing the map should fail, but we should still be able to get the existing image. assert fake_vacuum.v1_properties - fake_vacuum.v1_properties.map_content.refresh.side_effect = RoborockException - # Copy the device status so we don't override it - fake_vacuum.v1_properties.status = copy.deepcopy(STATUS) + fake_vacuum.v1_properties.home.refresh.side_effect = RoborockException fake_vacuum.v1_properties.status.in_cleaning = 1 - fake_vacuum.v1_properties.status.refresh = AsyncMock() now = dt_util.utcnow() + timedelta(seconds=91) - # Update image, but get none for parse image. - previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state with ( patch( "homeassistant.components.roborock.coordinator.dt_util.utcnow", @@ -214,13 +139,13 @@ async def test_map_status_change( now = dt_util.utcnow() + V1_LOCAL_NOT_CLEANING_INTERVAL assert fake_vacuum.v1_properties - # Copy the device prop so we don't override it - fake_vacuum.v1_properties.status = copy.deepcopy(STATUS) fake_vacuum.v1_properties.status.state = RoborockStateCode.returning_home - fake_vacuum.v1_properties.status.refresh = AsyncMock() - fake_vacuum.v1_properties.map_content.map_data = copy.deepcopy(MAP_DATA) - fake_vacuum.v1_properties.map_content.image_content = b"\x89PNG-003" - fake_vacuum.v1_properties.map_content.refresh = AsyncMock() + fake_vacuum.v1_properties.home.home_map_content = { + 0: MapContent( + image_content=b"\x89PNG-003", + map_data=copy.deepcopy(MAP_DATA), + ) + } with patch( "homeassistant.components.roborock.coordinator.dt_util.utcnow", diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 97daaddec4b420..2d392c03b70ebb 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,6 +1,5 @@ """Test for Roborock init.""" -from http import HTTPStatus import pathlib from typing import Any from unittest.mock import patch @@ -51,35 +50,6 @@ async def test_reauth_started( assert flows[0]["step_id"] == "reauth_confirm" -@pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) -async def test_remove_from_hass( - hass: HomeAssistant, - setup_entry: MockConfigEntry, - hass_client: ClientSessionGenerator, - storage_path: pathlib.Path, -) -> None: - """Test that removing from hass removes any existing images.""" - - # Ensure some image content is cached - assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert resp.status == HTTPStatus.OK - - config_entry_storage = storage_path / setup_entry.entry_id - assert not config_entry_storage.exists() - - # Flush to disk - await hass.config_entries.async_unload(setup_entry.entry_id) - assert config_entry_storage.exists() - paths = list(config_entry_storage.walk()) - assert len(paths) == 4 # Two map image and two directories - - await hass.config_entries.async_remove(setup_entry.entry_id) - # After removal, directories should be empty. - assert not config_entry_storage.exists() - - @pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) async def test_oserror_remove_image( hass: HomeAssistant, @@ -88,28 +58,19 @@ async def test_oserror_remove_image( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, ) -> None: - """Test that we gracefully handle failing to remove an image.""" - - # Ensure some image content is cached - assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert resp.status == HTTPStatus.OK - - # Image content is saved when unloading - config_entry_storage = storage_path / setup_entry.entry_id - assert not config_entry_storage.exists() - await hass.config_entries.async_unload(setup_entry.entry_id) - - assert config_entry_storage.exists() - paths = list(config_entry_storage.walk()) - assert len(paths) == 4 # Two map image and two directories - - with patch( - "homeassistant.components.roborock.roborock_storage.shutil.rmtree", - side_effect=OSError, + """Test that we gracefully handle failing to remove old map storage.""" + + with ( + patch( + "homeassistant.components.roborock.roborock_storage.Path.exists", + ), + patch( + "homeassistant.components.roborock.roborock_storage.shutil.rmtree", + side_effect=OSError, + ) as mock_rmtree, ): await hass.config_entries.async_remove(setup_entry.entry_id) + assert mock_rmtree.called assert "Unable to remove map files" in caplog.text diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index e657029b668fc2..de620890c56aeb 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -121,7 +121,7 @@ async def test_none_map_select( ) -> None: """Test that the select entity correctly handles not having a current map.""" # Set map status to None so that current map is never set - fake_vacuum.v1_properties.status.map_status = None + fake_vacuum.v1_properties.home.current_map_data = None await async_setup_component(hass, DOMAIN, {}) select_entity = hass.states.get("select.roborock_s7_maxv_selected_map") assert select_entity From 6c99c3ecaa1038fa11948679d006d01ee6dd3b62 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Oct 2025 14:29:36 +0000 Subject: [PATCH 05/23] Change formatting to reduce review diffs --- homeassistant/components/roborock/__init__.py | 5 +-- .../components/roborock/coordinator.py | 33 +++++++++++-------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 15820081d74065..7975e2530465e6 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -105,10 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_domain=DOMAIN, translation_key="no_coordinators", ) - valid_coordinators = RoborockCoordinators( - v1=v1_coords, - a01=a01_coords, - ) + valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) async def on_stop(_: Any) -> None: _LOGGER.debug("Shutting down roborock") diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 001c77713e90cd..155cb1a3e38e64 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import StateType @@ -80,9 +81,6 @@ def __init__( ) self._device = device self.properties_api = properties_api - _LOGGER.debug( - "Creating coordinator for device %s - %s", device.duid, device.name - ) self.device_info = DeviceInfo( name=self._device.device_info.name, identifiers={(DOMAIN, self.duid)}, @@ -96,6 +94,7 @@ def __init__( (dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)) } self.last_update_state: str | None = None + # Keep track of last attempt to refresh maps/rooms to know when to try again. self._last_home_update_attempt: datetime | None = None self.last_home_update: datetime | None = None @@ -118,10 +117,8 @@ def dock_device_info(self) -> DeviceInfo: async def _async_setup(self) -> None: """Set up the coordinator.""" - # This will either read from the cache or load information about the - # home and cache the detail. The device can only load information for - # the current map so from here forward. await self.properties_api.status.refresh() + self._last_home_update_attempt = dt_util.utcnow() try: await self.properties_api.home.discover_home() @@ -130,6 +127,19 @@ async def _async_setup(self) -> None: else: self.last_home_update = dt_util.utcnow() + async def update_map(self) -> None: + """Update the currently selected map.""" + try: + await self.properties_api.home.discover_home() + await self.properties_api.home.refresh() + except RoborockException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) from ex + else: + self.last_home_update = dt_util.utcnow() + async def async_shutdown(self) -> None: """Shutdown the coordinator.""" await super().async_shutdown() @@ -183,14 +193,9 @@ async def _async_update_data(self) -> DeviceState: ) or self.last_update_state != new_status.state_name: self._last_home_update_attempt = dt_util.utcnow() try: - await self.properties_api.home.discover_home() - await self.properties_api.home.refresh() - except RoborockDeviceBusy as ex: - _LOGGER.debug("Not refreshing home while device is busy: %s", ex) - except RoborockException as ex: - _LOGGER.debug("Failed to update map data: %s", ex) - else: - self.last_home_update = dt_util.utcnow() + await self.update_map() + except HomeAssistantError as err: + _LOGGER.debug("Failed to update map: %s", err) if self.properties_api.status.in_cleaning: if self._device.is_local_connected: From 46f717d706e9b54458b742253379f1a631d80d9f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Oct 2025 14:33:17 +0000 Subject: [PATCH 06/23] Reduce coordinator diffs --- homeassistant/components/roborock/button.py | 1 - .../components/roborock/coordinator.py | 34 +++++++++---------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 16080a26be87ab..62168dec104d39 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -131,7 +131,6 @@ def __init__( async def async_press(self) -> None: """Press the button.""" - _LOGGER.debug("Pressing button %s", self._consumable) try: await self._consumable.reset_consumable(self.entity_description.attribute) except RoborockException as err: diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 155cb1a3e38e64..003946df3c8c62 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -173,6 +173,23 @@ async def _async_update_data(self) -> DeviceState: try: # Update device props and standard api information await self._update_device_prop() + + # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL + # since the last map update, you can update the map. + new_status = self.properties_api.status + if ( + new_status.in_cleaning + and ( + self._last_home_update_attempt is None + or (dt_util.utcnow() - self._last_home_update_attempt) + > IMAGE_CACHE_INTERVAL + ) + ) or self.last_update_state != new_status.state_name: + self._last_home_update_attempt = dt_util.utcnow() + try: + await self.update_map() + except HomeAssistantError as err: + _LOGGER.debug("Failed to update map: %s", err) except RoborockException as ex: _LOGGER.debug("Failed to update data: %s", ex) raise UpdateFailed( @@ -180,23 +197,6 @@ async def _async_update_data(self) -> DeviceState: translation_key="update_data_fail", ) from ex - # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL - # since the last map update, you can update the map. - new_status = self.properties_api.status - if ( - new_status.in_cleaning - and ( - self._last_home_update_attempt is None - or (dt_util.utcnow() - self._last_home_update_attempt) - > IMAGE_CACHE_INTERVAL - ) - ) or self.last_update_state != new_status.state_name: - self._last_home_update_attempt = dt_util.utcnow() - try: - await self.update_map() - except HomeAssistantError as err: - _LOGGER.debug("Failed to update map: %s", err) - if self.properties_api.status.in_cleaning: if self._device.is_local_connected: self.update_interval = V1_LOCAL_IN_CLEANING_INTERVAL From edcfd6ef2a4f3f18b40ab930808594ad5f70a31f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Oct 2025 14:34:45 +0000 Subject: [PATCH 07/23] Update refresh behavior --- homeassistant/components/roborock/coordinator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 003946df3c8c62..8779c3d3f34a4b 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -231,10 +231,13 @@ def device(self) -> RoborockDevice: async def _refresh_traits(traits: list[Any]) -> None: - """Refresh multiple traits concurrently.""" + """Refresh a list of traits serially. + + We refresh traits serially to avoid overloading the cloud servers or device + with requests. + """ for trait in traits: try: - # await asyncio.gather(*[trait.refresh() for trait in traits]) await trait.refresh() except RoborockException as ex: _LOGGER.debug( From 4dbc287a54133561cfce4f755f67f8d32e1ea133 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Oct 2025 14:38:00 +0000 Subject: [PATCH 08/23] Further reduce diffs for update timestamp --- homeassistant/components/roborock/coordinator.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 8779c3d3f34a4b..87ff3c097a4c37 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -95,7 +95,7 @@ def __init__( } self.last_update_state: str | None = None # Keep track of last attempt to refresh maps/rooms to know when to try again. - self._last_home_update_attempt: datetime | None = None + self._last_home_update_attempt: datetime self.last_home_update: datetime | None = None @cached_property @@ -179,11 +179,8 @@ async def _async_update_data(self) -> DeviceState: new_status = self.properties_api.status if ( new_status.in_cleaning - and ( - self._last_home_update_attempt is None - or (dt_util.utcnow() - self._last_home_update_attempt) - > IMAGE_CACHE_INTERVAL - ) + and (dt_util.utcnow() - self._last_home_update_attempt) + > IMAGE_CACHE_INTERVAL ) or self.last_update_state != new_status.state_name: self._last_home_update_attempt = dt_util.utcnow() try: From 910453eb78527d4691a96271f204d6b45022179b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Oct 2025 03:34:25 +0000 Subject: [PATCH 09/23] Add back support for flagging repair issues for local connection issues --- .../components/roborock/coordinator.py | 26 +++++++++++++ tests/components/roborock/conftest.py | 5 +++ tests/components/roborock/test_init.py | 39 +++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 87ff3c097a4c37..5cc3199178fb17 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -21,6 +21,11 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util, slugify @@ -117,6 +122,7 @@ def dock_device_info(self) -> DeviceInfo: async def _async_setup(self) -> None: """Set up the coordinator.""" + await self._verify_api() await self.properties_api.status.refresh() self._last_home_update_attempt = dt_util.utcnow() @@ -140,6 +146,26 @@ async def update_map(self) -> None: else: self.last_home_update = dt_util.utcnow() + async def _verify_api(self) -> None: + """Verify that the api is reachable. If it is not, switch clients.""" + if self._device.is_connected: + if self._device.is_local_connected: + async_delete_issue( + self.hass, DOMAIN, f"cloud_api_used_{self.duid_slug}" + ) + else: + self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL + async_create_issue( + self.hass, + DOMAIN, + f"cloud_api_used_{self.duid_slug}", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="cloud_api_used", + translation_placeholders={"device_name": self._device.name}, + learn_more_url="https://www.home-assistant.io/integrations/roborock/#the-integration-tells-me-it-cannot-reach-my-vacuum-and-is-using-the-cloud-api-and-that-this-is-not-supported-or-i-am-having-any-networking-issues", + ) + async def async_shutdown(self) -> None: """Shutdown the coordinator.""" await super().async_shutdown() diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 54a02d79786af5..fe4671dd8d837d 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -107,6 +107,9 @@ def bypass_api_client_fixture() -> None: class FakeDevice(RoborockDevice): """A fake device that returns a list of devices.""" + is_connected: bool = True + is_local_connected: bool = True + def __init__( self, device_info: HomeDataDevice, @@ -235,6 +238,8 @@ def fake_devices_fixture() -> list[FakeDevice]: device_info=deepcopy(device_data), product=deepcopy(device_product_data), ) + fake_device.is_connected = True + fake_device.is_local_connected = True if device_data.pv == "1.0": fake_device.v1_properties = create_v1_properties( NETWORK_INFO_BY_DEVICE[device_data.duid] diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 2d392c03b70ebb..75a96074006c1d 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component @@ -224,3 +225,41 @@ async def test_migrate_config_entry_unique_id( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.LOADED assert config_entry.unique_id == ROBOROCK_RRUID + + +async def test_cloud_api_repair( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_vacuum: FakeDevice, +) -> None: + """Test that a repair is created when we use the cloud api.""" + + # Fake that the device is only reachable via cloud + fake_vacuum.is_connected = True + fake_vacuum.is_local_connected = False + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + # Check that both expected device names are present, regardless of order + assert all( + issue.translation_key == "cloud_api_used" + for issue in issue_registry.issues.values() + ) + names = { + issue.translation_placeholders["device_name"] + for issue in issue_registry.issues.values() + } + assert names == {"Roborock S7 MaxV"} + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) + + # Now fake that the device is reachable locally again + fake_vacuum.is_local_connected = True + + # Set it back up + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 0 From ebad65234fe04a8075cbad9fe7a4c31013c6caf2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Oct 2025 03:46:34 +0000 Subject: [PATCH 10/23] Reduce diffs for improved readability --- homeassistant/components/roborock/__init__.py | 7 +--- homeassistant/components/roborock/button.py | 23 ++-------- .../components/roborock/coordinator.py | 42 +++++++++++++++++-- 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 7975e2530465e6..1365695e604dfd 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -137,11 +137,6 @@ def _remove_stale_devices( entry: RoborockConfigEntry, devices: list[RoborockDevice], ) -> None: - """Remove stale devices from the device registry. - - The devices that are no longer in the account are removed from the device registry. - The API returns all devices, even if they are offline. - """ device_map: dict[str, RoborockDevice] = {device.duid: device for device in devices} device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry( @@ -202,7 +197,7 @@ def build_setup_functions( RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None, ] ]: - """Create coordinators for all devices.""" + """Create a list of setup functions that can later be called asynchronously.""" coordinators: list[ RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 ] = [] diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 62168dec104d39..bb6bf73f4e5211 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -9,7 +9,6 @@ from typing import Any from roborock.devices.traits.v1.consumeable import ConsumableAttribute -from roborock.devices.traits.v1.routines import RoutinesTrait from roborock.exceptions import RoborockException from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -74,11 +73,7 @@ async def async_setup_entry( """Set up Roborock button platform.""" _LOGGER.debug("Setting up Roborock button platform") routines_lists = await asyncio.gather( - *[ - routines_trait.get_routines() - for coordinator in config_entry.runtime_data.v1 - if (routines_trait := coordinator.properties_api.routines) is not None - ], + *[coordinator.get_routines() for coordinator in config_entry.runtime_data.v1], ) async_add_entities( itertools.chain( @@ -98,12 +93,10 @@ async def async_setup_entry( key=str(routine.id), name=routine.name, ), - routines_trait=routines_trait, ) for coordinator, routines in zip( config_entry.runtime_data.v1, routines_lists, strict=True ) - if (routines_trait := coordinator.properties_api.routines) is not None for routine in routines ), ) @@ -152,7 +145,6 @@ def __init__( self, coordinator: RoborockDataUpdateCoordinator, entity_description: ButtonEntityDescription, - routines_trait: RoutinesTrait, ) -> None: """Create a button entity.""" super().__init__( @@ -160,18 +152,9 @@ def __init__( coordinator.device_info, ) self._routine_id = int(entity_description.key) - self._routines_trait = routines_trait + self._coordinator = coordinator self.entity_description = entity_description async def async_press(self, **kwargs: Any) -> None: """Press the button.""" - try: - await self._routines_trait.execute_routine(self._routine_id) - except RoborockException as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_failed", - translation_placeholders={ - "command": "execute_scene", - }, - ) from err + await self._coordinator.execute_routines(self._routine_id) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 5cc3199178fb17..5adfa7315fb1b2 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -8,7 +8,7 @@ from typing import Any, TypeVar from propcache.api import cached_property -from roborock.data import RoborockCategory +from roborock.data import HomeDataScene, RoborockCategory from roborock.devices.device import RoborockDevice from roborock.devices.traits.a01 import DyadApi, ZeoApi from roborock.devices.traits.v1 import PropertiesApi @@ -81,7 +81,7 @@ def __init__( _LOGGER, config_entry=config_entry, name=DOMAIN, - # Update interval is adjusted in `_async_update_data` + # Assume we can use the local api. update_interval=V1_LOCAL_NOT_CLEANING_INTERVAL, ) self._device = device @@ -130,8 +130,16 @@ async def _async_setup(self) -> None: await self.properties_api.home.discover_home() except RoborockDeviceBusy: _LOGGER.info("Home discovery skipped while device is busy/cleaning") + except RoborockException as err: + _LOGGER.debug("Failed to get maps: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="map_failure", + translation_placeholders={"error": str(err)}, + ) from err else: - self.last_home_update = dt_util.utcnow() + # Force a map refresh on first setup + self.last_home_update = dt_util.utcnow() - IMAGE_CACHE_INTERVAL async def update_map(self) -> None: """Update the currently selected map.""" @@ -237,6 +245,34 @@ async def _async_update_data(self) -> DeviceState: clean_summary=self.properties_api.clean_summary, ) + async def get_routines(self) -> list[HomeDataScene]: + """Get routines.""" + try: + return await self.properties_api.routines.get_routines() + except RoborockException as err: + _LOGGER.error("Failed to get routines %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "get_scenes", + }, + ) from err + + async def execute_routines(self, routine_id: int) -> None: + """Execute routines.""" + try: + await self.properties_api.routines.execute_routine(routine_id) + except RoborockException as err: + _LOGGER.error("Failed to execute routines %s %s", routine_id, err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "execute_scene", + }, + ) from err + @cached_property def duid(self) -> str: """Get the unique id of the device as specified by Roborock.""" From 8d8b13620d636a8dff8d9308ea8037ddb350c1f8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Oct 2025 03:54:25 +0000 Subject: [PATCH 11/23] Remove unnecessary logger --- homeassistant/components/roborock/select.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index e402e44cfc9633..1acb8707cc6d42 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -3,7 +3,6 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -import logging from roborock.data import RoborockDockDustCollectionModeCode from roborock.devices.traits.v1 import PropertiesApi @@ -23,7 +22,6 @@ from .entity import RoborockCoordinatedEntityV1 PARALLEL_UPDATES = 0 -_LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) @@ -205,7 +203,6 @@ def options(self) -> list[str]: @property def current_option(self) -> str | None: """Get the current status of the select entity from device_status.""" - _LOGGER.info("Current map data: %s", self._home_trait.current_map_data) if current_map_info := self._home_trait.current_map_data: return current_map_info.name or f"Map {current_map_info.map_flag}" return None From cc300afb23bdebf84fc51dac8e8e65940cdc3ff7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 8 Nov 2025 14:48:47 +0000 Subject: [PATCH 12/23] Add comment for reset consumable error handling translation improvements --- homeassistant/components/roborock/button.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index bb6bf73f4e5211..d6f683848bc712 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -127,6 +127,9 @@ async def async_press(self) -> None: try: await self._consumable.reset_consumable(self.entity_description.attribute) except RoborockException as err: + # This error message could be improved since it is fairly low level + # and technical. Can add a more user friendly message with the + # name of the attribute being reset. raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed", From 4d28c5ae431a635766c0e2583a442908ea0c0889 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 8 Nov 2025 15:06:21 +0000 Subject: [PATCH 13/23] Add comment about implications of home discovery failures --- homeassistant/components/roborock/coordinator.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 5adfa7315fb1b2..f4b228814dcda1 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -126,6 +126,13 @@ async def _async_setup(self) -> None: await self.properties_api.status.refresh() self._last_home_update_attempt = dt_util.utcnow() + + # This populates a cache of maps/rooms so we have the information + # even for maps that are inactive but is a no-op if we already have + # the information. This will cycle through all the available maps and + # requires the device to be idle. If the device is busy cleaning, then + # we'll retry later in `update_map` and in the mean time we won't have + # all map/room information. try: await self.properties_api.home.discover_home() except RoborockDeviceBusy: From 4b06b87bc377f0d425b0fbe86579c57759a20e9c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 10 Nov 2025 04:34:00 +0000 Subject: [PATCH 14/23] Fix lint errors in test_number.py --- tests/components/roborock/test_number.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py index 6413395cd02758..62e23faf9e2f35 100644 --- a/tests/components/roborock/test_number.py +++ b/tests/components/roborock/test_number.py @@ -8,8 +8,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from .conftest import FakeDevice + from tests.common import MockConfigEntry -from tests.components.roborock.test_vacuum import FakeDevice @pytest.fixture From 652f405c71125f839520a1314aeabb6f80fe5a42 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Nov 2025 02:34:11 +0000 Subject: [PATCH 15/23] Move get_devices out of the exception catch path --- homeassistant/components/roborock/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1365695e604dfd..ae56fc47538a67 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -56,7 +56,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> cache=cache, session=async_get_clientsession(hass), ) - devices = await device_manager.get_devices() except RoborockInvalidCredentials as err: raise ConfigEntryAuthFailed( "Invalid credentials", @@ -80,7 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_domain=DOMAIN, translation_key="home_data_fail", ) from err - + devices = await device_manager.get_devices() _LOGGER.debug("Device manager found %d devices", len(devices)) for device in devices: entry.async_on_unload(device.close) From cb92699e19a9a30e5c9561893b1d3cc79311752f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Nov 2025 02:42:59 +0000 Subject: [PATCH 16/23] Add exception handling in coordinator setup --- homeassistant/components/roborock/button.py | 1 - homeassistant/components/roborock/coordinator.py | 13 ++++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index d6f683848bc712..2365a86c703a91 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -71,7 +71,6 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock button platform.""" - _LOGGER.debug("Setting up Roborock button platform") routines_lists = await asyncio.gather( *[coordinator.get_routines() for coordinator in config_entry.runtime_data.v1], ) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index f4b228814dcda1..f368bee55d6c32 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -123,7 +123,14 @@ def dock_device_info(self) -> DeviceInfo: async def _async_setup(self) -> None: """Set up the coordinator.""" await self._verify_api() - await self.properties_api.status.refresh() + try: + await self.properties_api.status.refresh() + except RoborockException as err: + _LOGGER.debug("Failed to update data during setup: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) from err self._last_home_update_attempt = dt_util.utcnow() @@ -181,10 +188,6 @@ async def _verify_api(self) -> None: learn_more_url="https://www.home-assistant.io/integrations/roborock/#the-integration-tells-me-it-cannot-reach-my-vacuum-and-is-using-the-cloud-api-and-that-this-is-not-supported-or-i-am-having-any-networking-issues", ) - async def async_shutdown(self) -> None: - """Shutdown the coordinator.""" - await super().async_shutdown() - async def _update_device_prop(self) -> None: """Update device properties.""" await _refresh_traits( From b5a2181c998bdc2f09e145929fa9b6e1d1f3d591 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Nov 2025 02:45:22 +0000 Subject: [PATCH 17/23] Add comment describing update behavior --- homeassistant/components/roborock/coordinator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index f368bee55d6c32..8dbf3ff01339db 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -303,7 +303,8 @@ async def _refresh_traits(traits: list[Any]) -> None: """Refresh a list of traits serially. We refresh traits serially to avoid overloading the cloud servers or device - with requests. + with requests. If any single trait fails to refresh, we stop the whole + update process and raise UpdateFailed. """ for trait in traits: try: From 6b289a3db7c38c2ee4ebf7e995035f486b1f55e8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Nov 2025 02:47:25 +0000 Subject: [PATCH 18/23] Update coordinator based on feedback --- .../components/roborock/coordinator.py | 39 ++++++++----------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 8dbf3ff01339db..8be0b70b44ba9e 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -8,7 +8,7 @@ from typing import Any, TypeVar from propcache.api import cached_property -from roborock.data import HomeDataScene, RoborockCategory +from roborock.data import HomeDataScene from roborock.devices.device import RoborockDevice from roborock.devices.traits.a01 import DyadApi, ZeoApi from roborock.devices.traits.v1 import PropertiesApi @@ -383,15 +383,13 @@ def __init__( super().__init__(hass, config_entry, device) self.api = api self.request_protocols: list[RoborockZeoProtocol] = [] - if device.product.category == RoborockCategory.WASHING_MACHINE: - self.request_protocols = [ - RoborockZeoProtocol.STATE, - RoborockZeoProtocol.COUNTDOWN, - RoborockZeoProtocol.WASHING_LEFT, - RoborockZeoProtocol.ERROR, - ] - else: - _LOGGER.warning("The device you added is not yet supported") + # This currently only supports the washing machine protocols + self.request_protocols = [ + RoborockZeoProtocol.STATE, + RoborockZeoProtocol.COUNTDOWN, + RoborockZeoProtocol.WASHING_LEFT, + RoborockZeoProtocol.ERROR, + ] async def _async_update_data( self, @@ -414,18 +412,15 @@ def __init__( """Initialize.""" super().__init__(hass, config_entry, device) self.api = api - self.request_protocols: list[RoborockDyadDataProtocol] = [] - if device.product.category == RoborockCategory.WET_DRY_VAC: - self.request_protocols = [ - RoborockDyadDataProtocol.STATUS, - RoborockDyadDataProtocol.POWER, - RoborockDyadDataProtocol.MESH_LEFT, - RoborockDyadDataProtocol.BRUSH_LEFT, - RoborockDyadDataProtocol.ERROR, - RoborockDyadDataProtocol.TOTAL_RUN_TIME, - ] - else: - _LOGGER.warning("The device you added is not yet supported") + # This currenltly only supports the WetDryVac protocols + self.request_protocols: list[RoborockDyadDataProtocol] = [ + RoborockDyadDataProtocol.STATUS, + RoborockDyadDataProtocol.POWER, + RoborockDyadDataProtocol.MESH_LEFT, + RoborockDyadDataProtocol.BRUSH_LEFT, + RoborockDyadDataProtocol.ERROR, + RoborockDyadDataProtocol.TOTAL_RUN_TIME, + ] async def _async_update_data( self, From 4c3ab7695614fa970b0edacc5842e72959672501 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Nov 2025 02:51:28 +0000 Subject: [PATCH 19/23] Update style improvements --- homeassistant/components/roborock/select.py | 28 +++++++++++++-------- homeassistant/components/roborock/sensor.py | 16 +++++++----- homeassistant/components/roborock/switch.py | 8 +++--- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 1acb8707cc6d42..2dba430fde93db 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -51,9 +51,11 @@ class RoborockSelectDescription(SelectEntityDescription): api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, value_fn=lambda api: api.status.water_box_mode_name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda api: api.status.water_box_mode.keys() - if api.status.water_box_mode is not None - else None, + options_lambda=lambda api: ( + api.status.water_box_mode.keys() + if api.status.water_box_mode is not None + else None + ), parameter_lambda=lambda key, api: [api.status.get_mop_intensity_code(key)], ), RoborockSelectDescription( @@ -62,9 +64,9 @@ class RoborockSelectDescription(SelectEntityDescription): api_command=RoborockCommand.SET_MOP_MODE, value_fn=lambda api: api.status.mop_mode_name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda api: api.status.mop_mode.keys() - if api.status.mop_mode is not None - else None, + options_lambda=lambda api: ( + api.status.mop_mode.keys() if api.status.mop_mode is not None else None + ), parameter_lambda=lambda key, api: [api.status.get_mop_mode_code(key)], ), RoborockSelectDescription( @@ -73,9 +75,11 @@ class RoborockSelectDescription(SelectEntityDescription): api_command=RoborockCommand.SET_DUST_COLLECTION_MODE, value_fn=lambda api: api.dust_collection_mode.mode.name, # type: ignore[union-attr] entity_category=EntityCategory.CONFIG, - options_lambda=lambda api: RoborockDockDustCollectionModeCode.keys() - if api.dust_collection_mode is not None - else None, + options_lambda=lambda api: ( + RoborockDockDustCollectionModeCode.keys() + if api.dust_collection_mode is not None + else None + ), parameter_lambda=lambda key, _: [ RoborockDockDustCollectionModeCode.as_dict().get(key) ], @@ -95,8 +99,10 @@ async def async_setup_entry( RoborockSelectEntity(coordinator, description, options) for coordinator in config_entry.runtime_data.v1 for description in SELECT_DESCRIPTIONS - if (options := description.options_lambda(coordinator.properties_api)) - is not None + if ( + (options := description.options_lambda(coordinator.properties_api)) + is not None + ) ) async_add_entities( RoborockCurrentMapSelectEntity( diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 4482d7ea81ae46..6eb633ca939523 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -193,18 +193,22 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: RoborockSensorDescription( key="last_clean_start", translation_key="last_clean_start", - value_fn=lambda data: data.clean_summary.last_clean_record.begin_datetime - if data.clean_summary.last_clean_record is not None - else None, + value_fn=lambda data: ( + data.clean_summary.last_clean_record.begin_datetime + if data.clean_summary.last_clean_record is not None + else None + ), entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, ), RoborockSensorDescription( key="last_clean_end", translation_key="last_clean_end", - value_fn=lambda data: data.clean_summary.last_clean_record.end_datetime - if data.clean_summary.last_clean_record is not None - else None, + value_fn=lambda data: ( + data.clean_summary.last_clean_record.end_datetime + if data.clean_summary.last_clean_record is not None + else None + ), entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, ), diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index cf5427269b7a56..b1d61461eb64a0 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -104,9 +104,11 @@ def __init__( self.entity_description = entity_description super().__init__( unique_id, - coordinator.device_info - if not entity_description.is_dock_entity - else coordinator.dock_device_info, + ( + coordinator.device_info + if not entity_description.is_dock_entity + else coordinator.dock_device_info + ), coordinator.properties_api.command, ) self._trait = trait From c42ebc69a544af1c2e8fd7ef565949cf0ffe523f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Nov 2025 02:53:34 +0000 Subject: [PATCH 20/23] Update exception handling --- homeassistant/components/roborock/image.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 4a35c727446d1c..b4bfacbc306a4f 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator @@ -100,5 +101,5 @@ def _handle_coordinator_update(self) -> None: async def async_image(self) -> bytes | None: """Get the cached image.""" if (map_content := self._map_content) is None: - raise ValueError("Map flag not found in coordinator maps") + raise HomeAssistantError("Map flag not found in coordinator maps") return map_content.image_content From 54a977591914083c4feb144b0b3e59eda0c0a20f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Nov 2025 02:56:32 +0000 Subject: [PATCH 21/23] Rename washing machine and wet dry vac coordinators --- homeassistant/components/roborock/__init__.py | 8 ++++---- homeassistant/components/roborock/coordinator.py | 4 ++-- tests/components/roborock/test_init.py | 13 ------------- 3 files changed, 6 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index ae56fc47538a67..045a7a1ba51eff 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -30,8 +30,8 @@ RoborockCoordinators, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, - RoborockDyadUpdateCoordinator, - RoborockZeoUpdateCoordinator, + RoborockWashingMachineUpdateCoordinator, + RoborockWetDryVacUpdateCoordinator, ) from .roborock_storage import CacheStore, async_remove_map_storage @@ -208,11 +208,11 @@ def build_setup_functions( ) elif device.dyad is not None: coordinators.append( - RoborockDyadUpdateCoordinator(hass, entry, device, device.dyad) + RoborockWetDryVacUpdateCoordinator(hass, entry, device, device.dyad) ) elif device.zeo is not None: coordinators.append( - RoborockZeoUpdateCoordinator(hass, entry, device, device.zeo) + RoborockWashingMachineUpdateCoordinator(hass, entry, device, device.zeo) ) else: _LOGGER.warning( diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 8be0b70b44ba9e..54911795cd80f7 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -367,7 +367,7 @@ def device(self) -> RoborockDevice: return self._device -class RoborockZeoUpdateCoordinator( +class RoborockWashingMachineUpdateCoordinator( RoborockDataUpdateCoordinatorA01[RoborockZeoProtocol] ): """Coordinator for Zeo devices.""" @@ -397,7 +397,7 @@ async def _async_update_data( return await self.api.query_values(self.request_protocols) -class RoborockDyadUpdateCoordinator( +class RoborockWetDryVacUpdateCoordinator( RoborockDataUpdateCoordinatorA01[RoborockDyadDataProtocol] ): """Coordinator for Dyad devices.""" diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 75a96074006c1d..4fa99df706130d 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -90,19 +90,6 @@ async def test_not_supported_protocol( assert "because its protocol version " in caplog.text -async def test_not_supported_a01_device( - hass: HomeAssistant, - mock_roborock_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, - fake_devices: list[FakeDevice], -) -> None: - """Test that we output a message on incorrect category.""" - fake_devices[2].product.category = "random" - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert "The device you added is not yet supported" in caplog.text - - async def test_invalid_user_agreement( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, From ddf9acfae7a2118b6d0b9fe24f5654ebd82bf077 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Nov 2025 14:24:54 +0000 Subject: [PATCH 22/23] Remove unnecessary patches --- tests/components/roborock/conftest.py | 29 ------------------------ tests/components/roborock/test_select.py | 1 - 2 files changed, 30 deletions(-) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index fe4671dd8d837d..bf9cc8768ef1eb 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -91,10 +91,6 @@ def bypass_api_client_fixture() -> None: base_url_future.set_result(BASE_URL) with ( - patch( - "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", - return_value=HOME_DATA, - ), patch( "homeassistant.components.roborock.config_flow.RoborockApiClient.base_url", new_callable=PropertyMock, @@ -293,31 +289,6 @@ def fake_create_device_manager_fixture( yield mock_create_device_manager -@pytest.fixture(name="bypass_device_manager", autouse=True) -def bypass_device_manager_fixture() -> None: - """Bypass the device manager network connection.""" - with ( - patch("roborock.devices.device_manager.create_lazy_mqtt_session"), - patch( - "roborock.devices.device_manager.create_v1_channel" - ) as mock_create_v1_channel, - ): - mock_create_v1_channel.return_value = AsyncMock() - yield - - -@pytest.fixture -def bypass_api_fixture_v1_only() -> None: - """Bypass api for tests that require only having v1 devices.""" - home_data_copy = deepcopy(HOME_DATA) - home_data_copy.received_devices = [] - with patch( - "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", - return_value=home_data_copy, - ): - yield - - @pytest.fixture(name="config_entry_data") def config_entry_data_fixture() -> dict[str, Any]: """Fixture that returns the unique id for the config entry.""" diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index de620890c56aeb..88c639b4b83e70 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -140,7 +140,6 @@ async def test_selected_map_name( async def test_selected_map_without_name( hass: HomeAssistant, - bypass_api_fixture_v1_only, mock_roborock_entry: MockConfigEntry, fake_vacuum: FakeDevice, ) -> None: From 2707ed00c7a560d76656fb388ce355930c0ed337 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 16 Nov 2025 16:00:12 +0000 Subject: [PATCH 23/23] Add tests that entity staes from the traits are refreshed --- tests/components/roborock/conftest.py | 60 ++++++++++++++---------- tests/components/roborock/test_number.py | 9 +++- tests/components/roborock/test_switch.py | 39 ++++++++------- tests/components/roborock/test_time.py | 19 +++++++- 4 files changed, 80 insertions(+), 47 deletions(-) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index bf9cc8768ef1eb..7c026d5c7d5457 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -1,7 +1,7 @@ """Global fixtures for Roborock integration.""" import asyncio -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator from copy import deepcopy import logging import pathlib @@ -130,17 +130,38 @@ async def get_devices(self) -> list[RoborockDevice]: return self._devices +def make_fake_switch(obj: Any) -> Any: + """Update the fake object to emulate the switch trait behavior.""" + obj.is_on = True + obj.enable = AsyncMock() + obj.enable.side_effect = lambda: setattr(obj, "is_on", True) + obj.disable = AsyncMock() + obj.disable.side_effect = lambda: setattr(obj, "is_on", False) + obj.refresh = AsyncMock() + return obj + + +def set_timer_fn(obj: Any) -> Callable[[Any], Awaitable[None]]: + """Make a function for the fake timer trait that emulates the real behavior.""" + + async def update_timer_attributes(timer: Any) -> None: + setattr(obj, "start_hour", timer.start_hour) + setattr(obj, "start_minute", timer.start_minute) + setattr(obj, "end_hour", timer.end_hour) + setattr(obj, "end_minute", timer.end_minute) + setattr(obj, "enabled", timer.enabled) + + return update_timer_attributes + + def create_v1_properties(network_info: NetworkInfo) -> Mock: """Create v1 properties for each fake device.""" v1_properties = Mock() v1_properties.status: Any = deepcopy(STATUS) v1_properties.status.refresh = AsyncMock() - v1_properties.dnd: Any = deepcopy(DND_TIMER) - v1_properties.dnd.is_on = True - v1_properties.dnd.refresh = AsyncMock() - v1_properties.dnd.enable = AsyncMock() - v1_properties.dnd.disable = AsyncMock() + v1_properties.dnd: Any = make_fake_switch(deepcopy(DND_TIMER)) v1_properties.dnd.set_dnd_timer = AsyncMock() + v1_properties.dnd.set_dnd_timer.side_effect = set_timer_fn(v1_properties.dnd) v1_properties.clean_summary: Any = deepcopy(CLEAN_SUMMARY) v1_properties.clean_summary.last_clean_record = deepcopy(CLEAN_RECORD) v1_properties.clean_summary.refresh = AsyncMock() @@ -149,6 +170,9 @@ def create_v1_properties(network_info: NetworkInfo) -> Mock: v1_properties.consumables.reset_consumable = AsyncMock() v1_properties.sound_volume = SoundVolume(volume=50) v1_properties.sound_volume.set_volume = AsyncMock() + v1_properties.sound_volume.set_volume.side_effect = lambda vol: setattr( + v1_properties.sound_volume, "volume", vol + ) v1_properties.sound_volume.refresh = AsyncMock() v1_properties.command = AsyncMock() v1_properties.command.send = AsyncMock() @@ -160,26 +184,10 @@ def create_v1_properties(network_info: NetworkInfo) -> Mock: v1_properties.map_content.image_content = b"\x89PNG-001" v1_properties.map_content.map_data = deepcopy(MAP_DATA) v1_properties.map_content.refresh = AsyncMock() - v1_properties.child_lock = AsyncMock() - v1_properties.child_lock.is_on = True - v1_properties.child_lock.enable = AsyncMock() - v1_properties.child_lock.disable = AsyncMock() - v1_properties.child_lock.refresh = AsyncMock() - v1_properties.led_status = AsyncMock() - v1_properties.led_status.is_on = True - v1_properties.led_status.enable = AsyncMock() - v1_properties.led_status.disable = AsyncMock() - v1_properties.led_status.refresh = AsyncMock() - v1_properties.flow_led_status = AsyncMock() - v1_properties.flow_led_status.is_on = True - v1_properties.flow_led_status.enable = AsyncMock() - v1_properties.flow_led_status.disable = AsyncMock() - v1_properties.flow_led_status.refresh = AsyncMock() - v1_properties.valley_electricity_timer = AsyncMock() - v1_properties.valley_electricity_timer.is_on = True - v1_properties.valley_electricity_timer.enable = AsyncMock() - v1_properties.valley_electricity_timer.disable = AsyncMock() - v1_properties.valley_electricity_timer.refresh = AsyncMock() + v1_properties.child_lock = make_fake_switch(AsyncMock()) + v1_properties.led_status = make_fake_switch(AsyncMock()) + v1_properties.flow_led_status = make_fake_switch(AsyncMock()) + v1_properties.valley_electricity_timer = make_fake_switch(AsyncMock()) v1_properties.dust_collection_mode = AsyncMock() v1_properties.dust_collection_mode.refresh = AsyncMock() v1_properties.wash_towel_mode = AsyncMock() diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py index 62e23faf9e2f35..24425b059bf4f1 100644 --- a/tests/components/roborock/test_number.py +++ b/tests/components/roborock/test_number.py @@ -27,7 +27,9 @@ async def test_update_sound_volume( """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("number.roborock_s7_maxv_volume") is not None + state = hass.states.get("number.roborock_s7_maxv_volume") + assert state is not None + assert state.state == "50.0" await hass.services.async_call( "number", @@ -41,6 +43,11 @@ async def test_update_sound_volume( assert fake_vacuum.v1_properties.sound_volume.set_volume.call_count == 1 assert fake_vacuum.v1_properties.sound_volume.set_volume.call_args[0] == (3.0,) + # Verify the entity state is updated with the latest information from the trait + state = hass.states.get("number.roborock_s7_maxv_volume") + assert state is not None + assert state.state == "3.0" + async def test_volume_update_failed( hass: HomeAssistant, diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index bb0158a6353935..6a5c706e030072 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -23,48 +23,51 @@ def platforms() -> list[Platform]: @pytest.mark.parametrize( - ("entity_id", "trait_fn"), + ("entity_id"), [ - ("switch.roborock_s7_maxv_dock_child_lock", lambda trait: trait.child_lock), - ( - "switch.roborock_s7_maxv_dock_status_indicator_light", - lambda trait: trait.flow_led_status, - ), - ("switch.roborock_s7_maxv_do_not_disturb", lambda trait: trait.dnd), + ("switch.roborock_s7_maxv_dock_child_lock"), + ("switch.roborock_s7_maxv_dock_status_indicator_light"), + ("switch.roborock_s7_maxv_do_not_disturb"), ], ) async def test_update_success( hass: HomeAssistant, setup_entry: MockConfigEntry, entity_id: str, - fake_vacuum: FakeDevice, - trait_fn: Callable[[Any], Any], ) -> None: """Test turning switch entities on and off.""" - trait = trait_fn(fake_vacuum.v1_properties) + # The entity fixture in conftest.py starts with the switch on and will + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" - # Ensure that the entity exist, as these test can pass even if there is no entity. + # Turn off the switch and verify the entity state is updated properly with + # the latest information from the trait. assert hass.states.get(entity_id) is not None await hass.services.async_call( "switch", - SERVICE_TURN_ON, + SERVICE_TURN_OFF, service_data=None, blocking=True, target={"entity_id": entity_id}, ) - assert len(trait.enable.mock_calls) == 1 - assert len(trait.disable.mock_calls) == 0 - trait.enable.reset_mock() + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" + # Turn back on and verify the entity state is updated properly with the + # latest information from the trait + assert hass.states.get(entity_id) is not None await hass.services.async_call( "switch", - SERVICE_TURN_OFF, + SERVICE_TURN_ON, service_data=None, blocking=True, target={"entity_id": entity_id}, ) - assert len(trait.enable.mock_calls) == 0 - assert len(trait.disable.mock_calls) == 1 + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" @pytest.mark.parametrize( diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index ceae23705c08b3..4bad15aa6817fa 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -23,17 +23,21 @@ def platforms() -> list[Platform]: @pytest.mark.parametrize( - ("entity_id", "expected_args"), + ("entity_id", "start_state", "expected_args", "end_state"), [ ( "time.roborock_s7_maxv_do_not_disturb_begin", + "22:00:00", DnDTimer(start_hour=1, start_minute=1, end_hour=7, end_minute=0, enabled=1), + "01:01:00", ), ( "time.roborock_s7_maxv_do_not_disturb_end", + "07:00:00", DnDTimer( start_hour=22, start_minute=0, end_hour=1, end_minute=1, enabled=1 ), + "01:01:00", ), ], ) @@ -42,11 +46,16 @@ async def test_update_success( setup_entry: MockConfigEntry, fake_vacuum: FakeDevice, entity_id: str, + start_state: str, + end_state: str, expected_args: DnDTimer, ) -> 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 + state = hass.states.get(entity_id) + assert state is not None + assert state.state == start_state + await hass.services.async_call( "time", SERVICE_SET_VALUE, @@ -56,8 +65,14 @@ async def test_update_success( ) assert fake_vacuum.v1_properties.dnd.set_dnd_timer.call_count == 1 + # Since we update the begin or end time separately: Verify that the args are built properly + # by reading the existing value and only updating the relevant fields. assert fake_vacuum.v1_properties.dnd.set_dnd_timer.call_args == ((expected_args,),) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == end_state + @pytest.mark.parametrize( ("entity_id"),