roborock.devices.device

Module for Roborock devices.

This interface is experimental and subject to breaking changes without notice until the API is stable.

  1"""Module for Roborock devices.
  2
  3This interface is experimental and subject to breaking changes without notice
  4until the API is stable.
  5"""
  6
  7import asyncio
  8import datetime
  9import logging
 10from abc import ABC
 11from collections.abc import Callable
 12from typing import Any
 13
 14from roborock.callbacks import CallbackList
 15from roborock.data import HomeDataDevice, HomeDataProduct
 16from roborock.diagnostics import redact_device_data
 17from roborock.exceptions import RoborockException
 18from roborock.roborock_message import RoborockMessage
 19from roborock.util import RoborockLoggerAdapter
 20
 21from .traits import Trait
 22from .traits.traits_mixin import TraitsMixin
 23from .transport.channel import Channel
 24
 25_LOGGER = logging.getLogger(__name__)
 26
 27__all__ = [
 28    "DeviceReadyCallback",
 29    "RoborockDevice",
 30]
 31
 32# Exponential backoff parameters
 33MIN_BACKOFF_INTERVAL = datetime.timedelta(seconds=10)
 34MAX_BACKOFF_INTERVAL = datetime.timedelta(minutes=30)
 35BACKOFF_MULTIPLIER = 1.5
 36# Give time for the NETWORK_INFO fetch and V1 hello attempt
 37# and potential fallback to L01.
 38START_ATTEMPT_TIMEOUT = datetime.timedelta(seconds=15)
 39
 40
 41DeviceReadyCallback = Callable[["RoborockDevice"], None]
 42
 43
 44class RoborockDevice(ABC, TraitsMixin):
 45    """A generic channel for establishing a connection with a Roborock device.
 46
 47    Individual channel implementations have their own methods for speaking to
 48    the device that hide some of the protocol specific complexity, but they
 49    are still specialized for the device type and protocol.
 50
 51    Attributes of the device are exposed through traits, which are mixed in
 52    through the TraitsMixin class. Traits are optional and may not be present
 53    on all devices.
 54    """
 55
 56    def __init__(
 57        self,
 58        device_info: HomeDataDevice,
 59        product: HomeDataProduct,
 60        channel: Channel,
 61        trait: Trait,
 62    ) -> None:
 63        """Initialize the RoborockDevice.
 64
 65        The device takes ownership of the channel for communication with the device.
 66        Use `connect()` to establish the connection, which will set up the appropriate
 67        protocol channel. Use `close()` to clean up all connections.
 68        """
 69        TraitsMixin.__init__(self, trait)
 70        self._duid = device_info.duid
 71        self._logger = RoborockLoggerAdapter(duid=self._duid, logger=_LOGGER)
 72        self._name = device_info.name
 73        self._device_info = device_info
 74        self._product = product
 75        self._channel = channel
 76        self._connect_task: asyncio.Task[None] | None = None
 77        self._unsub: Callable[[], None] | None = None
 78        self._ready_callbacks = CallbackList["RoborockDevice"]()
 79        self._has_connected = False
 80
 81    @property
 82    def duid(self) -> str:
 83        """Return the device unique identifier (DUID)."""
 84        return self._duid
 85
 86    @property
 87    def name(self) -> str:
 88        """Return the device name."""
 89        return self._name
 90
 91    @property
 92    def device_info(self) -> HomeDataDevice:
 93        """Return the device information.
 94
 95        This includes information specific to the device like its identifier or
 96        firmware version.
 97        """
 98        return self._device_info
 99
100    @property
101    def product(self) -> HomeDataProduct:
102        """Return the device product name.
103
104        This returns product level information such as the model name.
105        """
106        return self._product
107
108    @property
109    def is_connected(self) -> bool:
110        """Return whether the device is connected."""
111        return self._channel.is_connected
112
113    @property
114    def is_local_connected(self) -> bool:
115        """Return whether the device is connected locally.
116
117        This can be used to determine if the device is reachable over a local
118        network connection, as opposed to a cloud connection. This is useful
119        for adjusting behavior like polling frequency.
120        """
121        return self._channel.is_local_connected
122
123    def add_ready_callback(self, callback: DeviceReadyCallback) -> Callable[[], None]:
124        """Add a callback to be notified when the device is ready.
125
126        A device is considered ready when it has successfully connected. It may go
127        offline later, but this callback will only be called once when the device
128        first connects.
129
130        The callback will be called immediately if the device has already previously
131        connected.
132        """
133        remove = self._ready_callbacks.add_callback(callback)
134        if self._has_connected:
135            callback(self)
136
137        return remove
138
139    async def start_connect(self) -> None:
140        """Start a background task to connect to the device.
141
142        This will give a moment for the first connection attempt to start so
143        that the device will have connections established -- however, this will
144        never directly fail.
145
146        If the connection fails, it will retry in the background with
147        exponential backoff.
148
149        Once connected, the device will remain connected until `close()` is
150        called. The device will automatically attempt to reconnect if the connection
151        is lost.
152        """
153        # The future will be set to True if the first attempt succeeds, False if
154        # it fails, or an exception if an unexpected error occurs.
155        # We use this to wait a short time for the first attempt to complete. We
156        # don't actually care about the result, just that we waited long enough.
157        start_attempt: asyncio.Future[bool] = asyncio.Future()
158
159        async def connect_loop() -> None:
160            try:
161                backoff = MIN_BACKOFF_INTERVAL
162                while True:
163                    try:
164                        await self.connect()
165                        if not start_attempt.done():
166                            start_attempt.set_result(True)
167                        self._has_connected = True
168                        self._ready_callbacks(self)
169                        return
170                    except RoborockException as e:
171                        if not start_attempt.done():
172                            start_attempt.set_result(False)
173                        self._logger.info("Failed to connect (retry %s): %s", backoff.total_seconds(), e)
174                        await asyncio.sleep(backoff.total_seconds())
175                        backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
176                    except Exception as e:  # pylint: disable=broad-except
177                        if not start_attempt.done():
178                            start_attempt.set_exception(e)
179                        self._logger.exception("Uncaught error during connect: %s", e)
180                        return
181            except asyncio.CancelledError:
182                self._logger.debug("connect_loop was cancelled for device %s", self.duid)
183            finally:
184                if not start_attempt.done():
185                    start_attempt.set_result(False)
186
187        self._connect_task = asyncio.create_task(connect_loop())
188
189        try:
190            async with asyncio.timeout(START_ATTEMPT_TIMEOUT.total_seconds()):
191                await start_attempt
192        except TimeoutError:
193            self._logger.debug("Initial connection attempt took longer than expected, will keep trying in background")
194
195    async def connect(self) -> None:
196        """Connect to the device using the appropriate protocol channel."""
197        if self._unsub:
198            raise ValueError("Already connected to the device")
199        unsub = await self._channel.subscribe(self._on_message)
200        if self.v1_properties is not None:
201            try:
202                await self.v1_properties.discover_features()
203            except RoborockException:
204                unsub()
205                raise
206        self._logger.info("Connected to device")
207        self._unsub = unsub
208
209    async def close(self) -> None:
210        """Close all connections to the device."""
211        if self._connect_task:
212            self._connect_task.cancel()
213            try:
214                await self._connect_task
215            except asyncio.CancelledError:
216                pass
217        if self._unsub:
218            self._unsub()
219            self._unsub = None
220
221    def _on_message(self, message: RoborockMessage) -> None:
222        """Handle incoming messages from the device."""
223        self._logger.debug("Received message from device: %s", message)
224
225    def diagnostic_data(self) -> dict[str, Any]:
226        """Return diagnostics information about the device."""
227        extra: dict[str, Any] = {}
228        if self.v1_properties:
229            extra["traits"] = self.v1_properties.as_dict()
230        return redact_device_data(
231            {
232                "device": self.device_info.as_dict(),
233                "product": self.product.as_dict(),
234                **extra,
235            }
236        )
DeviceReadyCallback = collections.abc.Callable[['RoborockDevice'], None]
class RoborockDevice(abc.ABC, roborock.devices.traits.traits_mixin.TraitsMixin):
 45class RoborockDevice(ABC, TraitsMixin):
 46    """A generic channel for establishing a connection with a Roborock device.
 47
 48    Individual channel implementations have their own methods for speaking to
 49    the device that hide some of the protocol specific complexity, but they
 50    are still specialized for the device type and protocol.
 51
 52    Attributes of the device are exposed through traits, which are mixed in
 53    through the TraitsMixin class. Traits are optional and may not be present
 54    on all devices.
 55    """
 56
 57    def __init__(
 58        self,
 59        device_info: HomeDataDevice,
 60        product: HomeDataProduct,
 61        channel: Channel,
 62        trait: Trait,
 63    ) -> None:
 64        """Initialize the RoborockDevice.
 65
 66        The device takes ownership of the channel for communication with the device.
 67        Use `connect()` to establish the connection, which will set up the appropriate
 68        protocol channel. Use `close()` to clean up all connections.
 69        """
 70        TraitsMixin.__init__(self, trait)
 71        self._duid = device_info.duid
 72        self._logger = RoborockLoggerAdapter(duid=self._duid, logger=_LOGGER)
 73        self._name = device_info.name
 74        self._device_info = device_info
 75        self._product = product
 76        self._channel = channel
 77        self._connect_task: asyncio.Task[None] | None = None
 78        self._unsub: Callable[[], None] | None = None
 79        self._ready_callbacks = CallbackList["RoborockDevice"]()
 80        self._has_connected = False
 81
 82    @property
 83    def duid(self) -> str:
 84        """Return the device unique identifier (DUID)."""
 85        return self._duid
 86
 87    @property
 88    def name(self) -> str:
 89        """Return the device name."""
 90        return self._name
 91
 92    @property
 93    def device_info(self) -> HomeDataDevice:
 94        """Return the device information.
 95
 96        This includes information specific to the device like its identifier or
 97        firmware version.
 98        """
 99        return self._device_info
100
101    @property
102    def product(self) -> HomeDataProduct:
103        """Return the device product name.
104
105        This returns product level information such as the model name.
106        """
107        return self._product
108
109    @property
110    def is_connected(self) -> bool:
111        """Return whether the device is connected."""
112        return self._channel.is_connected
113
114    @property
115    def is_local_connected(self) -> bool:
116        """Return whether the device is connected locally.
117
118        This can be used to determine if the device is reachable over a local
119        network connection, as opposed to a cloud connection. This is useful
120        for adjusting behavior like polling frequency.
121        """
122        return self._channel.is_local_connected
123
124    def add_ready_callback(self, callback: DeviceReadyCallback) -> Callable[[], None]:
125        """Add a callback to be notified when the device is ready.
126
127        A device is considered ready when it has successfully connected. It may go
128        offline later, but this callback will only be called once when the device
129        first connects.
130
131        The callback will be called immediately if the device has already previously
132        connected.
133        """
134        remove = self._ready_callbacks.add_callback(callback)
135        if self._has_connected:
136            callback(self)
137
138        return remove
139
140    async def start_connect(self) -> None:
141        """Start a background task to connect to the device.
142
143        This will give a moment for the first connection attempt to start so
144        that the device will have connections established -- however, this will
145        never directly fail.
146
147        If the connection fails, it will retry in the background with
148        exponential backoff.
149
150        Once connected, the device will remain connected until `close()` is
151        called. The device will automatically attempt to reconnect if the connection
152        is lost.
153        """
154        # The future will be set to True if the first attempt succeeds, False if
155        # it fails, or an exception if an unexpected error occurs.
156        # We use this to wait a short time for the first attempt to complete. We
157        # don't actually care about the result, just that we waited long enough.
158        start_attempt: asyncio.Future[bool] = asyncio.Future()
159
160        async def connect_loop() -> None:
161            try:
162                backoff = MIN_BACKOFF_INTERVAL
163                while True:
164                    try:
165                        await self.connect()
166                        if not start_attempt.done():
167                            start_attempt.set_result(True)
168                        self._has_connected = True
169                        self._ready_callbacks(self)
170                        return
171                    except RoborockException as e:
172                        if not start_attempt.done():
173                            start_attempt.set_result(False)
174                        self._logger.info("Failed to connect (retry %s): %s", backoff.total_seconds(), e)
175                        await asyncio.sleep(backoff.total_seconds())
176                        backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
177                    except Exception as e:  # pylint: disable=broad-except
178                        if not start_attempt.done():
179                            start_attempt.set_exception(e)
180                        self._logger.exception("Uncaught error during connect: %s", e)
181                        return
182            except asyncio.CancelledError:
183                self._logger.debug("connect_loop was cancelled for device %s", self.duid)
184            finally:
185                if not start_attempt.done():
186                    start_attempt.set_result(False)
187
188        self._connect_task = asyncio.create_task(connect_loop())
189
190        try:
191            async with asyncio.timeout(START_ATTEMPT_TIMEOUT.total_seconds()):
192                await start_attempt
193        except TimeoutError:
194            self._logger.debug("Initial connection attempt took longer than expected, will keep trying in background")
195
196    async def connect(self) -> None:
197        """Connect to the device using the appropriate protocol channel."""
198        if self._unsub:
199            raise ValueError("Already connected to the device")
200        unsub = await self._channel.subscribe(self._on_message)
201        if self.v1_properties is not None:
202            try:
203                await self.v1_properties.discover_features()
204            except RoborockException:
205                unsub()
206                raise
207        self._logger.info("Connected to device")
208        self._unsub = unsub
209
210    async def close(self) -> None:
211        """Close all connections to the device."""
212        if self._connect_task:
213            self._connect_task.cancel()
214            try:
215                await self._connect_task
216            except asyncio.CancelledError:
217                pass
218        if self._unsub:
219            self._unsub()
220            self._unsub = None
221
222    def _on_message(self, message: RoborockMessage) -> None:
223        """Handle incoming messages from the device."""
224        self._logger.debug("Received message from device: %s", message)
225
226    def diagnostic_data(self) -> dict[str, Any]:
227        """Return diagnostics information about the device."""
228        extra: dict[str, Any] = {}
229        if self.v1_properties:
230            extra["traits"] = self.v1_properties.as_dict()
231        return redact_device_data(
232            {
233                "device": self.device_info.as_dict(),
234                "product": self.product.as_dict(),
235                **extra,
236            }
237        )

A generic channel for establishing a connection with a Roborock device.

Individual channel implementations have their own methods for speaking to the device that hide some of the protocol specific complexity, but they are still specialized for the device type and protocol.

Attributes of the device are exposed through traits, which are mixed in through the TraitsMixin class. Traits are optional and may not be present on all devices.

RoborockDevice( device_info: roborock.data.containers.HomeDataDevice, product: roborock.data.containers.HomeDataProduct, channel: roborock.devices.transport.channel.Channel, trait: roborock.devices.traits.Trait)
57    def __init__(
58        self,
59        device_info: HomeDataDevice,
60        product: HomeDataProduct,
61        channel: Channel,
62        trait: Trait,
63    ) -> None:
64        """Initialize the RoborockDevice.
65
66        The device takes ownership of the channel for communication with the device.
67        Use `connect()` to establish the connection, which will set up the appropriate
68        protocol channel. Use `close()` to clean up all connections.
69        """
70        TraitsMixin.__init__(self, trait)
71        self._duid = device_info.duid
72        self._logger = RoborockLoggerAdapter(duid=self._duid, logger=_LOGGER)
73        self._name = device_info.name
74        self._device_info = device_info
75        self._product = product
76        self._channel = channel
77        self._connect_task: asyncio.Task[None] | None = None
78        self._unsub: Callable[[], None] | None = None
79        self._ready_callbacks = CallbackList["RoborockDevice"]()
80        self._has_connected = False

Initialize the RoborockDevice.

The device takes ownership of the channel for communication with the device. Use connect() to establish the connection, which will set up the appropriate protocol channel. Use close() to clean up all connections.

duid: str
82    @property
83    def duid(self) -> str:
84        """Return the device unique identifier (DUID)."""
85        return self._duid

Return the device unique identifier (DUID).

name: str
87    @property
88    def name(self) -> str:
89        """Return the device name."""
90        return self._name

Return the device name.

device_info: roborock.data.containers.HomeDataDevice
92    @property
93    def device_info(self) -> HomeDataDevice:
94        """Return the device information.
95
96        This includes information specific to the device like its identifier or
97        firmware version.
98        """
99        return self._device_info

Return the device information.

This includes information specific to the device like its identifier or firmware version.

101    @property
102    def product(self) -> HomeDataProduct:
103        """Return the device product name.
104
105        This returns product level information such as the model name.
106        """
107        return self._product

Return the device product name.

This returns product level information such as the model name.

is_connected: bool
109    @property
110    def is_connected(self) -> bool:
111        """Return whether the device is connected."""
112        return self._channel.is_connected

Return whether the device is connected.

is_local_connected: bool
114    @property
115    def is_local_connected(self) -> bool:
116        """Return whether the device is connected locally.
117
118        This can be used to determine if the device is reachable over a local
119        network connection, as opposed to a cloud connection. This is useful
120        for adjusting behavior like polling frequency.
121        """
122        return self._channel.is_local_connected

Return whether the device is connected locally.

This can be used to determine if the device is reachable over a local network connection, as opposed to a cloud connection. This is useful for adjusting behavior like polling frequency.

def add_ready_callback( self, callback: Callable[RoborockDevice, None]) -> Callable[[], None]:
124    def add_ready_callback(self, callback: DeviceReadyCallback) -> Callable[[], None]:
125        """Add a callback to be notified when the device is ready.
126
127        A device is considered ready when it has successfully connected. It may go
128        offline later, but this callback will only be called once when the device
129        first connects.
130
131        The callback will be called immediately if the device has already previously
132        connected.
133        """
134        remove = self._ready_callbacks.add_callback(callback)
135        if self._has_connected:
136            callback(self)
137
138        return remove

Add a callback to be notified when the device is ready.

A device is considered ready when it has successfully connected. It may go offline later, but this callback will only be called once when the device first connects.

The callback will be called immediately if the device has already previously connected.

async def start_connect(self) -> None:
140    async def start_connect(self) -> None:
141        """Start a background task to connect to the device.
142
143        This will give a moment for the first connection attempt to start so
144        that the device will have connections established -- however, this will
145        never directly fail.
146
147        If the connection fails, it will retry in the background with
148        exponential backoff.
149
150        Once connected, the device will remain connected until `close()` is
151        called. The device will automatically attempt to reconnect if the connection
152        is lost.
153        """
154        # The future will be set to True if the first attempt succeeds, False if
155        # it fails, or an exception if an unexpected error occurs.
156        # We use this to wait a short time for the first attempt to complete. We
157        # don't actually care about the result, just that we waited long enough.
158        start_attempt: asyncio.Future[bool] = asyncio.Future()
159
160        async def connect_loop() -> None:
161            try:
162                backoff = MIN_BACKOFF_INTERVAL
163                while True:
164                    try:
165                        await self.connect()
166                        if not start_attempt.done():
167                            start_attempt.set_result(True)
168                        self._has_connected = True
169                        self._ready_callbacks(self)
170                        return
171                    except RoborockException as e:
172                        if not start_attempt.done():
173                            start_attempt.set_result(False)
174                        self._logger.info("Failed to connect (retry %s): %s", backoff.total_seconds(), e)
175                        await asyncio.sleep(backoff.total_seconds())
176                        backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
177                    except Exception as e:  # pylint: disable=broad-except
178                        if not start_attempt.done():
179                            start_attempt.set_exception(e)
180                        self._logger.exception("Uncaught error during connect: %s", e)
181                        return
182            except asyncio.CancelledError:
183                self._logger.debug("connect_loop was cancelled for device %s", self.duid)
184            finally:
185                if not start_attempt.done():
186                    start_attempt.set_result(False)
187
188        self._connect_task = asyncio.create_task(connect_loop())
189
190        try:
191            async with asyncio.timeout(START_ATTEMPT_TIMEOUT.total_seconds()):
192                await start_attempt
193        except TimeoutError:
194            self._logger.debug("Initial connection attempt took longer than expected, will keep trying in background")

Start a background task to connect to the device.

This will give a moment for the first connection attempt to start so that the device will have connections established -- however, this will never directly fail.

If the connection fails, it will retry in the background with exponential backoff.

Once connected, the device will remain connected until close() is called. The device will automatically attempt to reconnect if the connection is lost.

async def connect(self) -> None:
196    async def connect(self) -> None:
197        """Connect to the device using the appropriate protocol channel."""
198        if self._unsub:
199            raise ValueError("Already connected to the device")
200        unsub = await self._channel.subscribe(self._on_message)
201        if self.v1_properties is not None:
202            try:
203                await self.v1_properties.discover_features()
204            except RoborockException:
205                unsub()
206                raise
207        self._logger.info("Connected to device")
208        self._unsub = unsub

Connect to the device using the appropriate protocol channel.

async def close(self) -> None:
210    async def close(self) -> None:
211        """Close all connections to the device."""
212        if self._connect_task:
213            self._connect_task.cancel()
214            try:
215                await self._connect_task
216            except asyncio.CancelledError:
217                pass
218        if self._unsub:
219            self._unsub()
220            self._unsub = None

Close all connections to the device.

def diagnostic_data(self) -> dict[str, typing.Any]:
226    def diagnostic_data(self) -> dict[str, Any]:
227        """Return diagnostics information about the device."""
228        extra: dict[str, Any] = {}
229        if self.v1_properties:
230            extra["traits"] = self.v1_properties.as_dict()
231        return redact_device_data(
232            {
233                "device": self.device_info.as_dict(),
234                "product": self.product.as_dict(),
235                **extra,
236            }
237        )

Return diagnostics information about the device.