python-roborock 2.46.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.46.0 → python_roborock-2.47.0}/PKG-INFO +1 -1
  2. {python_roborock-2.46.0 → python_roborock-2.47.0}/pyproject.toml +1 -1
  3. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/api.py +1 -4
  4. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/cli.py +23 -38
  5. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/device.py +10 -13
  6. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/device_manager.py +7 -25
  7. python_roborock-2.47.0/roborock/devices/traits/__init__.py +15 -0
  8. python_roborock-2.47.0/roborock/devices/traits/a01/__init__.py +61 -0
  9. python_roborock-2.46.0/roborock/devices/traits/b01/props.py → python_roborock-2.47.0/roborock/devices/traits/b01/__init__.py +13 -14
  10. python_roborock-2.47.0/roborock/devices/traits/traits_mixin.py +61 -0
  11. python_roborock-2.47.0/roborock/devices/traits/v1/__init__.py +57 -0
  12. python_roborock-2.47.0/roborock/devices/traits/v1/clean_summary.py +29 -0
  13. python_roborock-2.47.0/roborock/devices/traits/v1/common.py +115 -0
  14. python_roborock-2.47.0/roborock/devices/traits/v1/do_not_disturb.py +17 -0
  15. python_roborock-2.47.0/roborock/devices/traits/v1/status.py +24 -0
  16. python_roborock-2.47.0/roborock/devices/traits/v1/volume.py +26 -0
  17. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/protocol.py +44 -11
  18. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/protocols/v1_protocol.py +2 -3
  19. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/util.py +1 -1
  20. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_client_v1.py +7 -1
  21. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_local_client_v1.py +82 -24
  22. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/web_api.py +2 -2
  23. python_roborock-2.46.0/roborock/devices/traits/b01/__init__.py +0 -0
  24. python_roborock-2.46.0/roborock/devices/traits/clean_summary.py +0 -52
  25. python_roborock-2.46.0/roborock/devices/traits/dnd.py +0 -41
  26. python_roborock-2.46.0/roborock/devices/traits/dyad.py +0 -36
  27. python_roborock-2.46.0/roborock/devices/traits/sound_volume.py +0 -31
  28. python_roborock-2.46.0/roborock/devices/traits/status.py +0 -49
  29. python_roborock-2.46.0/roborock/devices/traits/trait.py +0 -10
  30. python_roborock-2.46.0/roborock/devices/traits/zeo.py +0 -36
  31. {python_roborock-2.46.0 → python_roborock-2.47.0}/LICENSE +0 -0
  32. {python_roborock-2.46.0 → python_roborock-2.47.0}/README.md +0 -0
  33. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/__init__.py +0 -0
  34. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/b01_containers.py +0 -0
  35. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/broadcast_protocol.py +0 -0
  36. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/callbacks.py +0 -0
  37. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/clean_modes.py +0 -0
  38. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/cloud_api.py +0 -0
  39. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/code_mappings.py +0 -0
  40. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/command_cache.py +0 -0
  41. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/const.py +0 -0
  42. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/containers.py +0 -0
  43. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/device_features.py +0 -0
  44. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/README.md +0 -0
  45. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/__init__.py +0 -0
  46. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/a01_channel.py +0 -0
  47. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/b01_channel.py +0 -0
  48. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/cache.py +0 -0
  49. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/channel.py +0 -0
  50. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/local_channel.py +0 -0
  51. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/mqtt_channel.py +0 -0
  52. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/v1_channel.py +0 -0
  53. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/v1_rpc_channel.py +0 -0
  54. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/exceptions.py +0 -0
  55. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/mqtt/__init__.py +0 -0
  56. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/mqtt/roborock_session.py +0 -0
  57. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/mqtt/session.py +0 -0
  58. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/protocols/a01_protocol.py +0 -0
  59. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/protocols/b01_protocol.py +0 -0
  60. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/py.typed +0 -0
  61. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/roborock_future.py +0 -0
  62. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/roborock_message.py +0 -0
  63. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/roborock_typing.py +0 -0
  64. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_1_apis/__init__.py +0 -0
  65. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  66. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_a01_apis/__init__.py +0 -0
  67. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  68. {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 2.46.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.46.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,7 +18,6 @@ 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
 
@@ -91,9 +90,7 @@ class RoborockClient(ABC):
91
90
 
92
91
  def _async_response(self, request_id: int, protocol_id: int = 0) -> Any:
93
92
  queue = RoborockFuture(protocol_id)
94
- if request_id in self._waiting_queue and not (
95
- request_id == 2 and protocol_id == RoborockMessageProtocol.PING_REQUEST
96
- ):
93
+ if request_id in self._waiting_queue:
97
94
  new_id = get_next_int(10000, 32767)
98
95
  self._logger.warning(
99
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
 
@@ -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
@@ -0,0 +1,57 @@
1
+ """Create traits for V1 devices."""
2
+
3
+ from dataclasses import dataclass, field, fields
4
+
5
+ from roborock.containers import HomeDataProduct
6
+ from roborock.devices.traits import Trait
7
+ from roborock.devices.v1_rpc_channel import V1RpcChannel
8
+
9
+ from .clean_summary import CleanSummaryTrait
10
+ from .common import V1TraitMixin
11
+ from .do_not_disturb import DoNotDisturbTrait
12
+ from .status import StatusTrait
13
+ from .volume import SoundVolumeTrait
14
+
15
+ __all__ = [
16
+ "create",
17
+ "PropertiesApi",
18
+ "StatusTrait",
19
+ "DoNotDisturbTrait",
20
+ "CleanSummaryTrait",
21
+ "SoundVolumeTrait",
22
+ ]
23
+
24
+
25
+ @dataclass
26
+ class PropertiesApi(Trait):
27
+ """Common properties for V1 devices.
28
+
29
+ This class holds all the traits that are common across all V1 devices.
30
+ """
31
+
32
+ # All v1 devices have these traits
33
+ status: StatusTrait
34
+ dnd: DoNotDisturbTrait
35
+ clean_summary: CleanSummaryTrait
36
+ sound_volume: SoundVolumeTrait
37
+
38
+ # In the future optional fields can be added below based on supported features
39
+
40
+ def __init__(self, product: HomeDataProduct, rpc_channel: V1RpcChannel) -> None:
41
+ """Initialize the V1TraitProps with None values."""
42
+ self.status = StatusTrait(product)
43
+
44
+ # This is a hack to allow setting the rpc_channel on all traits. This is
45
+ # used so we can preserve the dataclass behavior when the values in the
46
+ # traits are updated, but still want to allow them to have a reference
47
+ # to the rpc channel for sending commands.
48
+ for item in fields(self):
49
+ if (trait := getattr(self, item.name, None)) is None:
50
+ trait = item.type()
51
+ setattr(self, item.name, trait)
52
+ trait._rpc_channel = rpc_channel
53
+
54
+
55
+ def create(product: HomeDataProduct, rpc_channel: V1RpcChannel) -> PropertiesApi:
56
+ """Create traits for V1 devices."""
57
+ return PropertiesApi(product, rpc_channel)
@@ -0,0 +1,29 @@
1
+ from typing import Self
2
+
3
+ from roborock.containers import CleanSummary
4
+ from roborock.devices.traits.v1 import common
5
+ from roborock.roborock_typing import RoborockCommand
6
+ from roborock.util import unpack_list
7
+
8
+
9
+ class CleanSummaryTrait(CleanSummary, common.V1TraitMixin):
10
+ """Trait for managing the clean summary of Roborock devices."""
11
+
12
+ command = RoborockCommand.GET_CLEAN_SUMMARY
13
+
14
+ @classmethod
15
+ def _parse_type_response(cls, response: common.V1ResponseData) -> Self:
16
+ """Parse the response from the device into a CleanSummary."""
17
+ if isinstance(response, dict):
18
+ return cls.from_dict(response)
19
+ elif isinstance(response, list):
20
+ clean_time, clean_area, clean_count, records = unpack_list(response, 4)
21
+ return cls(
22
+ clean_time=clean_time,
23
+ clean_area=clean_area,
24
+ clean_count=clean_count,
25
+ records=records,
26
+ )
27
+ elif isinstance(response, int):
28
+ return cls(clean_time=response)
29
+ raise ValueError(f"Unexpected clean summary format: {response!r}")
@@ -0,0 +1,115 @@
1
+ """Module for Roborock V1 devices common trait commands.
2
+
3
+ This is an internal library and should not be used directly by consumers.
4
+ """
5
+
6
+ from abc import ABC
7
+ from dataclasses import asdict, dataclass, fields
8
+ from typing import ClassVar, Self
9
+
10
+ from roborock.containers import RoborockBase
11
+ from roborock.devices.v1_rpc_channel import V1RpcChannel
12
+ from roborock.roborock_typing import RoborockCommand
13
+
14
+ V1ResponseData = dict | list | int | str
15
+
16
+
17
+ @dataclass
18
+ class V1TraitMixin(ABC):
19
+ """Base model that supports v1 traits.
20
+
21
+ This class provides functioanlity for parsing responses from V1 devices
22
+ into dataclass instances. It also provides a reference to the V1RpcChannel
23
+ used to communicate with the device to execute commands.
24
+
25
+ Each trait subclass must define a class variable `command` that specifies
26
+ the RoborockCommand used to fetch the trait data from the device. The
27
+ `refresh()` method can be called to update the contents of the trait data
28
+ from the device. A trait can also support additional commands for updating
29
+ state associated with the trait.
30
+
31
+ The traits typically subclass RoborockBase to provide serialization
32
+ and deserialization functionality, but this is not strictly required.
33
+ """
34
+
35
+ command: ClassVar[RoborockCommand]
36
+
37
+ @classmethod
38
+ def _parse_type_response(cls, response: V1ResponseData) -> Self:
39
+ """Parse the response from the device into a a RoborockBase.
40
+
41
+ Subclasses should override this method to implement custom parsing
42
+ logic as needed.
43
+ """
44
+ if not issubclass(cls, RoborockBase):
45
+ raise NotImplementedError(f"Trait {cls} does not implement RoborockBase")
46
+ # Subclasses can override to implement custom parsing logic
47
+ if isinstance(response, list):
48
+ response = response[0]
49
+ if not isinstance(response, dict):
50
+ raise ValueError(f"Unexpected {cls} response format: {response!r}")
51
+ return cls.from_dict(response)
52
+
53
+ def _parse_response(self, response: V1ResponseData) -> Self:
54
+ """Parse the response from the device into a a RoborockBase.
55
+
56
+ This is used by subclasses that want to override the class
57
+ behavior with instance-specific data.
58
+ """
59
+ return self._parse_type_response(response)
60
+
61
+ def __post_init__(self) -> None:
62
+ """Post-initialization to set up the RPC channel.
63
+
64
+ This is called automatically after the dataclass is initialized by the
65
+ device setup code.
66
+ """
67
+ self._rpc_channel = None
68
+
69
+ @property
70
+ def rpc_channel(self) -> V1RpcChannel:
71
+ """Helper for executing commands, used internally by the trait"""
72
+ if not self._rpc_channel:
73
+ raise ValueError("Device trait in invalid state")
74
+ return self._rpc_channel
75
+
76
+ async def refresh(self) -> Self:
77
+ """Refresh the contents of this trait."""
78
+ response = await self.rpc_channel.send_command(self.command)
79
+ new_data = self._parse_response(response)
80
+ for k, v in asdict(new_data).items():
81
+ if v is not None:
82
+ setattr(self, k, v)
83
+ return self
84
+
85
+
86
+ def _get_value_field(clazz: type[V1TraitMixin]) -> str:
87
+ """Get the name of the field marked as the main value of the RoborockValueBase."""
88
+ value_fields = [field.name for field in fields(clazz) if field.metadata.get("roborock_value", False)]
89
+ if len(value_fields) != 1:
90
+ raise ValueError(
91
+ f"RoborockValueBase subclass {clazz} must have exactly one field marked as roborock_value, "
92
+ f" but found: {value_fields}"
93
+ )
94
+ return value_fields[0]
95
+
96
+
97
+ @dataclass(init=False, kw_only=True)
98
+ class RoborockValueBase(V1TraitMixin, RoborockBase):
99
+ """Base class for traits that represent a single value.
100
+
101
+ This class is intended to be subclassed by traits that represent a single
102
+ value, such as volume or brightness. The subclass should define a single
103
+ field with the metadata `roborock_value=True` to indicate which field
104
+ represents the main value of the trait.
105
+ """
106
+
107
+ @classmethod
108
+ def _parse_response(cls, response: V1ResponseData) -> Self:
109
+ """Parse the response from the device into a RoborockValueBase."""
110
+ if isinstance(response, list):
111
+ response = response[0]
112
+ if not isinstance(response, int):
113
+ raise ValueError(f"Unexpected response format: {response!r}")
114
+ value_field = _get_value_field(cls)
115
+ return cls(**{value_field: response})
@@ -0,0 +1,17 @@
1
+ from roborock.containers import DnDTimer
2
+ from roborock.devices.traits.v1 import common
3
+ from roborock.roborock_typing import RoborockCommand
4
+
5
+
6
+ class DoNotDisturbTrait(DnDTimer, common.V1TraitMixin):
7
+ """Trait for managing Do Not Disturb (DND) settings on Roborock devices."""
8
+
9
+ command = RoborockCommand.GET_DND_TIMER
10
+
11
+ async def set_dnd_timer(self, dnd_timer: DnDTimer) -> None:
12
+ """Set the Do Not Disturb (DND) timer settings of the device."""
13
+ await self.rpc_channel.send_command(RoborockCommand.SET_DND_TIMER, params=dnd_timer.as_dict())
14
+
15
+ async def clear_dnd_timer(self) -> None:
16
+ """Clear the Do Not Disturb (DND) timer settings of the device."""
17
+ await self.rpc_channel.send_command(RoborockCommand.CLOSE_DND_TIMER)