Add support for Dyad vacuums to Roborock (#115331)

This commit is contained in:
Luke Lashley 2024-06-26 09:40:19 -04:00 committed by GitHub
parent 4defc4a58f
commit d0f82d6f02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 874 additions and 117 deletions

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Coroutine from collections.abc import Coroutine
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
@ -12,6 +13,7 @@ from roborock import HomeDataRoom, RoborockException, RoborockInvalidCredentials
from roborock.code_mappings import RoborockCategory from roborock.code_mappings import RoborockCategory
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 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.web_api import RoborockApiClient
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -20,13 +22,27 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS
from .coordinator import RoborockDataUpdateCoordinator from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01
SCAN_INTERVAL = timedelta(seconds=30) SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@dataclass
class RoborockCoordinators:
"""Roborock coordinators type."""
v1: list[RoborockDataUpdateCoordinator]
a01: list[RoborockDataUpdateCoordinatorA01]
def values(
self,
) -> list[RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01]:
"""Return all coordinators."""
return self.v1 + self.a01
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up roborock from a config entry.""" """Set up roborock from a config entry."""
@ -37,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL]) api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL])
_LOGGER.debug("Getting home data") _LOGGER.debug("Getting home data")
try: try:
home_data = await api_client.get_home_data(user_data) home_data = await api_client.get_home_data_v2(user_data)
except RoborockInvalidCredentials as err: except RoborockInvalidCredentials as err:
raise ConfigEntryAuthFailed( raise ConfigEntryAuthFailed(
"Invalid credentials", "Invalid credentials",
@ -66,21 +82,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return_exceptions=True, return_exceptions=True,
) )
# Valid coordinators are those where we had networking cached or we could get networking # Valid coordinators are those where we had networking cached or we could get networking
valid_coordinators: list[RoborockDataUpdateCoordinator] = [ v1_coords = [
coord coord
for coord in coordinators for coord in coordinators
if isinstance(coord, RoborockDataUpdateCoordinator) if isinstance(coord, RoborockDataUpdateCoordinator)
] ]
if len(valid_coordinators) == 0: a01_coords = [
coord
for coord in coordinators
if isinstance(coord, RoborockDataUpdateCoordinatorA01)
]
if len(v1_coords) + len(a01_coords) == 0:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
"No devices were able to successfully setup", "No devices were able to successfully setup",
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="no_coordinators", translation_key="no_coordinators",
) )
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { valid_coordinators = RoborockCoordinators(v1_coords, a01_coords)
coordinator.api.device_info.device.duid: coordinator hass.data.setdefault(DOMAIN, {})[entry.entry_id] = valid_coordinators
for coordinator in valid_coordinators
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
@ -92,14 +111,19 @@ def build_setup_functions(
user_data: UserData, user_data: UserData,
product_info: dict[str, HomeDataProduct], product_info: dict[str, HomeDataProduct],
home_data_rooms: list[HomeDataRoom], home_data_rooms: list[HomeDataRoom],
) -> list[Coroutine[Any, Any, RoborockDataUpdateCoordinator | None]]: ) -> list[
Coroutine[
Any,
Any,
RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None,
]
]:
"""Create a list of setup functions that can later be called asynchronously.""" """Create a list of setup functions that can later be called asynchronously."""
return [ return [
setup_device( setup_device(
hass, user_data, device, product_info[device.product_id], home_data_rooms hass, user_data, device, product_info[device.product_id], home_data_rooms
) )
for device in device_map.values() for device in device_map.values()
if product_info[device.product_id].category == RoborockCategory.VACUUM
] ]
@ -109,11 +133,33 @@ async def setup_device(
device: HomeDataDevice, device: HomeDataDevice,
product_info: HomeDataProduct, product_info: HomeDataProduct,
home_data_rooms: list[HomeDataRoom], home_data_rooms: list[HomeDataRoom],
) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None:
"""Set up a coordinator for a given device."""
if device.pv == "1.0":
return await setup_device_v1(
hass, user_data, device, product_info, home_data_rooms
)
if device.pv == "A01":
if product_info.category == RoborockCategory.WET_DRY_VAC:
return await setup_device_a01(hass, user_data, device, product_info)
_LOGGER.info(
"Not adding device %s because its protocol version %s or category %s is not supported",
device.duid,
device.pv,
product_info.category.name,
)
return None
async def setup_device_v1(
hass: HomeAssistant,
user_data: UserData,
device: HomeDataDevice,
product_info: HomeDataProduct,
home_data_rooms: list[HomeDataRoom],
) -> RoborockDataUpdateCoordinator | None: ) -> RoborockDataUpdateCoordinator | None:
"""Set up a device Coordinator.""" """Set up a device Coordinator."""
mqtt_client = RoborockMqttClientV1( mqtt_client = RoborockMqttClientV1(user_data, DeviceData(device, product_info.name))
user_data, DeviceData(device, product_info.model)
)
try: try:
networking = await mqtt_client.get_networking() networking = await mqtt_client.get_networking()
if networking is None: if networking is None:
@ -170,6 +216,21 @@ async def setup_device(
return coordinator return coordinator
async def setup_device_a01(
hass: HomeAssistant,
user_data: UserData,
device: HomeDataDevice,
product_info: HomeDataProduct,
) -> RoborockDataUpdateCoordinatorA01 | None:
"""Set up a A01 protocol device."""
mqtt_client = RoborockMqttClientA01(
user_data, DeviceData(device, product_info.name), product_info.category
)
coord = RoborockDataUpdateCoordinatorA01(hass, device, product_info, mqtt_client)
await coord.async_config_entry_first_refresh()
return coord
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle removal of an entry.""" """Handle removal of an entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):

View File

@ -18,9 +18,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify from homeassistant.util import slugify
from . import RoborockCoordinators
from .const import DOMAIN from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator from .coordinator import RoborockDataUpdateCoordinator
from .device import RoborockCoordinatedEntity from .device import RoborockCoordinatedEntityV1
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@ -75,34 +76,33 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Roborock vacuum binary sensors.""" """Set up the Roborock vacuum binary sensors."""
coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id
]
async_add_entities( async_add_entities(
RoborockBinarySensorEntity( RoborockBinarySensorEntity(
f"{description.key}_{slugify(device_id)}",
coordinator, coordinator,
description, description,
) )
for device_id, coordinator in coordinators.items() for coordinator in coordinators.v1
for description in BINARY_SENSOR_DESCRIPTIONS for description in BINARY_SENSOR_DESCRIPTIONS
if description.value_fn(coordinator.roborock_device_info.props) is not None if description.value_fn(coordinator.roborock_device_info.props) is not None
) )
class RoborockBinarySensorEntity(RoborockCoordinatedEntity, BinarySensorEntity): class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity):
"""Representation of a Roborock binary sensor.""" """Representation of a Roborock binary sensor."""
entity_description: RoborockBinarySensorDescription entity_description: RoborockBinarySensorDescription
def __init__( def __init__(
self, self,
unique_id: str,
coordinator: RoborockDataUpdateCoordinator, coordinator: RoborockDataUpdateCoordinator,
description: RoborockBinarySensorDescription, description: RoborockBinarySensorDescription,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(unique_id, coordinator) super().__init__(
f"{description.key}_{slugify(coordinator.duid)}",
coordinator,
)
self.entity_description = description self.entity_description = description
@property @property

View File

@ -13,9 +13,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify from homeassistant.util import slugify
from . import RoborockCoordinators
from .const import DOMAIN from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator from .coordinator import RoborockDataUpdateCoordinator
from .device import RoborockEntity from .device import RoborockEntityV1
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@ -68,33 +69,34 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Roborock button platform.""" """Set up Roborock button platform."""
coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id
]
async_add_entities( async_add_entities(
RoborockButtonEntity( RoborockButtonEntity(
f"{description.key}_{slugify(device_id)}",
coordinator, coordinator,
description, description,
) )
for device_id, coordinator in coordinators.items() for coordinator in coordinators.v1
for description in CONSUMABLE_BUTTON_DESCRIPTIONS for description in CONSUMABLE_BUTTON_DESCRIPTIONS
if isinstance(coordinator, RoborockDataUpdateCoordinator)
) )
class RoborockButtonEntity(RoborockEntity, ButtonEntity): class RoborockButtonEntity(RoborockEntityV1, ButtonEntity):
"""A class to define Roborock button entities.""" """A class to define Roborock button entities."""
entity_description: RoborockButtonDescription entity_description: RoborockButtonDescription
def __init__( def __init__(
self, self,
unique_id: str,
coordinator: RoborockDataUpdateCoordinator, coordinator: RoborockDataUpdateCoordinator,
entity_description: RoborockButtonDescription, entity_description: RoborockButtonDescription,
) -> None: ) -> None:
"""Create a button entity.""" """Create a button entity."""
super().__init__(unique_id, coordinator.device_info, coordinator.api) super().__init__(
f"{entity_description.key}_{slugify(coordinator.duid)}",
coordinator.device_info,
coordinator.api,
)
self.entity_description = entity_description self.entity_description = entity_description
async def async_press(self) -> None: async def async_press(self) -> None:

View File

@ -9,18 +9,21 @@ import logging
from roborock import HomeDataRoom from roborock import HomeDataRoom
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo
from roborock.exceptions import RoborockException from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
from roborock.roborock_typing import DeviceProp from roborock.roborock_typing import DeviceProp
from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1 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_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
from roborock.version_a01_apis import RoborockClientA01
from homeassistant.const import ATTR_CONNECTIONS from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
from .models import RoborockHassDeviceInfo, RoborockMapInfo from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo
SCAN_INTERVAL = timedelta(seconds=30) SCAN_INTERVAL = timedelta(seconds=30)
@ -77,6 +80,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
"Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance", "Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance",
self.roborock_device_info.device.duid, self.roborock_device_info.device.duid,
) )
await self.api.async_disconnect()
# We use the cloud api if the local api fails to connect. # We use the cloud api if the local api fails to connect.
self.api = self.cloud_api self.api = self.cloud_api
# Right now this should never be called if the cloud api is the primary api, # Right now this should never be called if the cloud api is the primary api,
@ -137,3 +141,57 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
self.maps[self.current_map].rooms[room.segment_id] = ( self.maps[self.current_map].rooms[room.segment_id] = (
self._home_data_rooms.get(room.iot_id, "Unknown") self._home_data_rooms.get(room.iot_id, "Unknown")
) )
@property
def duid(self) -> str:
"""Get the unique id of the device as specified by Roborock."""
return self.roborock_device_info.device.duid
class RoborockDataUpdateCoordinatorA01(
DataUpdateCoordinator[
dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType]
]
):
"""Class to manage fetching data from the API for A01 devices."""
def __init__(
self,
hass: HomeAssistant,
device: HomeDataDevice,
product_info: HomeDataProduct,
api: RoborockClientA01,
) -> None:
"""Initialize."""
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
self.api = api
self.device_info = DeviceInfo(
name=device.name,
identifiers={(DOMAIN, device.duid)},
manufacturer="Roborock",
model=product_info.model,
sw_version=device.fv,
)
self.request_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol] = [
RoborockDyadDataProtocol.STATUS,
RoborockDyadDataProtocol.POWER,
RoborockDyadDataProtocol.MESH_LEFT,
RoborockDyadDataProtocol.BRUSH_LEFT,
RoborockDyadDataProtocol.ERROR,
RoborockDyadDataProtocol.TOTAL_RUN_TIME,
]
self.roborock_device_info = RoborockA01HassDeviceInfo(device, product_info)
async def _async_update_data(
self,
) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType]:
return await self.api.update_values(self.request_protocols)
async def release(self) -> None:
"""Disconnect from API."""
await self.api.async_release()
@property
def duid(self) -> str:
"""Get the unique id of the device as specified by Roborock."""
return self.roborock_device_info.device.duid

View File

@ -2,6 +2,7 @@
from typing import Any from typing import Any
from roborock.api import RoborockClient
from roborock.command_cache import CacheableAttribute from roborock.command_cache import CacheableAttribute
from roborock.containers import Consumable, Status from roborock.containers import Consumable, Status
from roborock.exceptions import RoborockException from roborock.exceptions import RoborockException
@ -9,6 +10,7 @@ from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import RoborockCommand from roborock.roborock_typing import RoborockCommand
from roborock.version_1_apis.roborock_client_v1 import AttributeCache, RoborockClientV1 from roborock.version_1_apis.roborock_client_v1 import AttributeCache, RoborockClientV1
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
from roborock.version_a01_apis import RoborockClientA01
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@ -16,7 +18,7 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01
class RoborockEntity(Entity): class RoborockEntity(Entity):
@ -28,17 +30,24 @@ class RoborockEntity(Entity):
self, self,
unique_id: str, unique_id: str,
device_info: DeviceInfo, device_info: DeviceInfo,
api: RoborockClientV1, api: RoborockClient,
) -> None: ) -> None:
"""Initialize the coordinated Roborock Device.""" """Initialize the Roborock Device."""
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
self._attr_device_info = device_info self._attr_device_info = device_info
self._api = api self._api = api
@property
def api(self) -> RoborockClientV1: class RoborockEntityV1(RoborockEntity):
"""Returns the api.""" """Representation of a base Roborock V1 Entity."""
return self._api
_api: RoborockClientV1
def __init__(
self, unique_id: str, device_info: DeviceInfo, api: RoborockClientV1
) -> None:
"""Initialize the Roborock Device."""
super().__init__(unique_id, device_info, api)
def get_cache(self, attribute: CacheableAttribute) -> AttributeCache: def get_cache(self, attribute: CacheableAttribute) -> AttributeCache:
"""Get an item from the api cache.""" """Get an item from the api cache."""
@ -66,9 +75,26 @@ class RoborockEntity(Entity):
) from err ) from err
return response return response
@property
def api(self) -> RoborockClientV1:
"""Returns the api."""
return self._api
class RoborockCoordinatedEntity(
RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinator] class RoborockEntityA01(RoborockEntity):
"""Representation of a base Roborock Entity for A01 devices."""
_api: RoborockClientA01
def __init__(
self, unique_id: str, device_info: DeviceInfo, api: RoborockClientA01
) -> None:
"""Initialize the Roborock Device."""
super().__init__(unique_id, device_info, api)
class RoborockCoordinatedEntityV1(
RoborockEntityV1, CoordinatorEntity[RoborockDataUpdateCoordinator]
): ):
"""Representation of a base a coordinated Roborock Entity.""" """Representation of a base a coordinated Roborock Entity."""
@ -83,7 +109,7 @@ class RoborockCoordinatedEntity(
| None = None, | None = None,
) -> None: ) -> None:
"""Initialize the coordinated Roborock Device.""" """Initialize the coordinated Roborock Device."""
RoborockEntity.__init__( RoborockEntityV1.__init__(
self, self,
unique_id=unique_id, unique_id=unique_id,
device_info=coordinator.device_info, device_info=coordinator.device_info,
@ -138,3 +164,24 @@ class RoborockCoordinatedEntity(
self.coordinator.roborock_device_info.props.consumable = value self.coordinator.roborock_device_info.props.consumable = value
self.coordinator.data = self.coordinator.roborock_device_info.props self.coordinator.data = self.coordinator.roborock_device_info.props
self.schedule_update_ha_state() self.schedule_update_ha_state()
class RoborockCoordinatedEntityA01(
RoborockEntityA01, CoordinatorEntity[RoborockDataUpdateCoordinatorA01]
):
"""Representation of a base a coordinated Roborock Entity."""
def __init__(
self,
unique_id: str,
coordinator: RoborockDataUpdateCoordinatorA01,
) -> None:
"""Initialize the coordinated Roborock Device."""
RoborockEntityA01.__init__(
self,
unique_id=unique_id,
device_info=coordinator.device_info,
api=coordinator.api,
)
CoordinatorEntity.__init__(self, coordinator=coordinator)
self._attr_unique_id = unique_id

View File

@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_UNIQUE_ID from homeassistant.const import CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import RoborockCoordinators
from .const import DOMAIN from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator
TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"] TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"]
@ -21,9 +21,7 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id
]
return { return {
"config_entry": async_redact_data(config_entry.data, TO_REDACT_CONFIG), "config_entry": async_redact_data(config_entry.data, TO_REDACT_CONFIG),

View File

@ -21,9 +21,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify from homeassistant.util import slugify
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from . import RoborockCoordinators
from .const import DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_SLEEP from .const import DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_SLEEP
from .coordinator import RoborockDataUpdateCoordinator from .coordinator import RoborockDataUpdateCoordinator
from .device import RoborockCoordinatedEntity from .device import RoborockCoordinatedEntityV1
async def async_setup_entry( async def async_setup_entry(
@ -33,9 +34,7 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Roborock image platform.""" """Set up Roborock image platform."""
coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id
]
drawables = [ drawables = [
drawable drawable
for drawable, default_value in DEFAULT_DRAWABLES.items() for drawable, default_value in DEFAULT_DRAWABLES.items()
@ -46,7 +45,7 @@ async def async_setup_entry(
await asyncio.gather( await asyncio.gather(
*( *(
create_coordinator_maps(coord, drawables) create_coordinator_maps(coord, drawables)
for coord in coordinators.values() for coord in coordinators.v1
) )
) )
) )
@ -54,7 +53,7 @@ async def async_setup_entry(
async_add_entities(entities) async_add_entities(entities)
class RoborockMap(RoborockCoordinatedEntity, ImageEntity): class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
"""A class to let you visualize the map.""" """A class to let you visualize the map."""
_attr_has_entity_name = True _attr_has_entity_name = True
@ -70,7 +69,7 @@ class RoborockMap(RoborockCoordinatedEntity, ImageEntity):
drawables: list[Drawable], drawables: list[Drawable],
) -> None: ) -> None:
"""Initialize a Roborock map.""" """Initialize a Roborock map."""
RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator)
ImageEntity.__init__(self, coordinator.hass) ImageEntity.__init__(self, coordinator.hass)
self._attr_name = map_name self._attr_name = map_name
self.parser = RoborockMapDataParser( self.parser = RoborockMapDataParser(
@ -184,7 +183,7 @@ async def create_coordinator_maps(
api_data: bytes = map_update[0] if isinstance(map_update[0], bytes) else b"" api_data: bytes = map_update[0] if isinstance(map_update[0], bytes) else b""
entities.append( entities.append(
RoborockMap( RoborockMap(
f"{slugify(coord.roborock_device_info.device.duid)}_map_{map_info.name}", f"{slugify(coord.duid)}_map_{map_info.name}",
coord, coord,
map_flag, map_flag,
api_data, api_data,

View File

@ -26,6 +26,21 @@ class RoborockHassDeviceInfo:
} }
@dataclass
class RoborockA01HassDeviceInfo:
"""A model to describe A01 roborock devices."""
device: HomeDataDevice
product: HomeDataProduct
def as_dict(self) -> dict[str, dict[str, Any]]:
"""Turn RoborockA01HassDeviceInfo into a dictionary."""
return {
"device": self.device.as_dict(),
"product": self.product.as_dict(),
}
@dataclass @dataclass
class RoborockMapInfo: class RoborockMapInfo:
"""A model to describe all information about a map we may want.""" """A model to describe all information about a map we may want."""

View File

@ -17,9 +17,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify from homeassistant.util import slugify
from . import RoborockCoordinators
from .const import DOMAIN from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator from .coordinator import RoborockDataUpdateCoordinator
from .device import RoborockEntity from .device import RoborockEntityV1
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -54,14 +55,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Roborock number platform.""" """Set up Roborock number platform."""
coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id
]
possible_entities: list[ possible_entities: list[
tuple[RoborockDataUpdateCoordinator, RoborockNumberDescription] tuple[RoborockDataUpdateCoordinator, RoborockNumberDescription]
] = [ ] = [
(coordinator, description) (coordinator, description)
for coordinator in coordinators.values() for coordinator in coordinators.v1
for description in NUMBER_DESCRIPTIONS for description in NUMBER_DESCRIPTIONS
] ]
# We need to check if this function is supported by the device. # We need to check if this function is supported by the device.
@ -81,7 +80,7 @@ async def async_setup_entry(
else: else:
valid_entities.append( valid_entities.append(
RoborockNumberEntity( RoborockNumberEntity(
f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", f"{description.key}_{slugify(coordinator.duid)}",
coordinator, coordinator,
description, description,
) )
@ -89,7 +88,7 @@ async def async_setup_entry(
async_add_entities(valid_entities) async_add_entities(valid_entities)
class RoborockNumberEntity(RoborockEntity, NumberEntity): class RoborockNumberEntity(RoborockEntityV1, NumberEntity):
"""A class to let you set options on a Roborock vacuum where the potential options are fixed.""" """A class to let you set options on a Roborock vacuum where the potential options are fixed."""
entity_description: RoborockNumberDescription entity_description: RoborockNumberDescription

View File

@ -14,9 +14,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify from homeassistant.util import slugify
from . import RoborockCoordinators
from .const import DOMAIN from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator from .coordinator import RoborockDataUpdateCoordinator
from .device import RoborockCoordinatedEntity from .device import RoborockCoordinatedEntityV1
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@ -69,14 +70,10 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Roborock select platform.""" """Set up Roborock select platform."""
coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id
]
async_add_entities( async_add_entities(
RoborockSelectEntity( RoborockSelectEntity(coordinator, description, options)
f"{description.key}_{slugify(device_id)}", coordinator, description, options for coordinator in coordinators.v1
)
for device_id, coordinator in coordinators.items()
for description in SELECT_DESCRIPTIONS for description in SELECT_DESCRIPTIONS
if ( if (
options := description.options_lambda( options := description.options_lambda(
@ -87,21 +84,24 @@ async def async_setup_entry(
) )
class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity): class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
"""A class to let you set options on a Roborock vacuum where the potential options are fixed.""" """A class to let you set options on a Roborock vacuum where the potential options are fixed."""
entity_description: RoborockSelectDescription entity_description: RoborockSelectDescription
def __init__( def __init__(
self, self,
unique_id: str,
coordinator: RoborockDataUpdateCoordinator, coordinator: RoborockDataUpdateCoordinator,
entity_description: RoborockSelectDescription, entity_description: RoborockSelectDescription,
options: list[str], options: list[str],
) -> None: ) -> None:
"""Create a select entity.""" """Create a select entity."""
self.entity_description = entity_description self.entity_description = entity_description
super().__init__(unique_id, coordinator, entity_description.protocol_listener) super().__init__(
f"{entity_description.key}_{slugify(coordinator.duid)}",
coordinator,
entity_description.protocol_listener,
)
self._attr_options = options self._attr_options = options
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:

View File

@ -6,13 +6,14 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import datetime import datetime
from roborock.code_mappings import DyadError, RoborockDyadStateCode
from roborock.containers import ( from roborock.containers import (
RoborockDockErrorCode, RoborockDockErrorCode,
RoborockDockTypeCode, RoborockDockTypeCode,
RoborockErrorCode, RoborockErrorCode,
RoborockStateCode, RoborockStateCode,
) )
from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_message import RoborockDataProtocol, RoborockDyadDataProtocol
from roborock.roborock_typing import DeviceProp from roborock.roborock_typing import DeviceProp
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@ -32,9 +33,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.util import slugify from homeassistant.util import slugify
from . import RoborockCoordinators
from .const import DOMAIN from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01
from .device import RoborockCoordinatedEntity from .device import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@ -46,6 +48,13 @@ class RoborockSensorDescription(SensorEntityDescription):
protocol_listener: RoborockDataProtocol | None = None protocol_listener: RoborockDataProtocol | None = None
@dataclass(frozen=True, kw_only=True)
class RoborockSensorDescriptionA01(SensorEntityDescription):
"""A class that describes Roborock sensors."""
data_protocol: RoborockDyadDataProtocol
def _dock_error_value_fn(properties: DeviceProp) -> str | None: def _dock_error_value_fn(properties: DeviceProp) -> str | None:
if ( if (
status := properties.status.dock_error_status status := properties.status.dock_error_status
@ -193,41 +202,101 @@ SENSOR_DESCRIPTIONS = [
] ]
A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
RoborockSensorDescriptionA01(
key="status",
data_protocol=RoborockDyadDataProtocol.STATUS,
translation_key="a01_status",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=RoborockDyadStateCode.keys(),
),
RoborockSensorDescriptionA01(
key="battery",
data_protocol=RoborockDyadDataProtocol.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
),
RoborockSensorDescriptionA01(
key="filter_time_left",
data_protocol=RoborockDyadDataProtocol.MESH_LEFT,
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
translation_key="filter_time_left",
entity_category=EntityCategory.DIAGNOSTIC,
),
RoborockSensorDescriptionA01(
key="brush_remaining",
data_protocol=RoborockDyadDataProtocol.BRUSH_LEFT,
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
translation_key="brush_remaining",
entity_category=EntityCategory.DIAGNOSTIC,
),
RoborockSensorDescriptionA01(
key="error",
data_protocol=RoborockDyadDataProtocol.ERROR,
device_class=SensorDeviceClass.ENUM,
translation_key="a01_error",
entity_category=EntityCategory.DIAGNOSTIC,
options=DyadError.keys(),
),
RoborockSensorDescriptionA01(
key="total_cleaning_time",
native_unit_of_measurement=UnitOfTime.MINUTES,
data_protocol=RoborockDyadDataProtocol.TOTAL_RUN_TIME,
device_class=SensorDeviceClass.DURATION,
translation_key="total_cleaning_time",
entity_category=EntityCategory.DIAGNOSTIC,
),
]
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Roborock vacuum sensors.""" """Set up the Roborock vacuum sensors."""
coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id
]
async_add_entities( async_add_entities(
RoborockSensorEntity( RoborockSensorEntity(
f"{description.key}_{slugify(device_id)}",
coordinator, coordinator,
description, description,
) )
for device_id, coordinator in coordinators.items() for coordinator in coordinators.v1
for description in SENSOR_DESCRIPTIONS for description in SENSOR_DESCRIPTIONS
if description.value_fn(coordinator.roborock_device_info.props) is not None if description.value_fn(coordinator.roborock_device_info.props) is not None
) )
async_add_entities(
RoborockSensorEntityA01(
coordinator,
description,
)
for coordinator in coordinators.a01
for description in A01_SENSOR_DESCRIPTIONS
if description.data_protocol in coordinator.data
)
class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity):
"""Representation of a Roborock sensor.""" """Representation of a Roborock sensor."""
entity_description: RoborockSensorDescription entity_description: RoborockSensorDescription
def __init__( def __init__(
self, self,
unique_id: str,
coordinator: RoborockDataUpdateCoordinator, coordinator: RoborockDataUpdateCoordinator,
description: RoborockSensorDescription, description: RoborockSensorDescription,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
self.entity_description = description self.entity_description = description
super().__init__(unique_id, coordinator, description.protocol_listener) super().__init__(
f"{description.key}_{slugify(coordinator.duid)}",
coordinator,
description.protocol_listener,
)
@property @property
def native_value(self) -> StateType | datetime.datetime: def native_value(self) -> StateType | datetime.datetime:
@ -235,3 +304,23 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity):
return self.entity_description.value_fn( return self.entity_description.value_fn(
self.coordinator.roborock_device_info.props self.coordinator.roborock_device_info.props
) )
class RoborockSensorEntityA01(RoborockCoordinatedEntityA01, SensorEntity):
"""Representation of a A01 Roborock sensor."""
entity_description: RoborockSensorDescriptionA01
def __init__(
self,
coordinator: RoborockDataUpdateCoordinatorA01,
description: RoborockSensorDescriptionA01,
) -> None:
"""Initialize the entity."""
self.entity_description = description
super().__init__(f"{description.key}_{slugify(coordinator.duid)}", coordinator)
@property
def native_value(self) -> StateType:
"""Return the value reported by the sensor."""
return self.coordinator.data[self.entity_description.data_protocol]

View File

@ -95,6 +95,54 @@
} }
}, },
"sensor": { "sensor": {
"a01_error": {
"name": "Error",
"state": {
"none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]",
"dirty_tank_full": "Dirty tank full",
"water_level_sensor_stuck": "Water level sensor stuck.",
"clean_tank_empty": "Clean tank empty",
"clean_head_entangled": "Cleaning head entangled",
"clean_head_too_hot": "Cleaning head too hot.",
"fan_protection_e5": "Fan protection",
"cleaning_head_blocked": "Cleaning head blocked",
"temperature_protection": "Temperature protection",
"fan_protection_e4": "[%key:component::roborock::entity::sensor::a01_error::state::fan_protection_e5%]",
"fan_protection_e9": "[%key:component::roborock::entity::sensor::a01_error::state::fan_protection_e5%]",
"battery_temperature_protection_e0": "[%key:component::roborock::entity::sensor::a01_error::state::temperature_protection%]",
"battery_temperature_protection": "Battery temperature protection",
"battery_temperature_protection_2": "[%key:component::roborock::entity::sensor::a01_error::state::battery_temperature_protection%]",
"power_adapter_error": "Power adapter error",
"dirty_charging_contacts": "Clean charging contacts",
"low_battery": "[%key:component::roborock::entity::sensor::vacuum_error::state::low_battery%]",
"battery_under_10": "Battery under 10%"
}
},
"a01_status": {
"name": "Status",
"state": {
"unknown": "[%key:component::roborock::entity::sensor::status::state::unknown%]",
"fetching": "Fetching",
"fetch_failed": "Fetch failed",
"updating": "[%key:component::roborock::entity::sensor::status::state::updating%]",
"washing": "Washing",
"ready": "Ready",
"charging": "[%key:component::roborock::entity::sensor::status::state::charging%]",
"mop_washing": "Washing mop",
"self_clean_cleaning": "Self clean cleaning",
"self_clean_deep_cleaning": "Self clean deep cleaning",
"self_clean_rinsing": "Self clean rinsing",
"self_clean_dehydrating": "Self clean drying",
"drying": "Drying",
"ventilating": "Ventilating",
"reserving": "Reserving",
"mop_washing_paused": "Mop washing paused",
"dusting_mode": "Dusting mode"
}
},
"brush_remaining": {
"name": "Roller left"
},
"cleaning_area": { "cleaning_area": {
"name": "Cleaning area" "name": "Cleaning area"
}, },

View File

@ -18,9 +18,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify from homeassistant.util import slugify
from . import RoborockCoordinators
from .const import DOMAIN from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator from .coordinator import RoborockDataUpdateCoordinator
from .device import RoborockEntity from .device import RoborockEntityV1
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -102,14 +103,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Roborock switch platform.""" """Set up Roborock switch platform."""
coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id
]
possible_entities: list[ possible_entities: list[
tuple[RoborockDataUpdateCoordinator, RoborockSwitchDescription] tuple[RoborockDataUpdateCoordinator, RoborockSwitchDescription]
] = [ ] = [
(coordinator, description) (coordinator, description)
for coordinator in coordinators.values() for coordinator in coordinators.v1
for description in SWITCH_DESCRIPTIONS for description in SWITCH_DESCRIPTIONS
] ]
# We need to check if this function is supported by the device. # We need to check if this function is supported by the device.
@ -129,7 +128,7 @@ async def async_setup_entry(
else: else:
valid_entities.append( valid_entities.append(
RoborockSwitch( RoborockSwitch(
f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", f"{description.key}_{slugify(coordinator.duid)}",
coordinator, coordinator,
description, description,
) )
@ -137,7 +136,7 @@ async def async_setup_entry(
async_add_entities(valid_entities) async_add_entities(valid_entities)
class RoborockSwitch(RoborockEntity, SwitchEntity): class RoborockSwitch(RoborockEntityV1, SwitchEntity):
"""A class to let you turn functionality on Roborock devices on and off that does need a coordinator.""" """A class to let you turn functionality on Roborock devices on and off that does need a coordinator."""
entity_description: RoborockSwitchDescription entity_description: RoborockSwitchDescription

View File

@ -19,9 +19,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify from homeassistant.util import slugify
from . import RoborockCoordinators
from .const import DOMAIN from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator from .coordinator import RoborockDataUpdateCoordinator
from .device import RoborockEntity from .device import RoborockEntityV1
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -118,14 +119,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Roborock time platform.""" """Set up Roborock time platform."""
coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id
]
possible_entities: list[ possible_entities: list[
tuple[RoborockDataUpdateCoordinator, RoborockTimeDescription] tuple[RoborockDataUpdateCoordinator, RoborockTimeDescription]
] = [ ] = [
(coordinator, description) (coordinator, description)
for coordinator in coordinators.values() for coordinator in coordinators.v1
for description in TIME_DESCRIPTIONS for description in TIME_DESCRIPTIONS
] ]
# We need to check if this function is supported by the device. # We need to check if this function is supported by the device.
@ -145,7 +144,7 @@ async def async_setup_entry(
else: else:
valid_entities.append( valid_entities.append(
RoborockTimeEntity( RoborockTimeEntity(
f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", f"{description.key}_{slugify(coordinator.duid)}",
coordinator, coordinator,
description, description,
) )
@ -153,7 +152,7 @@ async def async_setup_entry(
async_add_entities(valid_entities) async_add_entities(valid_entities)
class RoborockTimeEntity(RoborockEntity, TimeEntity): class RoborockTimeEntity(RoborockEntityV1, TimeEntity):
"""A class to let you set options on a Roborock vacuum where the potential options are fixed.""" """A class to let you set options on a Roborock vacuum where the potential options are fixed."""
entity_description: RoborockTimeDescription entity_description: RoborockTimeDescription

View File

@ -23,9 +23,10 @@ from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify from homeassistant.util import slugify
from . import RoborockCoordinators
from .const import DOMAIN, GET_MAPS_SERVICE_NAME from .const import DOMAIN, GET_MAPS_SERVICE_NAME
from .coordinator import RoborockDataUpdateCoordinator from .coordinator import RoborockDataUpdateCoordinator
from .device import RoborockCoordinatedEntity from .device import RoborockCoordinatedEntityV1
STATE_CODE_TO_STATE = { STATE_CODE_TO_STATE = {
RoborockStateCode.starting: STATE_IDLE, # "Starting" RoborockStateCode.starting: STATE_IDLE, # "Starting"
@ -60,12 +61,11 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Roborock sensor.""" """Set up the Roborock sensor."""
coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ coordinators: RoborockCoordinators = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id
]
async_add_entities( async_add_entities(
RoborockVacuum(slugify(device_id), coordinator) RoborockVacuum(coordinator)
for device_id, coordinator in coordinators.items() for coordinator in coordinators.v1
if isinstance(coordinator, RoborockDataUpdateCoordinator)
) )
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
@ -78,7 +78,7 @@ async def async_setup_entry(
) )
class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
"""General Representation of a Roborock vacuum.""" """General Representation of a Roborock vacuum."""
_attr_icon = "mdi:robot-vacuum" _attr_icon = "mdi:robot-vacuum"
@ -99,14 +99,13 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity):
def __init__( def __init__(
self, self,
unique_id: str,
coordinator: RoborockDataUpdateCoordinator, coordinator: RoborockDataUpdateCoordinator,
) -> None: ) -> None:
"""Initialize a vacuum.""" """Initialize a vacuum."""
StateVacuumEntity.__init__(self) StateVacuumEntity.__init__(self)
RoborockCoordinatedEntity.__init__( RoborockCoordinatedEntityV1.__init__(
self, self,
unique_id, slugify(coordinator.duid),
coordinator, coordinator,
listener_request=[ listener_request=[
RoborockDataProtocol.FAN_POWER, RoborockDataProtocol.FAN_POWER,

View File

@ -1,9 +1,13 @@
"""Global fixtures for Roborock integration.""" """Global fixtures for Roborock integration."""
from copy import deepcopy
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from roborock import RoomMapping from roborock import RoomMapping
from roborock.code_mappings import DyadError, RoborockDyadStateCode
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
from roborock.version_a01_apis import RoborockMqttClientA01
from homeassistant.components.roborock.const import ( from homeassistant.components.roborock.const import (
CONF_BASE_URL, CONF_BASE_URL,
@ -28,6 +32,28 @@ from .mock_data import (
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
class A01Mock(RoborockMqttClientA01):
"""A class to mock the A01 client."""
def __init__(self, user_data, device_info, category) -> None:
"""Initialize the A01Mock."""
super().__init__(user_data, device_info, category)
self.protocol_responses = {
RoborockDyadDataProtocol.STATUS: RoborockDyadStateCode.drying.name,
RoborockDyadDataProtocol.POWER: 100,
RoborockDyadDataProtocol.MESH_LEFT: 111,
RoborockDyadDataProtocol.BRUSH_LEFT: 222,
RoborockDyadDataProtocol.ERROR: DyadError.none.name,
RoborockDyadDataProtocol.TOTAL_RUN_TIME: 213,
}
async def update_values(
self, dyad_data_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol]
):
"""Update values with a predetermined response that can be overridden."""
return {prot: self.protocol_responses[prot] for prot in dyad_data_protocols}
@pytest.fixture(name="bypass_api_fixture") @pytest.fixture(name="bypass_api_fixture")
def bypass_api_fixture() -> None: def bypass_api_fixture() -> None:
"""Skip calls to the API.""" """Skip calls to the API."""
@ -35,7 +61,7 @@ def bypass_api_fixture() -> None:
patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"),
patch("homeassistant.components.roborock.RoborockMqttClientV1._send_command"), patch("homeassistant.components.roborock.RoborockMqttClientV1._send_command"),
patch( patch(
"homeassistant.components.roborock.RoborockApiClient.get_home_data", "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2",
return_value=HOME_DATA, return_value=HOME_DATA,
), ),
patch( patch(
@ -95,6 +121,23 @@ def bypass_api_fixture() -> None:
"homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1",
return_value=b"123", return_value=b"123",
), ),
patch(
"homeassistant.components.roborock.coordinator.RoborockClientA01",
A01Mock,
),
patch("homeassistant.components.roborock.RoborockMqttClientA01", A01Mock),
):
yield
@pytest.fixture
def bypass_api_fixture_v1_only(bypass_api_fixture) -> None:
"""Bypass api for tests that require only having v1 devices."""
home_data_copy = deepcopy(HOME_DATA)
home_data_copy.received_devices = []
with patch(
"homeassistant.components.roborock.RoborockApiClient.get_home_data_v2",
return_value=home_data_copy,
): ):
yield yield

View File

@ -588,6 +588,369 @@
}), }),
}), }),
}), }),
'**REDACTED-2**': dict({
'api': dict({
'misc_info': dict({
}),
}),
'roborock_device_info': dict({
'device': dict({
'activeTime': 1700754026,
'deviceStatus': dict({
'10001': '{"f":"t"}',
'10002': '',
'10004': '{"sid_in_use":25,"sid_version":5,"location":"de","bom":"A.03.0291","language":"en"}',
'10005': '{"sn":"dyad_sn","ssid":"dyad_ssid","timezone":"Europe/Stockholm","posix_timezone":"CET-1CEST,M3.5.0,M10.5.0/3","ip":"1.123.12.1","mac":"b0:4a:33:33:33:33","oba":{"language":"en","name":"A.03.0291_CE","bom":"A.03.0291","location":"de","wifiplan":"EU","timezone":"CET-1CEST,M3.5.0,M10.5.0/3;Europe/Berlin","logserver":"awsde0","featureset":"0"}"}',
'10007': '{"mqttOtaData":{"mqttOtaStatus":{"status":"IDLE"}}}',
'200': 0,
'201': 3,
'202': 0,
'203': 2,
'204': 1,
'205': 1,
'206': 3,
'207': 4,
'208': 1,
'209': 100,
'210': 0,
'212': 1,
'213': 1,
'214': 513,
'215': 513,
'216': 0,
'221': 100,
'222': 0,
'223': 2,
'224': 1,
'225': 360,
'226': 0,
'227': 1320,
'228': 360,
'229': '000,000,003,000,005,000,000,000,003,000,005,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,012,003,000,000',
'230': 352,
'235': 0,
'237': 0,
}),
'duid': '**REDACTED**',
'f': False,
'fv': '01.12.34',
'iconUrl': '',
'localKey': '**REDACTED**',
'name': 'Dyad Pro',
'online': True,
'productId': 'dyad_product',
'pv': 'A01',
'share': True,
'shareTime': 1701367095,
'silentOtaSwitch': False,
'timeZoneId': 'Europe/Stockholm',
'tuyaMigrated': False,
}),
'product': dict({
'capability': 2,
'category': 'roborock.wetdryvac',
'id': 'dyad_product',
'model': 'roborock.wetdryvac.a56',
'name': 'Roborock Dyad Pro',
'schema': list([
dict({
'code': 'drying_status',
'id': '134',
'mode': 'ro',
'name': '烘干状态',
'type': 'RAW',
}),
dict({
'code': 'start',
'id': '200',
'mode': 'rw',
'name': '启停',
'type': 'VALUE',
}),
dict({
'code': 'status',
'id': '201',
'mode': 'ro',
'name': '状态',
'type': 'VALUE',
}),
dict({
'code': 'self_clean_mode',
'id': '202',
'mode': 'rw',
'name': '自清洁模式',
'type': 'VALUE',
}),
dict({
'code': 'self_clean_level',
'id': '203',
'mode': 'rw',
'name': '自清洁强度',
'type': 'VALUE',
}),
dict({
'code': 'warm_level',
'id': '204',
'mode': 'rw',
'name': '烘干强度',
'type': 'VALUE',
}),
dict({
'code': 'clean_mode',
'id': '205',
'mode': 'rw',
'name': '洗地模式',
'type': 'VALUE',
}),
dict({
'code': 'suction',
'id': '206',
'mode': 'rw',
'name': '吸力',
'type': 'VALUE',
}),
dict({
'code': 'water_level',
'id': '207',
'mode': 'rw',
'name': '水量',
'type': 'VALUE',
}),
dict({
'code': 'brush_speed',
'id': '208',
'mode': 'rw',
'name': '滚刷转速',
'type': 'VALUE',
}),
dict({
'code': 'power',
'id': '209',
'mode': 'ro',
'name': '电量',
'type': 'VALUE',
}),
dict({
'code': 'countdown_time',
'id': '210',
'mode': 'rw',
'name': '预约时间',
'type': 'VALUE',
}),
dict({
'code': 'auto_self_clean_set',
'id': '212',
'mode': 'rw',
'name': '自动自清洁',
'type': 'VALUE',
}),
dict({
'code': 'auto_dry',
'id': '213',
'mode': 'rw',
'name': '自动烘干',
'type': 'VALUE',
}),
dict({
'code': 'mesh_left',
'id': '214',
'mode': 'ro',
'name': '滤网已工作时间',
'type': 'VALUE',
}),
dict({
'code': 'brush_left',
'id': '215',
'mode': 'ro',
'name': '滚刷已工作时间',
'type': 'VALUE',
}),
dict({
'code': 'error',
'id': '216',
'mode': 'ro',
'name': '错误值',
'type': 'VALUE',
}),
dict({
'code': 'mesh_reset',
'id': '218',
'mode': 'rw',
'name': '滤网重置',
'type': 'VALUE',
}),
dict({
'code': 'brush_reset',
'id': '219',
'mode': 'rw',
'name': '滚刷重置',
'type': 'VALUE',
}),
dict({
'code': 'volume_set',
'id': '221',
'mode': 'rw',
'name': '音量',
'type': 'VALUE',
}),
dict({
'code': 'stand_lock_auto_run',
'id': '222',
'mode': 'rw',
'name': '直立解锁自动运行开关',
'type': 'VALUE',
}),
dict({
'code': 'auto_self_clean_set_mode',
'id': '223',
'mode': 'rw',
'name': '自动自清洁 - 模式',
'type': 'VALUE',
}),
dict({
'code': 'auto_dry_mode',
'id': '224',
'mode': 'rw',
'name': '自动烘干 - 模式',
'type': 'VALUE',
}),
dict({
'code': 'silent_dry_duration',
'id': '225',
'mode': 'rw',
'name': '静音烘干时长',
'type': 'VALUE',
}),
dict({
'code': 'silent_mode',
'id': '226',
'mode': 'rw',
'name': '勿扰模式开关',
'type': 'VALUE',
}),
dict({
'code': 'silent_mode_start_time',
'id': '227',
'mode': 'rw',
'name': '勿扰开启时间',
'type': 'VALUE',
}),
dict({
'code': 'silent_mode_end_time',
'id': '228',
'mode': 'rw',
'name': '勿扰结束时间',
'type': 'VALUE',
}),
dict({
'code': 'recent_run_time',
'id': '229',
'mode': 'rw',
'name': '近30天每天洗地时长',
'type': 'STRING',
}),
dict({
'code': 'total_run_time',
'id': '230',
'mode': 'rw',
'name': '洗地总时长',
'type': 'VALUE',
}),
dict({
'code': 'feature_info',
'id': '235',
'mode': 'ro',
'name': 'featureinfo',
'type': 'VALUE',
}),
dict({
'code': 'recover_settings',
'id': '236',
'mode': 'rw',
'name': '恢复初始设置',
'type': 'VALUE',
}),
dict({
'code': 'dry_countdown',
'id': '237',
'mode': 'ro',
'name': '烘干倒计时',
'type': 'VALUE',
}),
dict({
'code': 'id_query',
'id': '10000',
'mode': 'rw',
'name': 'ID点数据查询',
'type': 'STRING',
}),
dict({
'code': 'f_c',
'id': '10001',
'mode': 'ro',
'name': '防串货',
'type': 'STRING',
}),
dict({
'code': 'schedule_task',
'id': '10002',
'mode': 'rw',
'name': '定时任务',
'type': 'STRING',
}),
dict({
'code': 'snd_switch',
'id': '10003',
'mode': 'rw',
'name': '语音包切换',
'type': 'STRING',
}),
dict({
'code': 'snd_state',
'id': '10004',
'mode': 'rw',
'name': '语音包/OBA信息',
'type': 'STRING',
}),
dict({
'code': 'product_info',
'id': '10005',
'mode': 'ro',
'name': '产品信息',
'type': 'STRING',
}),
dict({
'code': 'privacy_info',
'id': '10006',
'mode': 'rw',
'name': '隐私协议',
'type': 'STRING',
}),
dict({
'code': 'ota_nfo',
'id': '10007',
'mode': 'ro',
'name': 'OTA info',
'type': 'STRING',
}),
dict({
'code': 'rpc_req',
'id': '10101',
'mode': 'wo',
'name': 'rpc req',
'type': 'STRING',
}),
dict({
'code': 'rpc_resp',
'id': '10102',
'mode': 'ro',
'name': 'rpc resp',
'type': 'STRING',
}),
]),
}),
}),
}),
}), }),
}) })
# --- # ---

View File

@ -1,7 +1,9 @@
"""Test for Roborock init.""" """Test for Roborock init."""
from copy import deepcopy
from unittest.mock import patch from unittest.mock import patch
import pytest
from roborock import RoborockException, RoborockInvalidCredentials from roborock import RoborockException, RoborockInvalidCredentials
from homeassistant.components.roborock.const import DOMAIN from homeassistant.components.roborock.const import DOMAIN
@ -9,6 +11,8 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .mock_data import HOME_DATA
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -34,7 +38,7 @@ async def test_config_entry_not_ready(
"""Test that when coordinator update fails, entry retries.""" """Test that when coordinator update fails, entry retries."""
with ( with (
patch( patch(
"homeassistant.components.roborock.RoborockApiClient.get_home_data", "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2",
), ),
patch( patch(
"homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop",
@ -51,7 +55,7 @@ async def test_config_entry_not_ready_home_data(
"""Test that when we fail to get home data, entry retries.""" """Test that when we fail to get home data, entry retries."""
with ( with (
patch( patch(
"homeassistant.components.roborock.RoborockApiClient.get_home_data", "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2",
side_effect=RoborockException(), side_effect=RoborockException(),
), ),
patch( patch(
@ -64,7 +68,9 @@ async def test_config_entry_not_ready_home_data(
async def test_get_networking_fails( async def test_get_networking_fails(
hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
bypass_api_fixture_v1_only,
) -> None: ) -> None:
"""Test that when networking fails, we attempt to retry.""" """Test that when networking fails, we attempt to retry."""
with patch( with patch(
@ -76,7 +82,9 @@ async def test_get_networking_fails(
async def test_get_networking_fails_none( async def test_get_networking_fails_none(
hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
bypass_api_fixture_v1_only,
) -> None: ) -> None:
"""Test that when networking returns None, we attempt to retry.""" """Test that when networking returns None, we attempt to retry."""
with patch( with patch(
@ -88,7 +96,9 @@ async def test_get_networking_fails_none(
async def test_cloud_client_fails_props( async def test_cloud_client_fails_props(
hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
bypass_api_fixture_v1_only,
) -> None: ) -> None:
"""Test that if networking succeeds, but we can't communicate with the vacuum, we can't get props, fail.""" """Test that if networking succeeds, but we can't communicate with the vacuum, we can't get props, fail."""
with ( with (
@ -106,7 +116,9 @@ async def test_cloud_client_fails_props(
async def test_local_client_fails_props( async def test_local_client_fails_props(
hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
bypass_api_fixture_v1_only,
) -> None: ) -> None:
"""Test that if networking succeeds, but we can't communicate locally with the vacuum, we can't get props, fail.""" """Test that if networking succeeds, but we can't communicate locally with the vacuum, we can't get props, fail."""
with patch( with patch(
@ -118,7 +130,9 @@ async def test_local_client_fails_props(
async def test_fails_maps_continue( async def test_fails_maps_continue(
hass: HomeAssistant, mock_roborock_entry: MockConfigEntry, bypass_api_fixture hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
bypass_api_fixture_v1_only,
) -> None: ) -> None:
"""Test that if we fail to get the maps, we still setup.""" """Test that if we fail to get the maps, we still setup."""
with patch( with patch(
@ -136,7 +150,7 @@ async def test_reauth_started(
) -> None: ) -> None:
"""Test reauth flow started.""" """Test reauth flow started."""
with patch( with patch(
"homeassistant.components.roborock.RoborockApiClient.get_home_data", "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2",
side_effect=RoborockInvalidCredentials(), side_effect=RoborockInvalidCredentials(),
): ):
await async_setup_component(hass, DOMAIN, {}) await async_setup_component(hass, DOMAIN, {})
@ -145,3 +159,21 @@ async def test_reauth_started(
flows = hass.config_entries.flow.async_progress() flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1 assert len(flows) == 1
assert flows[0]["step_id"] == "reauth_confirm" assert flows[0]["step_id"] == "reauth_confirm"
async def test_not_supported_protocol(
hass: HomeAssistant,
bypass_api_fixture,
mock_roborock_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that we output a message on incorrect protocol."""
home_data_copy = deepcopy(HOME_DATA)
home_data_copy.received_devices[0].pv = "random"
with patch(
"homeassistant.components.roborock.RoborockApiClient.get_home_data_v2",
return_value=home_data_copy,
):
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert "because its protocol version random" in caplog.text

View File

@ -21,7 +21,7 @@ from tests.common import MockConfigEntry
async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None:
"""Test sensors and check test values are correctly set.""" """Test sensors and check test values are correctly set."""
assert len(hass.states.async_all("sensor")) == 28 assert len(hass.states.async_all("sensor")) == 34
assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str(
MAIN_BRUSH_REPLACE_TIME - 74382 MAIN_BRUSH_REPLACE_TIME - 74382
) )
@ -54,6 +54,12 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non
hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state
== "2023-01-01T03:43:58+00:00" == "2023-01-01T03:43:58+00:00"
) )
assert hass.states.get("sensor.dyad_pro_status").state == "drying"
assert hass.states.get("sensor.dyad_pro_battery").state == "100"
assert hass.states.get("sensor.dyad_pro_filter_time_left").state == "111"
assert hass.states.get("sensor.dyad_pro_roller_left").state == "222"
assert hass.states.get("sensor.dyad_pro_error").state == "none"
assert hass.states.get("sensor.dyad_pro_total_cleaning_time").state == "213"
async def test_listener_update( async def test_listener_update(