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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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