Skip to content

Commit e04a215

Browse files
authored
feat: Add a DeviceManager to perform discovery (#399)
* feat: Add a DeviceManager to perform discovery * feat: Update review feedback * feat: Update tests with additional feedback * feat: Fix lint error --------- Co-authored-by: semantic-release <semantic-release>
1 parent e1a9e69 commit e04a215

File tree

10 files changed

+363
-2
lines changed

10 files changed

+363
-2
lines changed

roborock/containers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from dataclasses import asdict, dataclass, field
88
from datetime import timezone
99
from enum import Enum
10+
from functools import cached_property
1011
from typing import Any, NamedTuple, get_args, get_origin
1112

1213
from .code_mappings import (
@@ -469,6 +470,21 @@ def get_all_devices(self) -> list[HomeDataDevice]:
469470
devices += self.received_devices
470471
return devices
471472

473+
@cached_property
474+
def product_map(self) -> dict[str, HomeDataProduct]:
475+
"""Returns a dictionary of product IDs to HomeDataProduct objects."""
476+
return {product.id: product for product in self.products}
477+
478+
@cached_property
479+
def device_products(self) -> dict[str, tuple[HomeDataDevice, HomeDataProduct]]:
480+
"""Returns a dictionary of device DUIDs to HomeDataDeviceProduct objects."""
481+
product_map = self.product_map
482+
return {
483+
device.duid: (device, product)
484+
for device in self.get_all_devices()
485+
if (product := product_map.get(device.product_id)) is not None
486+
}
487+
472488

473489
@dataclass
474490
class LoginData(RoborockBase):

roborock/devices/README.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Roborock Device Discovery
2+
3+
This page documents the full lifecycle of device discovery across Cloud and Network.
4+
5+
## Init account setup
6+
7+
### Login
8+
9+
- Login can happen with either email and password or email and sending a code. We
10+
currently prefer email with sending a code -- however the roborock no longer
11+
supports this method of login. In the future we may want to migrate to password
12+
if this login method is no longer supported.
13+
- The Login API provides a `userData` object with information on connecting to the cloud APIs
14+
- This `rriot` data contains per-session information, unique each time you login.
15+
- This contains information used to connect to MQTT
16+
- You get an `-eu` suffix in the API URLs if you are in the eu and `-us` if you are in the us
17+
18+
## Home Data
19+
20+
The `HomeData` includes information about the various devices in the home. We use `v3`
21+
and it is notable that if devices don't show up in the `home_data` response it is likely
22+
that a newer version of the API should be used.
23+
24+
- `products`: This is a list of all of the products you have on your account. These objects are always the same (i.e. a s7 maxv is always the exact same.)
25+
- It only shows the products for devices available on your account
26+
- `devices` and `received_devices`:
27+
- These both share the same objects, but one is for devices that have been shared with you and one is those that are on your account.
28+
- The big things here are (MOST are static):
29+
- `duid`: A unique identifier for your device (this is always the same i think)
30+
- `name`: The name of the device in your app
31+
- `local_key`: The local key that is needed for encoding and decoding messages for the device. This stays the same unless someone sets their vacuum back up.
32+
- `pv`: the protocol version (i.e. 1.0 or A1 or B1)
33+
- `product_id`: The id of the product from the above products list.
34+
- `device_status`: An initial status for some of the data we care about, though this changes on each update.
35+
- `rooms`: The rooms in the home.
36+
- This changes if the user adds a new room or changes its name.
37+
- We have to combine this with the room numbers from `GET_ROOM_MAPPING` on the device
38+
- There is another REST request `get_rooms` that will do the same thing.
39+
- Note: If we cache home_data, we likely need to use `get_rooms` to get rooms fresh
40+
41+
## Device Connections
42+
43+
### MQTT connection
44+
45+
- Initial device information must be obtained from MQTT
46+
- We typically set up the MQTT device connection before the local device connection.
47+
- The `NetworkingInfo` needs to be fetched to get additional information about connecting to the device:
48+
- e.g. Local IP Address
49+
- This networking info can be cached to reduce network calls
50+
- MQTT also is the only way to get the device Map
51+
- Incoming and outgoing messages are decoded/encoded using the device `local_key`
52+
- Otherwise all commands may be performed locally.
53+
54+
## Local connection
55+
56+
- We can use the `ip` from the `NetworkingInfo` to find the device
57+
- The local connection is preferred to for improved latency and reducing load on the cloud servers to avoid rate limiting.
58+
- Connections are made using a normal TCP socket on port `58867`
59+
- Incoming and outgoing messages are decoded/encoded using the device `local_key`
60+
- Messages received on the stream may be partially received so we keep a running as messages are partially decoded
61+
62+
## Design
63+
64+
### Current API Issues
65+
66+
- Complex Inheritance Hierarchy: Multiple inheritance with classes like RoborockMqttClientV1 inheriting from both RoborockMqttClient and RoborockClientV1
67+
68+
- Callback-Heavy Design: Heavy reliance on callbacks and listeners in RoborockClientV1.on_message_received and the ListenerModel system
69+
70+
- Version Fragmentation: Separate v1 and A01 APIs with different patterns and abstractions
71+
72+
- Mixed Concerns: Classes handle both communication protocols (MQTT/local) and device-specific logic
73+
74+
- Complex Caching: The AttributeCache system with RepeatableTask adds complexity
75+
76+
- Manual Connection Management: Users need to manually set up both MQTT and local clients as shown in the README example
77+
78+
## Design Changes
79+
80+
- Prefer a single unfieid client that handles both MQTT and local connections internally.
81+
82+
- Home and device discovery (fetching home data and device setup) will be behind a single API.
83+
84+
- Asyncio First: Everything should be asyncio as much as possible, with fewer callbacks.
85+
86+
- The clients should be working in terms of devices. We need to detect capabilities for each device and not expose details about API versions.
87+
88+
- Reliability issues: The current Home Assistant integration has issues with reliability and needs to be simplified. It may be that there are bugs with the exception handling and it's too heavy the cloud APIs and could benefit from more seamless caching.
89+
90+
## Implementation Details
91+
92+
- We don't really need to worry about backwards compatibility for the new set of APIs.
93+
94+
- We'll have a `RoborockManager` responsible for managing the connections and getting devices.
95+
96+
- Caching can be persisted to disk. The caller can implement the cache storage themselves, but we need to give them an API to do so.
97+
98+
- Users don't really choose between cloud vs local. However, we will want to allow the caller to know if its using the locale connection so we can show a warnings.

roborock/devices/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""The devices module provides functionality to discover Roborock devices on the network."""
2+
3+
__all__ = [
4+
"device",
5+
"device_manager",
6+
]

roborock/devices/device.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Module for Roborock devices.
2+
3+
This interface is experimental and subject to breaking changes without notice
4+
until the API is stable.
5+
"""
6+
7+
import enum
8+
import logging
9+
from functools import cached_property
10+
11+
from roborock.containers import HomeDataDevice, HomeDataProduct, UserData
12+
13+
_LOGGER = logging.getLogger(__name__)
14+
15+
__all__ = [
16+
"RoborockDevice",
17+
"DeviceVersion",
18+
]
19+
20+
21+
class DeviceVersion(enum.StrEnum):
22+
"""Enum for device versions."""
23+
24+
V1 = "1.0"
25+
A01 = "A01"
26+
UNKNOWN = "unknown"
27+
28+
29+
class RoborockDevice:
30+
"""Unified Roborock device class with automatic connection setup."""
31+
32+
def __init__(self, user_data: UserData, device_info: HomeDataDevice, product_info: HomeDataProduct) -> None:
33+
"""Initialize the RoborockDevice with device info, user data, and capabilities."""
34+
self._user_data = user_data
35+
self._device_info = device_info
36+
self._product_info = product_info
37+
38+
@property
39+
def duid(self) -> str:
40+
"""Return the device unique identifier (DUID)."""
41+
return self._device_info.duid
42+
43+
@property
44+
def name(self) -> str:
45+
"""Return the device name."""
46+
return self._device_info.name
47+
48+
@cached_property
49+
def device_version(self) -> str:
50+
"""Return the device version.
51+
52+
At the moment this is a simple check against the product version (pv) of the device
53+
and used as a placeholder for upcoming functionality for devices that will behave
54+
differently based on the version and capabilities.
55+
"""
56+
if self._device_info.pv == DeviceVersion.V1.value:
57+
return DeviceVersion.V1
58+
elif self._device_info.pv == DeviceVersion.A01.value:
59+
return DeviceVersion.A01
60+
_LOGGER.warning(
61+
"Unknown device version %s for device %s, using default UNKNOWN",
62+
self._device_info.pv,
63+
self._device_info.name,
64+
)
65+
return DeviceVersion.UNKNOWN

roborock/devices/device_manager.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Module for discovering Roborock devices."""
2+
3+
import logging
4+
from collections.abc import Awaitable, Callable
5+
6+
from roborock.containers import (
7+
HomeData,
8+
HomeDataDevice,
9+
HomeDataProduct,
10+
UserData,
11+
)
12+
from roborock.devices.device import RoborockDevice
13+
from roborock.web_api import RoborockApiClient
14+
15+
_LOGGER = logging.getLogger(__name__)
16+
17+
__all__ = [
18+
"create_device_manager",
19+
"create_home_data_api",
20+
"DeviceManager",
21+
"HomeDataApi",
22+
"DeviceCreator",
23+
]
24+
25+
26+
HomeDataApi = Callable[[], Awaitable[HomeData]]
27+
DeviceCreator = Callable[[HomeDataDevice, HomeDataProduct], RoborockDevice]
28+
29+
30+
class DeviceManager:
31+
"""Central manager for Roborock device discovery and connections."""
32+
33+
def __init__(
34+
self,
35+
home_data_api: HomeDataApi,
36+
device_creator: DeviceCreator,
37+
) -> None:
38+
"""Initialize the DeviceManager with user data and optional cache storage."""
39+
self._home_data_api = home_data_api
40+
self._device_creator = device_creator
41+
self._devices: dict[str, RoborockDevice] = {}
42+
43+
async def discover_devices(self) -> list[RoborockDevice]:
44+
"""Discover all devices for the logged-in user."""
45+
home_data = await self._home_data_api()
46+
device_products = home_data.device_products
47+
_LOGGER.debug("Discovered %d devices %s", len(device_products), home_data)
48+
49+
self._devices = {
50+
duid: self._device_creator(device, product) for duid, (device, product) in device_products.items()
51+
}
52+
return list(self._devices.values())
53+
54+
async def get_device(self, duid: str) -> RoborockDevice | None:
55+
"""Get a specific device by DUID."""
56+
return self._devices.get(duid)
57+
58+
async def get_devices(self) -> list[RoborockDevice]:
59+
"""Get all discovered devices."""
60+
return list(self._devices.values())
61+
62+
63+
def create_home_data_api(email: str, user_data: UserData) -> HomeDataApi:
64+
"""Create a home data API wrapper.
65+
66+
This function creates a wrapper around the Roborock API client to fetch
67+
home data for the user.
68+
"""
69+
70+
client = RoborockApiClient(email, user_data)
71+
72+
async def home_data_api() -> HomeData:
73+
return await client.get_home_data(user_data)
74+
75+
return home_data_api
76+
77+
78+
async def create_device_manager(user_data: UserData, home_data_api: HomeDataApi) -> DeviceManager:
79+
"""Convenience function to create and initialize a DeviceManager.
80+
81+
The Home Data is fetched using the provided home_data_api callable which
82+
is exposed this way to allow for swapping out other implementations to
83+
include caching or other optimizations.
84+
"""
85+
86+
def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
87+
return RoborockDevice(user_data, device, product)
88+
89+
manager = DeviceManager(home_data_api, device_creator)
90+
await manager.discover_devices()
91+
return manager

roborock/mqtt/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@
44
modules.
55
"""
66

7+
# This module is part of the Roborock Python library, which provides a way to
8+
# interact with Roborock devices using MQTT. It is not intended to be used directly,
9+
# but rather as a base for higher level modules.
710
__all__: list[str] = []

tests/devices/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for the device module."""

tests/devices/test_device_manager.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Tests for the DeviceManager class."""
2+
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
from roborock.containers import HomeData, UserData
8+
from roborock.devices.device import DeviceVersion
9+
from roborock.devices.device_manager import create_device_manager, create_home_data_api
10+
from roborock.exceptions import RoborockException
11+
12+
from .. import mock_data
13+
14+
USER_DATA = UserData.from_dict(mock_data.USER_DATA)
15+
16+
17+
async def home_home_data_no_devices() -> HomeData:
18+
"""Mock home data API that returns no devices."""
19+
return HomeData(
20+
id=1,
21+
name="Test Home",
22+
devices=[],
23+
products=[],
24+
)
25+
26+
27+
async def mock_home_data() -> HomeData:
28+
"""Mock home data API that returns devices."""
29+
return HomeData.from_dict(mock_data.HOME_DATA_RAW)
30+
31+
32+
async def test_no_devices() -> None:
33+
"""Test the DeviceManager created with no devices returned from the API."""
34+
35+
device_manager = await create_device_manager(USER_DATA, home_home_data_no_devices)
36+
devices = await device_manager.get_devices()
37+
assert devices == []
38+
39+
40+
async def test_with_device() -> None:
41+
"""Test the DeviceManager created with devices returned from the API."""
42+
device_manager = await create_device_manager(USER_DATA, mock_home_data)
43+
devices = await device_manager.get_devices()
44+
assert len(devices) == 1
45+
assert devices[0].duid == "abc123"
46+
assert devices[0].name == "Roborock S7 MaxV"
47+
assert devices[0].device_version == DeviceVersion.V1
48+
49+
device = await device_manager.get_device("abc123")
50+
assert device is not None
51+
assert device.duid == "abc123"
52+
assert device.name == "Roborock S7 MaxV"
53+
assert device.device_version == DeviceVersion.V1
54+
55+
56+
async def test_get_non_existent_device() -> None:
57+
"""Test getting a non-existent device."""
58+
device_manager = await create_device_manager(USER_DATA, mock_home_data)
59+
device = await device_manager.get_device("non_existent_duid")
60+
assert device is None
61+
62+
63+
async def test_home_data_api_exception() -> None:
64+
"""Test the home data API with an exception."""
65+
66+
async def home_data_api_exception() -> HomeData:
67+
raise RoborockException("Test exception")
68+
69+
with pytest.raises(RoborockException, match="Test exception"):
70+
await create_device_manager(USER_DATA, home_data_api_exception)
71+
72+
73+
async def test_create_home_data_api_exception() -> None:
74+
"""Test that exceptions from the home data API are propagated through the wrapper."""
75+
76+
with patch("roborock.devices.device_manager.RoborockApiClient.get_home_data") as mock_get_home_data:
77+
mock_get_home_data.side_effect = RoborockException("Test exception")
78+
api = create_home_data_api(USER_DATA, mock_get_home_data)
79+
80+
with pytest.raises(RoborockException, match="Test exception"):
81+
await api()

tests/mock_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@
293293
"runtimeEnv": None,
294294
"timeZoneId": "America/Los_Angeles",
295295
"iconUrl": "no_url",
296-
"productId": "product123",
296+
"productId": PRODUCT_ID,
297297
"lon": None,
298298
"lat": None,
299299
"share": False,

0 commit comments

Comments
 (0)