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