roborock.devices.device_manager

Module for discovering Roborock devices.

  1"""Module for discovering Roborock devices."""
  2
  3import asyncio
  4import enum
  5import logging
  6from collections.abc import Callable, Mapping
  7from dataclasses import dataclass
  8from typing import Any
  9
 10import aiohttp
 11
 12from roborock.data import (
 13    HomeData,
 14    HomeDataDevice,
 15    HomeDataProduct,
 16    RoborockCategory,
 17    UserData,
 18)
 19from roborock.devices.device import DeviceReadyCallback, RoborockDevice
 20from roborock.diagnostics import Diagnostics, redact_device_data
 21from roborock.exceptions import RoborockException
 22from roborock.map.map_parser import MapParserConfig
 23from roborock.mqtt.roborock_session import create_lazy_mqtt_session
 24from roborock.mqtt.session import MqttSession, SessionUnauthorizedHook
 25from roborock.protocol import create_mqtt_params
 26from roborock.web_api import RoborockApiClient, UserWebApiClient
 27
 28from .cache import Cache, DeviceCache, NoCache
 29from .rpc.b01_q7_channel import create_b01_q7_channel
 30from .rpc.b01_q10_channel import create_b01_q10_channel
 31from .rpc.v1_channel import create_v1_channel
 32from .traits import Trait, a01, b01, v1
 33from .transport.channel import Channel
 34from .transport.mqtt_channel import create_mqtt_channel
 35
 36_LOGGER = logging.getLogger(__name__)
 37
 38__all__ = [
 39    "create_device_manager",
 40    "UserParams",
 41    "DeviceManager",
 42]
 43
 44
 45DeviceCreator = Callable[[HomeData, HomeDataDevice, HomeDataProduct], RoborockDevice]
 46
 47
 48class DeviceVersion(enum.StrEnum):
 49    """Enum for device versions."""
 50
 51    V1 = "1.0"
 52    A01 = "A01"
 53    B01 = "B01"
 54    UNKNOWN = "unknown"
 55
 56
 57class UnsupportedDeviceError(RoborockException):
 58    """Exception raised when a device is unsupported."""
 59
 60
 61class DeviceManager:
 62    """Central manager for Roborock device discovery and connections."""
 63
 64    def __init__(
 65        self,
 66        web_api: UserWebApiClient,
 67        device_creator: DeviceCreator,
 68        mqtt_session: MqttSession,
 69        cache: Cache,
 70        diagnostics: Diagnostics,
 71    ) -> None:
 72        """Initialize the DeviceManager with user data and optional cache storage.
 73
 74        This takes ownership of the MQTT session and will close it when the manager is closed.
 75        """
 76        self._web_api = web_api
 77        self._cache = cache
 78        self._device_creator = device_creator
 79        self._devices: dict[str, RoborockDevice] = {}
 80        self._mqtt_session = mqtt_session
 81        self._diagnostics = diagnostics
 82        self._home_data: HomeData | None = None
 83
 84    async def discover_devices(self, prefer_cache: bool = True) -> list[RoborockDevice]:
 85        """Discover all devices for the logged-in user."""
 86        self._diagnostics.increment("discover_devices")
 87        cache_data = await self._cache.get()
 88        if not cache_data.home_data or not prefer_cache:
 89            _LOGGER.debug("Fetching home data (prefer_cache=%s)", prefer_cache)
 90            self._diagnostics.increment("fetch_home_data")
 91            try:
 92                cache_data.home_data = await self._web_api.get_home_data()
 93            except RoborockException as ex:
 94                if not cache_data.home_data:
 95                    raise
 96                _LOGGER.debug("Failed to fetch home data, using cached data: %s", ex)
 97            await self._cache.set(cache_data)
 98        self._home_data = cache_data.home_data
 99
100        device_products = self._home_data.device_products
101        _LOGGER.debug("Discovered %d devices", len(device_products))
102
103        # These are connected serially to avoid overwhelming the MQTT broker
104        new_devices = {}
105        start_tasks = []
106        supported_devices_counter = self._diagnostics.subkey("supported_devices")
107        unsupported_devices_counter = self._diagnostics.subkey("unsupported_devices")
108        for duid, (device, product) in device_products.items():
109            _LOGGER.debug("[%s] Discovered device %s %s", duid, product.summary_info(), device.summary_info())
110            if duid in self._devices:
111                continue
112            try:
113                new_device = self._device_creator(self._home_data, device, product)
114            except UnsupportedDeviceError:
115                _LOGGER.info("Skipping unsupported device %s %s", product.summary_info(), device.summary_info())
116                unsupported_devices_counter.increment(device.pv or "unknown")
117                continue
118            supported_devices_counter.increment(device.pv or "unknown")
119            start_tasks.append(new_device.start_connect())
120            new_devices[duid] = new_device
121
122        self._devices.update(new_devices)
123        await asyncio.gather(*start_tasks)
124        return list(self._devices.values())
125
126    async def get_device(self, duid: str) -> RoborockDevice | None:
127        """Get a specific device by DUID."""
128        return self._devices.get(duid)
129
130    async def get_devices(self) -> list[RoborockDevice]:
131        """Get all discovered devices."""
132        return list(self._devices.values())
133
134    async def close(self) -> None:
135        """Close all MQTT connections and clean up resources."""
136        tasks = [device.close() for device in self._devices.values()]
137        self._devices.clear()
138        tasks.append(self._mqtt_session.close())
139        await asyncio.gather(*tasks)
140
141    def diagnostic_data(self) -> Mapping[str, Any]:
142        """Return diagnostics information about the device manager."""
143        return {
144            "home_data": redact_device_data(self._home_data.as_dict()) if self._home_data else None,
145            "devices": [device.diagnostic_data() for device in self._devices.values()],
146            "diagnostics": self._diagnostics.as_dict(),
147        }
148
149
150@dataclass
151class UserParams:
152    """Parameters for creating a new session with Roborock devices.
153
154    These parameters include the username, user data for authentication,
155    and an optional base URL for the Roborock API. The `user_data` and `base_url`
156    parameters are obtained from `RoborockApiClient` during the login process.
157    """
158
159    username: str
160    """The username (email) used for logging in."""
161
162    user_data: UserData
163    """This is the user data containing authentication information."""
164
165    base_url: str | None = None
166    """Optional base URL for the Roborock API.
167
168    This is used to speed up connection times by avoiding the need to
169    discover the API base URL each time. If not provided, the API client
170    will attempt to discover it automatically which may take multiple requests.
171    """
172
173
174def create_web_api_wrapper(
175    user_params: UserParams,
176    *,
177    cache: Cache | None = None,
178    session: aiohttp.ClientSession | None = None,
179    unauthorized_hook: SessionUnauthorizedHook | None = None,
180) -> UserWebApiClient:
181    """Create a home data API wrapper from an existing API client."""
182
183    # Note: This will auto discover the API base URL. This can be improved
184    # by caching this next to `UserData` if needed to avoid unnecessary API calls.
185    client = RoborockApiClient(username=user_params.username, base_url=user_params.base_url, session=session)
186
187    return UserWebApiClient(client, user_params.user_data, unauthorized_hook=unauthorized_hook)
188
189
190async def create_device_manager(
191    user_params: UserParams,
192    *,
193    cache: Cache | None = None,
194    map_parser_config: MapParserConfig | None = None,
195    session: aiohttp.ClientSession | None = None,
196    ready_callback: DeviceReadyCallback | None = None,
197    mqtt_session_unauthorized_hook: SessionUnauthorizedHook | None = None,
198    prefer_cache: bool = True,
199) -> DeviceManager:
200    """Convenience function to create and initialize a DeviceManager.
201
202    Args:
203        user_params: Parameters for creating the user session.
204        cache: Optional cache implementation to use for caching device data.
205        map_parser_config: Optional configuration for parsing maps.
206        session: Optional aiohttp ClientSession to use for HTTP requests.
207        ready_callback: Optional callback to be notified when a device is ready.
208        mqtt_session_unauthorized_hook: Optional hook for MQTT session unauthorized
209          events which may indicate rate limiting or revoked credentials. The
210          caller may use this to refresh authentication tokens as needed.
211        prefer_cache: Whether to prefer cached device data over always fetching it from the API.
212
213    Returns:
214        An initialized DeviceManager with discovered devices.
215    """
216    if cache is None:
217        cache = NoCache()
218
219    web_api = create_web_api_wrapper(
220        user_params, session=session, cache=cache, unauthorized_hook=mqtt_session_unauthorized_hook
221    )
222    user_data = user_params.user_data
223
224    diagnostics = Diagnostics()
225
226    mqtt_params = create_mqtt_params(user_data.rriot)
227    mqtt_params.diagnostics = diagnostics.subkey("mqtt_session")
228    mqtt_params.unauthorized_hook = mqtt_session_unauthorized_hook
229    mqtt_session = await create_lazy_mqtt_session(mqtt_params)
230
231    def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
232        channel: Channel
233        trait: Trait
234        device_cache: DeviceCache = DeviceCache(device.duid, cache)
235        match device.pv:
236            case DeviceVersion.V1:
237                if product.category != RoborockCategory.VACUUM:
238                    raise UnsupportedDeviceError(
239                        f"Device {device.name} has unsupported V1 category {product.category}: {product.model}"
240                    )
241                channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache)
242                trait = v1.create(
243                    device.duid,
244                    product,
245                    home_data,
246                    channel.rpc_channel,
247                    channel.mqtt_rpc_channel,
248                    channel.map_rpc_channel,
249                    channel.blob_rpc_channel,
250                    channel.add_dps_listener,
251                    web_api,
252                    device_cache=device_cache,
253                    map_parser_config=map_parser_config,
254                    region=user_data.region,
255                )
256            case DeviceVersion.A01:
257                channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
258                trait = a01.create(product, channel)
259            case DeviceVersion.B01:
260                mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
261                model_part = product.model.split(".")[-1]
262                if "ss" in model_part:
263                    b01_q10_channel = create_b01_q10_channel(mqtt_channel)
264                    channel = b01_q10_channel
265                    trait = b01.q10.create(channel)
266                elif "sc" in model_part:
267                    # Q7 devices start with 'sc' in their model naming.
268                    b01_q7_channel = create_b01_q7_channel(device, product, mqtt_channel)
269                    channel = b01_q7_channel
270                    trait = b01.q7.create(
271                        product,
272                        device,
273                        rpc_channel=b01_q7_channel,
274                        map_rpc_channel=b01_q7_channel,
275                    )
276                else:
277                    raise UnsupportedDeviceError(f"Device {device.name} has unsupported B01 model: {product.model}")
278            case _:
279                raise UnsupportedDeviceError(
280                    f"Device {device.name} has unsupported version {device.pv} {product.model}"
281                )
282
283        dev = RoborockDevice(device, product, channel, trait)
284        if ready_callback:
285            dev.add_ready_callback(ready_callback)
286        return dev
287
288    manager = DeviceManager(
289        web_api,
290        device_creator,
291        mqtt_session=mqtt_session,
292        cache=cache,
293        diagnostics=diagnostics,
294    )
295    await manager.discover_devices(prefer_cache)
296    return manager
async def create_device_manager( user_params: UserParams, *, cache: roborock.devices.cache.Cache | None = None, map_parser_config: roborock.map.MapParserConfig | None = None, session: aiohttp.client.ClientSession | None = None, ready_callback: Callable[roborock.devices.device.RoborockDevice, None] | None = None, mqtt_session_unauthorized_hook: Callable[[], None] | None = None, prefer_cache: bool = True) -> DeviceManager:
191async def create_device_manager(
192    user_params: UserParams,
193    *,
194    cache: Cache | None = None,
195    map_parser_config: MapParserConfig | None = None,
196    session: aiohttp.ClientSession | None = None,
197    ready_callback: DeviceReadyCallback | None = None,
198    mqtt_session_unauthorized_hook: SessionUnauthorizedHook | None = None,
199    prefer_cache: bool = True,
200) -> DeviceManager:
201    """Convenience function to create and initialize a DeviceManager.
202
203    Args:
204        user_params: Parameters for creating the user session.
205        cache: Optional cache implementation to use for caching device data.
206        map_parser_config: Optional configuration for parsing maps.
207        session: Optional aiohttp ClientSession to use for HTTP requests.
208        ready_callback: Optional callback to be notified when a device is ready.
209        mqtt_session_unauthorized_hook: Optional hook for MQTT session unauthorized
210          events which may indicate rate limiting or revoked credentials. The
211          caller may use this to refresh authentication tokens as needed.
212        prefer_cache: Whether to prefer cached device data over always fetching it from the API.
213
214    Returns:
215        An initialized DeviceManager with discovered devices.
216    """
217    if cache is None:
218        cache = NoCache()
219
220    web_api = create_web_api_wrapper(
221        user_params, session=session, cache=cache, unauthorized_hook=mqtt_session_unauthorized_hook
222    )
223    user_data = user_params.user_data
224
225    diagnostics = Diagnostics()
226
227    mqtt_params = create_mqtt_params(user_data.rriot)
228    mqtt_params.diagnostics = diagnostics.subkey("mqtt_session")
229    mqtt_params.unauthorized_hook = mqtt_session_unauthorized_hook
230    mqtt_session = await create_lazy_mqtt_session(mqtt_params)
231
232    def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
233        channel: Channel
234        trait: Trait
235        device_cache: DeviceCache = DeviceCache(device.duid, cache)
236        match device.pv:
237            case DeviceVersion.V1:
238                if product.category != RoborockCategory.VACUUM:
239                    raise UnsupportedDeviceError(
240                        f"Device {device.name} has unsupported V1 category {product.category}: {product.model}"
241                    )
242                channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache)
243                trait = v1.create(
244                    device.duid,
245                    product,
246                    home_data,
247                    channel.rpc_channel,
248                    channel.mqtt_rpc_channel,
249                    channel.map_rpc_channel,
250                    channel.blob_rpc_channel,
251                    channel.add_dps_listener,
252                    web_api,
253                    device_cache=device_cache,
254                    map_parser_config=map_parser_config,
255                    region=user_data.region,
256                )
257            case DeviceVersion.A01:
258                channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
259                trait = a01.create(product, channel)
260            case DeviceVersion.B01:
261                mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
262                model_part = product.model.split(".")[-1]
263                if "ss" in model_part:
264                    b01_q10_channel = create_b01_q10_channel(mqtt_channel)
265                    channel = b01_q10_channel
266                    trait = b01.q10.create(channel)
267                elif "sc" in model_part:
268                    # Q7 devices start with 'sc' in their model naming.
269                    b01_q7_channel = create_b01_q7_channel(device, product, mqtt_channel)
270                    channel = b01_q7_channel
271                    trait = b01.q7.create(
272                        product,
273                        device,
274                        rpc_channel=b01_q7_channel,
275                        map_rpc_channel=b01_q7_channel,
276                    )
277                else:
278                    raise UnsupportedDeviceError(f"Device {device.name} has unsupported B01 model: {product.model}")
279            case _:
280                raise UnsupportedDeviceError(
281                    f"Device {device.name} has unsupported version {device.pv} {product.model}"
282                )
283
284        dev = RoborockDevice(device, product, channel, trait)
285        if ready_callback:
286            dev.add_ready_callback(ready_callback)
287        return dev
288
289    manager = DeviceManager(
290        web_api,
291        device_creator,
292        mqtt_session=mqtt_session,
293        cache=cache,
294        diagnostics=diagnostics,
295    )
296    await manager.discover_devices(prefer_cache)
297    return manager

Convenience function to create and initialize a DeviceManager.

Args: user_params: Parameters for creating the user session. cache: Optional cache implementation to use for caching device data. map_parser_config: Optional configuration for parsing maps. session: Optional aiohttp ClientSession to use for HTTP requests. ready_callback: Optional callback to be notified when a device is ready. mqtt_session_unauthorized_hook: Optional hook for MQTT session unauthorized events which may indicate rate limiting or revoked credentials. The caller may use this to refresh authentication tokens as needed. prefer_cache: Whether to prefer cached device data over always fetching it from the API.

Returns: An initialized DeviceManager with discovered devices.

@dataclass
class UserParams:
151@dataclass
152class UserParams:
153    """Parameters for creating a new session with Roborock devices.
154
155    These parameters include the username, user data for authentication,
156    and an optional base URL for the Roborock API. The `user_data` and `base_url`
157    parameters are obtained from `RoborockApiClient` during the login process.
158    """
159
160    username: str
161    """The username (email) used for logging in."""
162
163    user_data: UserData
164    """This is the user data containing authentication information."""
165
166    base_url: str | None = None
167    """Optional base URL for the Roborock API.
168
169    This is used to speed up connection times by avoiding the need to
170    discover the API base URL each time. If not provided, the API client
171    will attempt to discover it automatically which may take multiple requests.
172    """

Parameters for creating a new session with Roborock devices.

These parameters include the username, user data for authentication, and an optional base URL for the Roborock API. The user_data and base_url parameters are obtained from RoborockApiClient during the login process.

UserParams( username: str, user_data: roborock.data.containers.UserData, base_url: str | None = None)
username: str

The username (email) used for logging in.

This is the user data containing authentication information.

base_url: str | None = None

Optional base URL for the Roborock API.

This is used to speed up connection times by avoiding the need to discover the API base URL each time. If not provided, the API client will attempt to discover it automatically which may take multiple requests.

class DeviceManager:
 62class DeviceManager:
 63    """Central manager for Roborock device discovery and connections."""
 64
 65    def __init__(
 66        self,
 67        web_api: UserWebApiClient,
 68        device_creator: DeviceCreator,
 69        mqtt_session: MqttSession,
 70        cache: Cache,
 71        diagnostics: Diagnostics,
 72    ) -> None:
 73        """Initialize the DeviceManager with user data and optional cache storage.
 74
 75        This takes ownership of the MQTT session and will close it when the manager is closed.
 76        """
 77        self._web_api = web_api
 78        self._cache = cache
 79        self._device_creator = device_creator
 80        self._devices: dict[str, RoborockDevice] = {}
 81        self._mqtt_session = mqtt_session
 82        self._diagnostics = diagnostics
 83        self._home_data: HomeData | None = None
 84
 85    async def discover_devices(self, prefer_cache: bool = True) -> list[RoborockDevice]:
 86        """Discover all devices for the logged-in user."""
 87        self._diagnostics.increment("discover_devices")
 88        cache_data = await self._cache.get()
 89        if not cache_data.home_data or not prefer_cache:
 90            _LOGGER.debug("Fetching home data (prefer_cache=%s)", prefer_cache)
 91            self._diagnostics.increment("fetch_home_data")
 92            try:
 93                cache_data.home_data = await self._web_api.get_home_data()
 94            except RoborockException as ex:
 95                if not cache_data.home_data:
 96                    raise
 97                _LOGGER.debug("Failed to fetch home data, using cached data: %s", ex)
 98            await self._cache.set(cache_data)
 99        self._home_data = cache_data.home_data
100
101        device_products = self._home_data.device_products
102        _LOGGER.debug("Discovered %d devices", len(device_products))
103
104        # These are connected serially to avoid overwhelming the MQTT broker
105        new_devices = {}
106        start_tasks = []
107        supported_devices_counter = self._diagnostics.subkey("supported_devices")
108        unsupported_devices_counter = self._diagnostics.subkey("unsupported_devices")
109        for duid, (device, product) in device_products.items():
110            _LOGGER.debug("[%s] Discovered device %s %s", duid, product.summary_info(), device.summary_info())
111            if duid in self._devices:
112                continue
113            try:
114                new_device = self._device_creator(self._home_data, device, product)
115            except UnsupportedDeviceError:
116                _LOGGER.info("Skipping unsupported device %s %s", product.summary_info(), device.summary_info())
117                unsupported_devices_counter.increment(device.pv or "unknown")
118                continue
119            supported_devices_counter.increment(device.pv or "unknown")
120            start_tasks.append(new_device.start_connect())
121            new_devices[duid] = new_device
122
123        self._devices.update(new_devices)
124        await asyncio.gather(*start_tasks)
125        return list(self._devices.values())
126
127    async def get_device(self, duid: str) -> RoborockDevice | None:
128        """Get a specific device by DUID."""
129        return self._devices.get(duid)
130
131    async def get_devices(self) -> list[RoborockDevice]:
132        """Get all discovered devices."""
133        return list(self._devices.values())
134
135    async def close(self) -> None:
136        """Close all MQTT connections and clean up resources."""
137        tasks = [device.close() for device in self._devices.values()]
138        self._devices.clear()
139        tasks.append(self._mqtt_session.close())
140        await asyncio.gather(*tasks)
141
142    def diagnostic_data(self) -> Mapping[str, Any]:
143        """Return diagnostics information about the device manager."""
144        return {
145            "home_data": redact_device_data(self._home_data.as_dict()) if self._home_data else None,
146            "devices": [device.diagnostic_data() for device in self._devices.values()],
147            "diagnostics": self._diagnostics.as_dict(),
148        }

Central manager for Roborock device discovery and connections.

DeviceManager( web_api: roborock.web_api.UserWebApiClient, device_creator: Callable[[roborock.data.containers.HomeData, roborock.data.containers.HomeDataDevice, roborock.data.containers.HomeDataProduct], roborock.devices.device.RoborockDevice], mqtt_session: roborock.mqtt.session.MqttSession, cache: roborock.devices.cache.Cache, diagnostics: roborock.diagnostics.Diagnostics)
65    def __init__(
66        self,
67        web_api: UserWebApiClient,
68        device_creator: DeviceCreator,
69        mqtt_session: MqttSession,
70        cache: Cache,
71        diagnostics: Diagnostics,
72    ) -> None:
73        """Initialize the DeviceManager with user data and optional cache storage.
74
75        This takes ownership of the MQTT session and will close it when the manager is closed.
76        """
77        self._web_api = web_api
78        self._cache = cache
79        self._device_creator = device_creator
80        self._devices: dict[str, RoborockDevice] = {}
81        self._mqtt_session = mqtt_session
82        self._diagnostics = diagnostics
83        self._home_data: HomeData | None = None

Initialize the DeviceManager with user data and optional cache storage.

This takes ownership of the MQTT session and will close it when the manager is closed.

async def discover_devices( self, prefer_cache: bool = True) -> list[roborock.devices.device.RoborockDevice]:
 85    async def discover_devices(self, prefer_cache: bool = True) -> list[RoborockDevice]:
 86        """Discover all devices for the logged-in user."""
 87        self._diagnostics.increment("discover_devices")
 88        cache_data = await self._cache.get()
 89        if not cache_data.home_data or not prefer_cache:
 90            _LOGGER.debug("Fetching home data (prefer_cache=%s)", prefer_cache)
 91            self._diagnostics.increment("fetch_home_data")
 92            try:
 93                cache_data.home_data = await self._web_api.get_home_data()
 94            except RoborockException as ex:
 95                if not cache_data.home_data:
 96                    raise
 97                _LOGGER.debug("Failed to fetch home data, using cached data: %s", ex)
 98            await self._cache.set(cache_data)
 99        self._home_data = cache_data.home_data
100
101        device_products = self._home_data.device_products
102        _LOGGER.debug("Discovered %d devices", len(device_products))
103
104        # These are connected serially to avoid overwhelming the MQTT broker
105        new_devices = {}
106        start_tasks = []
107        supported_devices_counter = self._diagnostics.subkey("supported_devices")
108        unsupported_devices_counter = self._diagnostics.subkey("unsupported_devices")
109        for duid, (device, product) in device_products.items():
110            _LOGGER.debug("[%s] Discovered device %s %s", duid, product.summary_info(), device.summary_info())
111            if duid in self._devices:
112                continue
113            try:
114                new_device = self._device_creator(self._home_data, device, product)
115            except UnsupportedDeviceError:
116                _LOGGER.info("Skipping unsupported device %s %s", product.summary_info(), device.summary_info())
117                unsupported_devices_counter.increment(device.pv or "unknown")
118                continue
119            supported_devices_counter.increment(device.pv or "unknown")
120            start_tasks.append(new_device.start_connect())
121            new_devices[duid] = new_device
122
123        self._devices.update(new_devices)
124        await asyncio.gather(*start_tasks)
125        return list(self._devices.values())

Discover all devices for the logged-in user.

async def get_device(self, duid: str) -> roborock.devices.device.RoborockDevice | None:
127    async def get_device(self, duid: str) -> RoborockDevice | None:
128        """Get a specific device by DUID."""
129        return self._devices.get(duid)

Get a specific device by DUID.

async def get_devices(self) -> list[roborock.devices.device.RoborockDevice]:
131    async def get_devices(self) -> list[RoborockDevice]:
132        """Get all discovered devices."""
133        return list(self._devices.values())

Get all discovered devices.

async def close(self) -> None:
135    async def close(self) -> None:
136        """Close all MQTT connections and clean up resources."""
137        tasks = [device.close() for device in self._devices.values()]
138        self._devices.clear()
139        tasks.append(self._mqtt_session.close())
140        await asyncio.gather(*tasks)

Close all MQTT connections and clean up resources.

def diagnostic_data(self) -> Mapping[str, typing.Any]:
142    def diagnostic_data(self) -> Mapping[str, Any]:
143        """Return diagnostics information about the device manager."""
144        return {
145            "home_data": redact_device_data(self._home_data.as_dict()) if self._home_data else None,
146            "devices": [device.diagnostic_data() for device in self._devices.values()],
147            "diagnostics": self._diagnostics.as_dict(),
148        }

Return diagnostics information about the device manager.