python-roborock 2.45.0__tar.gz → 2.47.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {python_roborock-2.45.0 → python_roborock-2.47.0}/PKG-INFO +1 -1
  2. {python_roborock-2.45.0 → python_roborock-2.47.0}/pyproject.toml +1 -1
  3. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/api.py +2 -11
  4. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/cli.py +23 -38
  5. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/cloud_api.py +92 -13
  6. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/device.py +10 -13
  7. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/device_manager.py +7 -25
  8. python_roborock-2.47.0/roborock/devices/traits/__init__.py +15 -0
  9. python_roborock-2.47.0/roborock/devices/traits/a01/__init__.py +61 -0
  10. python_roborock-2.45.0/roborock/devices/traits/b01/props.py → python_roborock-2.47.0/roborock/devices/traits/b01/__init__.py +13 -14
  11. python_roborock-2.47.0/roborock/devices/traits/traits_mixin.py +61 -0
  12. python_roborock-2.47.0/roborock/devices/traits/v1/__init__.py +57 -0
  13. python_roborock-2.47.0/roborock/devices/traits/v1/clean_summary.py +29 -0
  14. python_roborock-2.47.0/roborock/devices/traits/v1/common.py +115 -0
  15. python_roborock-2.47.0/roborock/devices/traits/v1/do_not_disturb.py +17 -0
  16. python_roborock-2.47.0/roborock/devices/traits/v1/status.py +24 -0
  17. python_roborock-2.47.0/roborock/devices/traits/v1/volume.py +26 -0
  18. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/exceptions.py +1 -0
  19. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/protocol.py +44 -11
  20. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/protocols/v1_protocol.py +2 -3
  21. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/util.py +1 -1
  22. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_client_v1.py +7 -1
  23. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_local_client_v1.py +89 -25
  24. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +1 -1
  25. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +1 -1
  26. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/web_api.py +2 -2
  27. python_roborock-2.45.0/roborock/devices/traits/b01/__init__.py +0 -0
  28. python_roborock-2.45.0/roborock/devices/traits/clean_summary.py +0 -52
  29. python_roborock-2.45.0/roborock/devices/traits/dnd.py +0 -41
  30. python_roborock-2.45.0/roborock/devices/traits/dyad.py +0 -36
  31. python_roborock-2.45.0/roborock/devices/traits/sound_volume.py +0 -31
  32. python_roborock-2.45.0/roborock/devices/traits/status.py +0 -49
  33. python_roborock-2.45.0/roborock/devices/traits/trait.py +0 -10
  34. python_roborock-2.45.0/roborock/devices/traits/zeo.py +0 -36
  35. {python_roborock-2.45.0 → python_roborock-2.47.0}/LICENSE +0 -0
  36. {python_roborock-2.45.0 → python_roborock-2.47.0}/README.md +0 -0
  37. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/__init__.py +0 -0
  38. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/b01_containers.py +0 -0
  39. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/broadcast_protocol.py +0 -0
  40. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/callbacks.py +0 -0
  41. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/clean_modes.py +0 -0
  42. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/code_mappings.py +0 -0
  43. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/command_cache.py +0 -0
  44. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/const.py +0 -0
  45. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/containers.py +0 -0
  46. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/device_features.py +0 -0
  47. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/README.md +0 -0
  48. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/__init__.py +0 -0
  49. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/a01_channel.py +0 -0
  50. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/b01_channel.py +0 -0
  51. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/cache.py +0 -0
  52. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/channel.py +0 -0
  53. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/local_channel.py +0 -0
  54. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/mqtt_channel.py +0 -0
  55. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/v1_channel.py +0 -0
  56. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/v1_rpc_channel.py +0 -0
  57. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/mqtt/__init__.py +0 -0
  58. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/mqtt/roborock_session.py +0 -0
  59. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/mqtt/session.py +0 -0
  60. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/protocols/a01_protocol.py +0 -0
  61. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/protocols/b01_protocol.py +0 -0
  62. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/py.typed +0 -0
  63. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/roborock_future.py +0 -0
  64. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/roborock_message.py +0 -0
  65. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/roborock_typing.py +0 -0
  66. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_1_apis/__init__.py +0 -0
  67. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_a01_apis/__init__.py +0 -0
  68. {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 2.45.0
3
+ Version: 2.47.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Home-page: https://github.com/humbertogontijo/python-roborock
6
6
  License: GPL-3.0-only
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-roborock"
3
- version = "2.45.0"
3
+ version = "2.47.0"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = ["humbertogontijo <humbertogontijo@users.noreply.github.com>"]
6
6
  license = "GPL-3.0-only"
@@ -18,12 +18,11 @@ from .exceptions import (
18
18
  from .roborock_future import RoborockFuture
19
19
  from .roborock_message import (
20
20
  RoborockMessage,
21
- RoborockMessageProtocol,
22
21
  )
23
22
  from .util import get_next_int
24
23
 
25
24
  _LOGGER = logging.getLogger(__name__)
26
- KEEPALIVE = 60
25
+ KEEPALIVE = 70
27
26
 
28
27
 
29
28
  class RoborockClient(ABC):
@@ -78,12 +77,6 @@ class RoborockClient(ABC):
78
77
  return False
79
78
  return True
80
79
 
81
- async def validate_connection(self) -> None:
82
- if not self.should_keepalive():
83
- self._logger.info("Resetting Roborock connection due to keepalive timeout")
84
- await self.async_disconnect()
85
- await self.async_connect()
86
-
87
80
  async def _wait_response(self, request_id: int, queue: RoborockFuture) -> Any:
88
81
  try:
89
82
  response = await queue.async_get(self.queue_timeout)
@@ -97,9 +90,7 @@ class RoborockClient(ABC):
97
90
 
98
91
  def _async_response(self, request_id: int, protocol_id: int = 0) -> Any:
99
92
  queue = RoborockFuture(protocol_id)
100
- if request_id in self._waiting_queue and not (
101
- request_id == 2 and protocol_id == RoborockMessageProtocol.PING_REQUEST
102
- ):
93
+ if request_id in self._waiting_queue:
103
94
  new_id = get_next_int(10000, 32767)
104
95
  self._logger.warning(
105
96
  "Attempting to create a future with an existing id %s (%s)... New id is %s. "
@@ -27,6 +27,7 @@ import functools
27
27
  import json
28
28
  import logging
29
29
  import threading
30
+ from collections.abc import Callable
30
31
  from dataclasses import asdict, dataclass
31
32
  from pathlib import Path
32
33
  from typing import Any, cast
@@ -43,6 +44,8 @@ from roborock.containers import DeviceData, HomeData, NetworkInfo, RoborockBase,
43
44
  from roborock.devices.cache import Cache, CacheData
44
45
  from roborock.devices.device import RoborockDevice
45
46
  from roborock.devices.device_manager import DeviceManager, create_device_manager, create_home_data_api
47
+ from roborock.devices.traits import Trait
48
+ from roborock.devices.traits.v1 import V1TraitMixin
46
49
  from roborock.protocol import MessageParser
47
50
  from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
48
51
  from roborock.web_api import RoborockApiClient
@@ -377,6 +380,22 @@ async def execute_scene(ctx, scene_id):
377
380
  await client.execute_scene(cache_data.user_data, scene_id)
378
381
 
379
382
 
383
+ async def _v1_trait(context: RoborockContext, device_id: str, display_func: Callable[[], V1TraitMixin]) -> Trait:
384
+ device_manager = await context.get_device_manager()
385
+ device = await device_manager.get_device(device_id)
386
+ if device.v1_properties is None:
387
+ raise RoborockException(f"Device {device.name} does not support V1 protocol")
388
+
389
+ trait = display_func(device.v1_properties)
390
+ await trait.refresh()
391
+ return trait
392
+
393
+
394
+ async def _display_v1_trait(context: RoborockContext, device_id: str, display_func: Callable[[], Trait]) -> None:
395
+ trait = await _v1_trait(context, device_id, display_func)
396
+ click.echo(dump_json(trait.as_dict()))
397
+
398
+
380
399
  @session.command()
381
400
  @click.option("--device_id", required=True)
382
401
  @click.pass_context
@@ -384,16 +403,7 @@ async def execute_scene(ctx, scene_id):
384
403
  async def status(ctx, device_id: str):
385
404
  """Get device status."""
386
405
  context: RoborockContext = ctx.obj
387
-
388
- device_manager = await context.get_device_manager()
389
- device = await device_manager.get_device(device_id)
390
-
391
- if not (status_trait := device.traits.get("status")):
392
- click.echo(f"Device {device.name} does not have a status trait")
393
- return
394
-
395
- status_result = await status_trait.get_status()
396
- click.echo(dump_json(status_result.as_dict()))
406
+ await _display_v1_trait(context, device_id, lambda v1: v1.status)
397
407
 
398
408
 
399
409
  @session.command()
@@ -403,15 +413,7 @@ async def status(ctx, device_id: str):
403
413
  async def clean_summary(ctx, device_id: str):
404
414
  """Get device clean summary."""
405
415
  context: RoborockContext = ctx.obj
406
-
407
- device_manager = await context.get_device_manager()
408
- device = await device_manager.get_device(device_id)
409
- if not (clean_summary_trait := device.traits.get("clean_summary")):
410
- click.echo(f"Device {device.name} does not have a clean summary trait")
411
- return
412
-
413
- clean_summary_result = await clean_summary_trait.get_clean_summary()
414
- click.echo(dump_json(clean_summary_result.as_dict()))
416
+ await _display_v1_trait(context, device_id, lambda v1: v1.clean_summary)
415
417
 
416
418
 
417
419
  @session.command()
@@ -421,17 +423,7 @@ async def clean_summary(ctx, device_id: str):
421
423
  async def volume(ctx, device_id: str):
422
424
  """Get device volume."""
423
425
  context: RoborockContext = ctx.obj
424
-
425
- device_manager = await context.get_device_manager()
426
- device = await device_manager.get_device(device_id)
427
-
428
- if not (volume_trait := device.traits.get("sound_volume")):
429
- click.echo(f"Device {device.name} does not have a volume trait")
430
- return
431
-
432
- volume_result = await volume_trait.get_volume()
433
- click.echo(f"Device {device_id} volume:")
434
- click.echo(volume_result)
426
+ await _display_v1_trait(context, device_id, lambda v1: v1.sound_volume)
435
427
 
436
428
 
437
429
  @session.command()
@@ -442,14 +434,7 @@ async def volume(ctx, device_id: str):
442
434
  async def set_volume(ctx, device_id: str, volume: int):
443
435
  """Set the devicevolume."""
444
436
  context: RoborockContext = ctx.obj
445
-
446
- device_manager = await context.get_device_manager()
447
- device = await device_manager.get_device(device_id)
448
-
449
- if not (volume_trait := device.traits.get("sound_volume")):
450
- click.echo(f"Device {device.name} does not have a volume trait")
451
- return
452
-
437
+ volume_trait = await _v1_trait(context, device_id, lambda v1: v1.sound_volume)
453
438
  await volume_trait.set_volume(volume)
454
439
  click.echo(f"Set Device {device_id} volume to {volume}")
455
440
 
@@ -8,6 +8,10 @@ from asyncio import Lock
8
8
  from typing import Any
9
9
 
10
10
  import paho.mqtt.client as mqtt
11
+ from paho.mqtt.enums import MQTTErrorCode
12
+
13
+ # Mypy is not seeing this for some reason. It wants me to use the depreciated ReasonCodes
14
+ from paho.mqtt.reasoncodes import ReasonCode # type: ignore
11
15
 
12
16
  from .api import KEEPALIVE, RoborockClient
13
17
  from .containers import DeviceData, UserData
@@ -67,7 +71,8 @@ class RoborockMqttClient(RoborockClient, ABC):
67
71
  self._mqtt_client = _Mqtt()
68
72
  self._mqtt_client.on_connect = self._mqtt_on_connect
69
73
  self._mqtt_client.on_message = self._mqtt_on_message
70
- self._mqtt_client.on_disconnect = self._mqtt_on_disconnect
74
+ # Due to the incorrect ReasonCode, it is confused by typing
75
+ self._mqtt_client.on_disconnect = self._mqtt_on_disconnect # type: ignore
71
76
  if mqtt_params.tls:
72
77
  self._mqtt_client.tls_set()
73
78
 
@@ -76,12 +81,20 @@ class RoborockMqttClient(RoborockClient, ABC):
76
81
  self._mutex = Lock()
77
82
  self._decoder: Decoder = create_mqtt_decoder(device_info.device.local_key)
78
83
  self._encoder: Encoder = create_mqtt_encoder(device_info.device.local_key)
84
+ self.received_message_since_last_disconnect = False
85
+ self._topic = f"rr/m/o/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}"
79
86
 
80
- def _mqtt_on_connect(self, *args, **kwargs):
81
- _, __, ___, rc, ____ = args
87
+ def _mqtt_on_connect(
88
+ self,
89
+ client: mqtt.Client,
90
+ userdata: object,
91
+ flags: dict[str, int],
92
+ rc: ReasonCode,
93
+ properties: mqtt.Properties | None = None,
94
+ ):
82
95
  connection_queue = self._waiting_queue.get(CONNECT_REQUEST_ID)
83
- if rc != mqtt.MQTT_ERR_SUCCESS:
84
- message = f"Failed to connect ({mqtt.error_string(rc)})"
96
+ if rc.is_failure:
97
+ message = f"Failed to connect ({rc})"
85
98
  self._logger.error(message)
86
99
  if connection_queue:
87
100
  connection_queue.set_exception(VacuumError(message))
@@ -89,19 +102,19 @@ class RoborockMqttClient(RoborockClient, ABC):
89
102
  self._logger.debug("Failed to notify connect future, not in queue")
90
103
  return
91
104
  self._logger.info(f"Connected to mqtt {self._mqtt_host}:{self._mqtt_port}")
92
- topic = f"rr/m/o/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}"
93
- (result, mid) = self._mqtt_client.subscribe(topic)
105
+ (result, mid) = self._mqtt_client.subscribe(self._topic)
94
106
  if result != 0:
95
- message = f"Failed to subscribe ({mqtt.error_string(rc)})"
107
+ message = f"Failed to subscribe ({str(rc)})"
96
108
  self._logger.error(message)
97
109
  if connection_queue:
98
110
  connection_queue.set_exception(VacuumError(message))
99
111
  return
100
- self._logger.info(f"Subscribed to topic {topic}")
112
+ self._logger.info(f"Subscribed to topic {self._topic}")
101
113
  if connection_queue:
102
114
  connection_queue.set_result(True)
103
115
 
104
116
  def _mqtt_on_message(self, *args, **kwargs):
117
+ self.received_message_since_last_disconnect = True
105
118
  client, __, msg = args
106
119
  try:
107
120
  messages = self._decoder(msg.payload)
@@ -109,10 +122,16 @@ class RoborockMqttClient(RoborockClient, ABC):
109
122
  except Exception as ex:
110
123
  self._logger.exception(ex)
111
124
 
112
- def _mqtt_on_disconnect(self, *args, **kwargs):
113
- _, __, rc, ___ = args
125
+ def _mqtt_on_disconnect(
126
+ self,
127
+ client: mqtt.Client,
128
+ data: object,
129
+ flags: dict[str, int],
130
+ rc: ReasonCode | None,
131
+ properties: mqtt.Properties | None = None,
132
+ ):
114
133
  try:
115
- exc = RoborockException(mqtt.error_string(rc)) if rc != mqtt.MQTT_ERR_SUCCESS else None
134
+ exc = RoborockException(str(rc)) if rc is not None and rc.is_failure else None
116
135
  super().on_connection_lost(exc)
117
136
  connection_queue = self._waiting_queue.get(DISCONNECT_REQUEST_ID)
118
137
  if connection_queue:
@@ -138,7 +157,7 @@ class RoborockMqttClient(RoborockClient, ABC):
138
157
 
139
158
  if rc != mqtt.MQTT_ERR_SUCCESS:
140
159
  disconnected_future.cancel()
141
- raise RoborockException(f"Failed to disconnect ({mqtt.error_string(rc)})")
160
+ raise RoborockException(f"Failed to disconnect ({str(rc)})")
142
161
 
143
162
  return disconnected_future
144
163
 
@@ -178,3 +197,63 @@ class RoborockMqttClient(RoborockClient, ABC):
178
197
  )
179
198
  if info.rc != mqtt.MQTT_ERR_SUCCESS:
180
199
  raise RoborockException(f"Failed to publish ({mqtt.error_string(info.rc)})")
200
+
201
+ async def _unsubscribe(self) -> MQTTErrorCode:
202
+ """Unsubscribe from the topic."""
203
+ loop = asyncio.get_running_loop()
204
+ (result, mid) = await loop.run_in_executor(None, self._mqtt_client.unsubscribe, self._topic)
205
+
206
+ if result != 0:
207
+ message = f"Failed to unsubscribe ({mqtt.error_string(result)})"
208
+ self._logger.error(message)
209
+ else:
210
+ self._logger.info(f"Unsubscribed from topic {self._topic}")
211
+ return result
212
+
213
+ async def _subscribe(self) -> MQTTErrorCode:
214
+ """Subscribe to the topic."""
215
+ loop = asyncio.get_running_loop()
216
+ (result, mid) = await loop.run_in_executor(None, self._mqtt_client.subscribe, self._topic)
217
+
218
+ if result != 0:
219
+ message = f"Failed to subscribe ({mqtt.error_string(result)})"
220
+ self._logger.error(message)
221
+ else:
222
+ self._logger.info(f"Subscribed to topic {self._topic}")
223
+ return result
224
+
225
+ async def _reconnect(self) -> None:
226
+ """Reconnect to the MQTT broker."""
227
+ await self.async_disconnect()
228
+ await self.async_connect()
229
+
230
+ async def _validate_connection(self) -> None:
231
+ """Override the default validate connection to try to re-subscribe rather than disconnect.
232
+ When something seems to be wrong with our connection, we should follow the following steps:
233
+ 1. Try to unsubscribe and resubscribe from the topic.
234
+ 2. If we don't end up getting a message, we should completely disconnect and reconnect to the MQTT broker.
235
+ 3. We will continue to try to disconnect and reconnect until we get a message.
236
+ 4. If we get a message, the next time connection is lost, We will go back to step 1.
237
+ """
238
+ # If we should no longer keep the current connection alive...
239
+ if not self.should_keepalive():
240
+ self._logger.info("Resetting Roborock connection due to keepalive timeout")
241
+ if not self.received_message_since_last_disconnect:
242
+ # If we have already tried to unsub and resub, and we are still in this state,
243
+ # we should try to reconnect.
244
+ return await self._reconnect()
245
+ try:
246
+ # Mark that we have tried to unsubscribe and resubscribe
247
+ self.received_message_since_last_disconnect = False
248
+ if await self._unsubscribe() != 0:
249
+ # If we fail to unsubscribe, reconnect to the broker
250
+ return await self._reconnect()
251
+ if await self._subscribe() != 0:
252
+ # If we fail to subscribe, reconnected to the broker.
253
+ return await self._reconnect()
254
+
255
+ except Exception: # noqa
256
+ # If we get any errors at all, we should just reconnect.
257
+ return await self._reconnect()
258
+ # Call connect to make sure everything is still in a good state.
259
+ await self.async_connect()
@@ -6,14 +6,14 @@ until the API is stable.
6
6
 
7
7
  import logging
8
8
  from abc import ABC
9
- from collections.abc import Callable, Mapping
10
- from types import MappingProxyType
9
+ from collections.abc import Callable
11
10
 
12
11
  from roborock.containers import HomeDataDevice
13
12
  from roborock.roborock_message import RoborockMessage
14
13
 
15
14
  from .channel import Channel
16
- from .traits.trait import Trait
15
+ from .traits import Trait
16
+ from .traits.traits_mixin import TraitsMixin
17
17
 
18
18
  _LOGGER = logging.getLogger(__name__)
19
19
 
@@ -22,19 +22,23 @@ __all__ = [
22
22
  ]
23
23
 
24
24
 
25
- class RoborockDevice(ABC):
25
+ class RoborockDevice(ABC, TraitsMixin):
26
26
  """A generic channel for establishing a connection with a Roborock device.
27
27
 
28
28
  Individual channel implementations have their own methods for speaking to
29
29
  the device that hide some of the protocol specific complexity, but they
30
30
  are still specialized for the device type and protocol.
31
+
32
+ Attributes of the device are exposed through traits, which are mixed in
33
+ through the TraitsMixin class. Traits are optional and may not be present
34
+ on all devices.
31
35
  """
32
36
 
33
37
  def __init__(
34
38
  self,
35
39
  device_info: HomeDataDevice,
36
40
  channel: Channel,
37
- traits: list[Trait],
41
+ trait: Trait,
38
42
  ) -> None:
39
43
  """Initialize the RoborockDevice.
40
44
 
@@ -42,13 +46,11 @@ class RoborockDevice(ABC):
42
46
  Use `connect()` to establish the connection, which will set up the appropriate
43
47
  protocol channel. Use `close()` to clean up all connections.
44
48
  """
49
+ TraitsMixin.__init__(self, trait)
45
50
  self._duid = device_info.duid
46
51
  self._name = device_info.name
47
52
  self._channel = channel
48
53
  self._unsub: Callable[[], None] | None = None
49
- self._trait_map = {trait.name: trait for trait in traits}
50
- if len(self._trait_map) != len(traits):
51
- raise ValueError("Duplicate trait names found in traits list")
52
54
 
53
55
  @property
54
56
  def duid(self) -> str:
@@ -81,8 +83,3 @@ class RoborockDevice(ABC):
81
83
  def _on_message(self, message: RoborockMessage) -> None:
82
84
  """Handle incoming messages from the device."""
83
85
  _LOGGER.debug("Received message from device: %s", message)
84
-
85
- @property
86
- def traits(self) -> Mapping[str, Trait]:
87
- """Return the traits of the device."""
88
- return MappingProxyType(self._trait_map)
@@ -7,7 +7,6 @@ from collections.abc import Awaitable, Callable
7
7
 
8
8
  import aiohttp
9
9
 
10
- from roborock.code_mappings import RoborockCategory
11
10
  from roborock.containers import (
12
11
  HomeData,
13
12
  HomeDataDevice,
@@ -23,14 +22,7 @@ from roborock.web_api import RoborockApiClient
23
22
  from .cache import Cache, NoCache
24
23
  from .channel import Channel
25
24
  from .mqtt_channel import create_mqtt_channel
26
- from .traits.b01.props import B01PropsApi
27
- from .traits.clean_summary import CleanSummaryTrait
28
- from .traits.dnd import DoNotDisturbTrait
29
- from .traits.dyad import DyadApi
30
- from .traits.sound_volume import SoundVolumeTrait
31
- from .traits.status import StatusTrait
32
- from .traits.trait import Trait
33
- from .traits.zeo import ZeoApi
25
+ from .traits import Trait, a01, b01, v1
34
26
  from .v1_channel import create_v1_channel
35
27
 
36
28
  _LOGGER = logging.getLogger(__name__)
@@ -153,30 +145,20 @@ async def create_device_manager(
153
145
 
154
146
  def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
155
147
  channel: Channel
156
- traits: list[Trait] = []
157
- # TODO: Define a registration mechanism/factory for v1 traits
148
+ trait: Trait
158
149
  match device.pv:
159
150
  case DeviceVersion.V1:
160
151
  channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, cache)
161
- traits.append(StatusTrait(product, channel.rpc_channel))
162
- traits.append(DoNotDisturbTrait(channel.rpc_channel))
163
- traits.append(CleanSummaryTrait(channel.rpc_channel))
164
- traits.append(SoundVolumeTrait(channel.rpc_channel))
152
+ trait = v1.create(product, channel.rpc_channel)
165
153
  case DeviceVersion.A01:
166
- mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
167
- match product.category:
168
- case RoborockCategory.WET_DRY_VAC:
169
- traits.append(DyadApi(mqtt_channel))
170
- case RoborockCategory.WASHING_MACHINE:
171
- traits.append(ZeoApi(mqtt_channel))
172
- case _:
173
- raise NotImplementedError(f"Device {device.name} has unsupported category {product.category}")
154
+ channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
155
+ trait = a01.create(product, channel)
174
156
  case DeviceVersion.B01:
175
157
  channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
176
- traits.append(B01PropsApi(channel))
158
+ trait = b01.create(channel)
177
159
  case _:
178
160
  raise NotImplementedError(f"Device {device.name} has unsupported version {device.pv}")
179
- return RoborockDevice(device, channel, traits)
161
+ return RoborockDevice(device, channel, trait)
180
162
 
181
163
  manager = DeviceManager(home_data_api, device_creator, mqtt_session=mqtt_session, cache=cache)
182
164
  await manager.discover_devices()
@@ -0,0 +1,15 @@
1
+ """Module for device traits."""
2
+
3
+ from abc import ABC
4
+
5
+ __all__ = [
6
+ "Trait",
7
+ "traits_mixin",
8
+ "v1",
9
+ "a01",
10
+ "b01",
11
+ ]
12
+
13
+
14
+ class Trait(ABC):
15
+ """Base class for all traits."""
@@ -0,0 +1,61 @@
1
+ from typing import Any
2
+
3
+ from roborock.containers import HomeDataProduct, RoborockCategory
4
+ from roborock.devices.a01_channel import send_decoded_command
5
+ from roborock.devices.mqtt_channel import MqttChannel
6
+ from roborock.devices.traits import Trait
7
+ from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
8
+
9
+ __init__ = [
10
+ "DyadApi",
11
+ "ZeoApi",
12
+ ]
13
+
14
+
15
+ class DyadApi(Trait):
16
+ """API for interacting with Dyad devices."""
17
+
18
+ def __init__(self, channel: MqttChannel) -> None:
19
+ """Initialize the Dyad API."""
20
+ self._channel = channel
21
+
22
+ async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]:
23
+ """Query the device for the values of the given Dyad protocols."""
24
+ params = {RoborockDyadDataProtocol.ID_QUERY: [int(p) for p in protocols]}
25
+ return await send_decoded_command(self._channel, params)
26
+
27
+ async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]:
28
+ """Set a value for a specific protocol on the device."""
29
+ params = {protocol: value}
30
+ return await send_decoded_command(self._channel, params)
31
+
32
+
33
+ class ZeoApi(Trait):
34
+ """API for interacting with Zeo devices."""
35
+
36
+ name = "zeo"
37
+
38
+ def __init__(self, channel: MqttChannel) -> None:
39
+ """Initialize the Zeo API."""
40
+ self._channel = channel
41
+
42
+ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]:
43
+ """Query the device for the values of the given protocols."""
44
+ params = {RoborockZeoProtocol.ID_QUERY: [int(p) for p in protocols]}
45
+ return await send_decoded_command(self._channel, params)
46
+
47
+ async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]:
48
+ """Set a value for a specific protocol on the device."""
49
+ params = {protocol: value}
50
+ return await send_decoded_command(self._channel, params)
51
+
52
+
53
+ def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | ZeoApi:
54
+ """Create traits for A01 devices."""
55
+ match product.category:
56
+ case RoborockCategory.WET_DRY_VAC:
57
+ return DyadApi(mqtt_channel)
58
+ case RoborockCategory.WASHING_MACHINE:
59
+ return ZeoApi(mqtt_channel)
60
+ case _:
61
+ raise NotImplementedError(f"Unsupported category {product.category}")
@@ -1,26 +1,20 @@
1
- from __future__ import annotations
2
-
3
- import logging
1
+ """Traits for B01 devices."""
4
2
 
5
3
  from roborock import RoborockB01Methods
4
+ from roborock.devices.b01_channel import send_decoded_command
5
+ from roborock.devices.mqtt_channel import MqttChannel
6
+ from roborock.devices.traits import Trait
6
7
  from roborock.roborock_message import RoborockB01Props
7
8
 
8
- from ...b01_channel import send_decoded_command
9
- from ...mqtt_channel import MqttChannel
10
- from ..trait import Trait
11
-
12
- _LOGGER = logging.getLogger(__name__)
13
-
14
- __all__ = [
15
- "B01PropsApi",
9
+ __init__ = [
10
+ "create_b01_traits",
11
+ "PropertiesApi",
16
12
  ]
17
13
 
18
14
 
19
- class B01PropsApi(Trait):
15
+ class PropertiesApi(Trait):
20
16
  """API for interacting with B01 devices."""
21
17
 
22
- name = "B01_props"
23
-
24
18
  def __init__(self, channel: MqttChannel) -> None:
25
19
  """Initialize the B01Props API."""
26
20
  self._channel = channel
@@ -30,3 +24,8 @@ class B01PropsApi(Trait):
30
24
  await send_decoded_command(
31
25
  self._channel, dps=10000, command=RoborockB01Methods.GET_PROP, params={"property": props}
32
26
  )
27
+
28
+
29
+ def create(channel: MqttChannel) -> PropertiesApi:
30
+ """Create traits for B01 devices."""
31
+ return PropertiesApi(channel)
@@ -0,0 +1,61 @@
1
+ """Holds device traits mixin and related code.
2
+
3
+ This holds the TraitsMixin class, which is used to provide accessors for
4
+ various device traits. Each trait is a class that encapsulates a specific
5
+ set of functionality for a device, such as controlling a vacuum or a mop.
6
+
7
+ The TraitsMixin holds traits across all protocol types. A trait is supported
8
+ if it is non-None.
9
+ """
10
+
11
+ from dataclasses import dataclass, fields
12
+ from typing import get_args, get_origin
13
+
14
+ from . import Trait, a01, b01, v1
15
+
16
+ __all__ = [
17
+ "TraitsMixin",
18
+ ]
19
+
20
+
21
+ @dataclass(init=False)
22
+ class TraitsMixin:
23
+ """Mixin to provide trait accessors."""
24
+
25
+ v1_properties: v1.PropertiesApi | None = None
26
+ """V1 properties trait, if supported."""
27
+
28
+ dyad: a01.DyadApi | None = None
29
+ """Dyad API, if supported."""
30
+
31
+ zeo: a01.ZeoApi | None = None
32
+ """Zeo API, if supported."""
33
+
34
+ b01_properties: b01.PropertiesApi | None = None
35
+ """B01 properties trait, if supported."""
36
+
37
+ def __init__(self, trait: Trait) -> None:
38
+ """Initialize the TraitsMixin with the given trait.
39
+
40
+ This will populate the appropriate trait attributes based on the types
41
+ of the traits provided.
42
+ """
43
+ for item in fields(self):
44
+ trait_type = _get_trait_type(item)
45
+ if trait_type == type(trait):
46
+ setattr(self, item.name, trait)
47
+ break
48
+
49
+
50
+ def _get_trait_type(item) -> type[Trait]:
51
+ """Get the trait type from a dataclass field."""
52
+ if get_origin(item.type) is None:
53
+ raise ValueError(f"Trait {item.name} is not an optional type")
54
+ if (args := get_args(item.type)) is None:
55
+ raise ValueError(f"Trait {item.name} is not an optional type")
56
+ if len(args) != 2 or args[1] is not type(None):
57
+ raise ValueError(f"Trait {item.name} is not an optional type")
58
+ trait_type = args[0]
59
+ if not issubclass(trait_type, Trait):
60
+ raise ValueError(f"Trait {item.name} is not a Trait subclass")
61
+ return trait_type