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