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

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

Return the device unique identifier (DUID).

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

Return the device name.

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

Return the device information.

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

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

Return the device product name.

This returns product level information such as the model name.

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

Return whether the device is connected.

is_local_connected: bool
112    @property
113    def is_local_connected(self) -> bool:
114        """Return whether the device is connected locally.
115
116        This can be used to determine if the device is reachable over a local
117        network connection, as opposed to a cloud connection. This is useful
118        for adjusting behavior like polling frequency.
119        """
120        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]:
122    def add_ready_callback(self, callback: DeviceReadyCallback) -> Callable[[], None]:
123        """Add a callback to be notified when the device is ready.
124
125        A device is considered ready when it has successfully connected. It may go
126        offline later, but this callback will only be called once when the device
127        first connects.
128
129        The callback will be called immediately if the device has already previously
130        connected.
131        """
132        remove = self._ready_callbacks.add_callback(callback)
133        if self._has_connected:
134            callback(self)
135
136        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:
138    async def start_connect(self) -> None:
139        """Start a background task to connect to the device.
140
141        This will give a moment for the first connection attempt to start so
142        that the device will have connections established -- however, this will
143        never directly fail.
144
145        If the connection fails, it will retry in the background with
146        exponential backoff.
147
148        Once connected, the device will remain connected until `close()` is
149        called. The device will automatically attempt to reconnect if the connection
150        is lost.
151        """
152        # The future will be set to True if the first attempt succeeds, False if
153        # it fails, or an exception if an unexpected error occurs.
154        # We use this to wait a short time for the first attempt to complete. We
155        # don't actually care about the result, just that we waited long enough.
156        start_attempt: asyncio.Future[bool] = asyncio.Future()
157
158        async def connect_loop() -> None:
159            try:
160                backoff = MIN_BACKOFF_INTERVAL
161                while True:
162                    try:
163                        await self.connect()
164                        if not start_attempt.done():
165                            start_attempt.set_result(True)
166                        self._has_connected = True
167                        self._ready_callbacks(self)
168                        return
169                    except RoborockException as e:
170                        if not start_attempt.done():
171                            start_attempt.set_result(False)
172                        self._logger.info("Failed to connect (retry %s): %s", backoff.total_seconds(), e)
173                        await asyncio.sleep(backoff.total_seconds())
174                        backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
175                    except Exception as e:  # pylint: disable=broad-except
176                        if not start_attempt.done():
177                            start_attempt.set_exception(e)
178                        self._logger.exception("Uncaught error during connect: %s", e)
179                        return
180            except asyncio.CancelledError:
181                self._logger.debug("connect_loop was cancelled for device %s", self.duid)
182            finally:
183                if not start_attempt.done():
184                    start_attempt.set_result(False)
185
186        self._connect_task = asyncio.create_task(connect_loop())
187
188        try:
189            async with asyncio.timeout(START_ATTEMPT_TIMEOUT.total_seconds()):
190                await start_attempt
191        except TimeoutError:
192            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:
194    async def connect(self) -> None:
195        """Connect to the device using the appropriate protocol channel."""
196        if self._unsub:
197            raise ValueError("Already connected to the device")
198        unsub = await self._channel.subscribe(self._on_message)
199        if self.v1_properties is not None:
200            try:
201                await self.v1_properties.discover_features()
202            except RoborockException:
203                unsub()
204                raise
205        self._logger.info("Connected to device")
206        self._unsub = unsub

Connect to the device using the appropriate protocol channel.

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

Close all connections to the device.

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

Return diagnostics information about the device.