python-roborock 4.21.0__tar.gz → 4.23.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 (98) hide show
  1. {python_roborock-4.21.0 → python_roborock-4.23.0}/PKG-INFO +1 -1
  2. {python_roborock-4.21.0 → python_roborock-4.23.0}/pyproject.toml +1 -1
  3. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/b01_q7/b01_q7_containers.py +27 -0
  4. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/rpc/b01_q7_channel.py +76 -34
  5. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/b01/q7/__init__.py +22 -2
  6. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/b01/q7/clean_summary.py +2 -2
  7. python_roborock-4.23.0/roborock/devices/traits/b01/q7/map.py +59 -0
  8. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/child_lock.py +1 -0
  9. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/clean_summary.py +37 -30
  10. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/common.py +65 -62
  11. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/consumeable.py +1 -0
  12. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/device_features.py +24 -14
  13. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/do_not_disturb.py +1 -0
  14. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/dust_collection_mode.py +1 -0
  15. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/flow_led_status.py +1 -0
  16. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/home.py +2 -6
  17. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/led_status.py +18 -14
  18. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/map_content.py +20 -11
  19. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/maps.py +19 -15
  20. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/network_info.py +12 -7
  21. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/rooms.py +56 -42
  22. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/smart_wash_params.py +1 -0
  23. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/status.py +1 -9
  24. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/valley_electricity_timer.py +1 -0
  25. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/volume.py +5 -7
  26. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/wash_towel_mode.py +1 -0
  27. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/protocols/b01_q7_protocol.py +1 -0
  28. {python_roborock-4.21.0 → python_roborock-4.23.0}/.gitignore +0 -0
  29. {python_roborock-4.21.0 → python_roborock-4.23.0}/LICENSE +0 -0
  30. {python_roborock-4.21.0 → python_roborock-4.23.0}/README.md +0 -0
  31. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/__init__.py +0 -0
  32. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/broadcast_protocol.py +0 -0
  33. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/callbacks.py +0 -0
  34. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/cli.py +0 -0
  35. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/const.py +0 -0
  36. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/__init__.py +0 -0
  37. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/b01_q10/__init__.py +0 -0
  38. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  39. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  40. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/b01_q7/__init__.py +0 -0
  41. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  42. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/code_mappings.py +0 -0
  43. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/containers.py +0 -0
  44. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/dyad/__init__.py +0 -0
  45. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  46. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/dyad/dyad_containers.py +0 -0
  47. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/v1/__init__.py +0 -0
  48. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  49. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  50. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/v1/v1_containers.py +0 -0
  51. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/zeo/__init__.py +0 -0
  52. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  53. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/data/zeo/zeo_containers.py +0 -0
  54. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/device_features.py +0 -0
  55. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/README.md +0 -0
  56. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/__init__.py +0 -0
  57. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/cache.py +0 -0
  58. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/device.py +0 -0
  59. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/device_manager.py +0 -0
  60. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/file_cache.py +0 -0
  61. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/rpc/__init__.py +0 -0
  62. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/rpc/a01_channel.py +0 -0
  63. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  64. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/rpc/v1_channel.py +0 -0
  65. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/__init__.py +0 -0
  66. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/a01/__init__.py +0 -0
  67. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/b01/__init__.py +0 -0
  68. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  69. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  70. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  71. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  72. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  73. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/traits_mixin.py +0 -0
  74. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/__init__.py +0 -0
  75. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/command.py +0 -0
  76. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/traits/v1/routines.py +0 -0
  77. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/transport/__init__.py +0 -0
  78. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/transport/channel.py +0 -0
  79. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/transport/local_channel.py +0 -0
  80. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  81. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/diagnostics.py +0 -0
  82. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/exceptions.py +0 -0
  83. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/map/__init__.py +0 -0
  84. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/map/map_parser.py +0 -0
  85. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/mqtt/__init__.py +0 -0
  86. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/mqtt/health_manager.py +0 -0
  87. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/mqtt/roborock_session.py +0 -0
  88. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/mqtt/session.py +0 -0
  89. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/protocol.py +0 -0
  90. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/protocols/__init__.py +0 -0
  91. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/protocols/a01_protocol.py +0 -0
  92. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  93. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/protocols/v1_protocol.py +0 -0
  94. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/py.typed +0 -0
  95. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/roborock_message.py +0 -0
  96. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/roborock_typing.py +0 -0
  97. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/util.py +0 -0
  98. {python_roborock-4.21.0 → python_roborock-4.23.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 4.21.0
3
+ Version: 4.23.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/humbertogontijo/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-roborock"
3
- version = "4.21.0"
3
+ version = "4.23.0"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
6
6
  requires-python = ">=3.11, <4"
@@ -75,6 +75,33 @@ class Recommend(RoborockBase):
75
75
  room_id: list[int] = field(default_factory=list)
76
76
 
77
77
 
78
+ @dataclass
79
+ class Q7MapListEntry(RoborockBase):
80
+ """Single map list entry returned by `service.get_map_list`."""
81
+
82
+ id: int | None = None
83
+ cur: bool | None = None
84
+
85
+
86
+ @dataclass
87
+ class Q7MapList(RoborockBase):
88
+ """Map list response returned by `service.get_map_list`."""
89
+
90
+ map_list: list[Q7MapListEntry] = field(default_factory=list)
91
+
92
+ @property
93
+ def current_map_id(self) -> int | None:
94
+ """Current map id, preferring the entry marked current."""
95
+ if not self.map_list:
96
+ return None
97
+
98
+ ordered = sorted(self.map_list, key=lambda entry: entry.cur or False, reverse=True)
99
+ first = next(iter(ordered), None)
100
+ if first is None or not isinstance(first.id, int):
101
+ return None
102
+ return first.id
103
+
104
+
78
105
  @dataclass
79
106
  class B01Props(RoborockBase):
80
107
  """
@@ -5,31 +5,67 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
- from typing import Any
8
+ from collections.abc import Callable
9
+ from typing import Any, TypeVar
9
10
 
10
11
  from roborock.devices.transport.mqtt_channel import MqttChannel
11
12
  from roborock.exceptions import RoborockException
12
- from roborock.protocols.b01_q7_protocol import (
13
- Q7RequestMessage,
14
- decode_rpc_response,
15
- encode_mqtt_payload,
16
- )
17
- from roborock.roborock_message import RoborockMessage
13
+ from roborock.protocols.b01_q7_protocol import B01_VERSION, Q7RequestMessage, decode_rpc_response, encode_mqtt_payload
14
+ from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
18
15
 
19
16
  _LOGGER = logging.getLogger(__name__)
20
17
  _TIMEOUT = 10.0
18
+ _T = TypeVar("_T")
19
+
20
+
21
+ def _matches_map_response(response_message: RoborockMessage, *, version: bytes | None) -> bytes | None:
22
+ """Return raw map payload bytes for matching MAP_RESPONSE messages."""
23
+ if (
24
+ response_message.protocol == RoborockMessageProtocol.MAP_RESPONSE
25
+ and response_message.payload
26
+ and response_message.version == version
27
+ ):
28
+ return response_message.payload
29
+ return None
30
+
31
+
32
+ async def _send_command(
33
+ mqtt_channel: MqttChannel,
34
+ request_message: Q7RequestMessage,
35
+ *,
36
+ response_matcher: Callable[[RoborockMessage], _T | None],
37
+ ) -> _T:
38
+ """Publish a B01 command and resolve on the first matching response."""
39
+ roborock_message = encode_mqtt_payload(request_message)
40
+ future: asyncio.Future[_T] = asyncio.get_running_loop().create_future()
41
+
42
+ def on_message(response_message: RoborockMessage) -> None:
43
+ if future.done():
44
+ return
45
+ try:
46
+ response = response_matcher(response_message)
47
+ except Exception as ex:
48
+ future.set_exception(ex)
49
+ return
50
+ if response is not None:
51
+ future.set_result(response)
52
+
53
+ unsub = await mqtt_channel.subscribe(on_message)
54
+ try:
55
+ await mqtt_channel.publish(roborock_message)
56
+ return await asyncio.wait_for(future, timeout=_TIMEOUT)
57
+ finally:
58
+ unsub()
21
59
 
22
60
 
23
61
  async def send_decoded_command(
24
62
  mqtt_channel: MqttChannel,
25
63
  request_message: Q7RequestMessage,
26
- ) -> dict[str, Any] | None:
64
+ ) -> Any:
27
65
  """Send a command on the MQTT channel and get a decoded response."""
28
66
  _LOGGER.debug("Sending B01 MQTT command: %s", request_message)
29
- roborock_message = encode_mqtt_payload(request_message)
30
- future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
31
67
 
32
- def find_response(response_message: RoborockMessage) -> None:
68
+ def find_response(response_message: RoborockMessage) -> Any | None:
33
69
  """Handle incoming messages and resolve the future."""
34
70
  try:
35
71
  decoded_dps = decode_rpc_response(response_message)
@@ -41,7 +77,7 @@ async def send_decoded_command(
41
77
  response_message,
42
78
  ex,
43
79
  )
44
- return
80
+ return None
45
81
  for dps_value in decoded_dps.values():
46
82
  # valid responses are JSON strings wrapped in the dps value
47
83
  if not isinstance(dps_value, str):
@@ -55,31 +91,23 @@ async def send_decoded_command(
55
91
  continue
56
92
  if isinstance(inner, dict) and inner.get("msgId") == str(request_message.msg_id):
57
93
  _LOGGER.debug("Received query response: %s", inner)
58
- # Check for error code (0 = success, non-zero = error)
59
94
  code = inner.get("code", 0)
60
95
  if code != 0:
61
96
  error_msg = f"B01 command failed with code {code} ({request_message})"
62
97
  _LOGGER.debug("B01 error response: %s", error_msg)
63
- if not future.done():
64
- future.set_exception(RoborockException(error_msg))
65
- return
98
+ raise RoborockException(error_msg)
66
99
  data = inner.get("data")
67
- # All get commands should be dicts
68
- if request_message.command.endswith(".get") and not isinstance(data, dict):
69
- if not future.done():
70
- future.set_exception(
71
- RoborockException(f"Unexpected data type for response {data} ({request_message})")
72
- )
73
- return
74
- if not future.done():
75
- future.set_result(data)
76
-
77
- unsub = await mqtt_channel.subscribe(find_response)
78
-
79
- _LOGGER.debug("Sending MQTT message: %s", roborock_message)
100
+ if request_message.command == "prop.get" and not isinstance(data, dict):
101
+ raise RoborockException(f"Unexpected data type for response {data} ({request_message})")
102
+ return data
103
+ return None
104
+
80
105
  try:
81
- await mqtt_channel.publish(roborock_message)
82
- return await asyncio.wait_for(future, timeout=_TIMEOUT)
106
+ return await _send_command(
107
+ mqtt_channel,
108
+ request_message,
109
+ response_matcher=find_response,
110
+ )
83
111
  except TimeoutError as ex:
84
112
  raise RoborockException(f"B01 command timed out after {_TIMEOUT}s ({request_message})") from ex
85
113
  except RoborockException as ex:
@@ -89,7 +117,6 @@ async def send_decoded_command(
89
117
  ex,
90
118
  )
91
119
  raise
92
-
93
120
  except Exception as ex:
94
121
  _LOGGER.exception(
95
122
  "Error sending B01 decoded command (%ss): %s",
@@ -97,5 +124,20 @@ async def send_decoded_command(
97
124
  ex,
98
125
  )
99
126
  raise
100
- finally:
101
- unsub()
127
+
128
+
129
+ async def send_map_command(mqtt_channel: MqttChannel, request_message: Q7RequestMessage) -> bytes:
130
+ """Send map upload command and wait for MAP_RESPONSE payload bytes.
131
+
132
+ This stays separate from ``send_decoded_command()`` because map uploads arrive as
133
+ raw ``MAP_RESPONSE`` payload bytes instead of a decoded RPC ``data`` payload.
134
+ """
135
+
136
+ try:
137
+ return await _send_command(
138
+ mqtt_channel,
139
+ request_message,
140
+ response_matcher=lambda response_message: _matches_map_response(response_message, version=B01_VERSION),
141
+ )
142
+ except TimeoutError as ex:
143
+ raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex
@@ -4,6 +4,7 @@ Potentially other devices may fall into this category in the future."""
4
4
  from typing import Any
5
5
 
6
6
  from roborock import B01Props
7
+ from roborock.data import Q7MapList, Q7MapListEntry
7
8
  from roborock.data.b01_q7.b01_q7_code_mappings import (
8
9
  CleanPathPreferenceMapping,
9
10
  CleanRepeatMapping,
@@ -16,15 +17,19 @@ from roborock.data.b01_q7.b01_q7_code_mappings import (
16
17
  from roborock.devices.rpc.b01_q7_channel import send_decoded_command
17
18
  from roborock.devices.traits import Trait
18
19
  from roborock.devices.transport.mqtt_channel import MqttChannel
19
- from roborock.protocols.b01_q7_protocol import CommandType, ParamsType, Q7RequestMessage
20
+ from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, CommandType, ParamsType, Q7RequestMessage
20
21
  from roborock.roborock_message import RoborockB01Props
21
22
  from roborock.roborock_typing import RoborockB01Q7Methods
22
23
 
23
24
  from .clean_summary import CleanSummaryTrait
25
+ from .map import MapTrait
24
26
 
25
27
  __all__ = [
26
28
  "Q7PropertiesApi",
27
29
  "CleanSummaryTrait",
30
+ "MapTrait",
31
+ "Q7MapList",
32
+ "Q7MapListEntry",
28
33
  ]
29
34
 
30
35
 
@@ -34,10 +39,14 @@ class Q7PropertiesApi(Trait):
34
39
  clean_summary: CleanSummaryTrait
35
40
  """Trait for clean records / clean summary (Q7 `service.get_record_list`)."""
36
41
 
42
+ map: MapTrait
43
+ """Trait for map list metadata + raw map payload retrieval."""
44
+
37
45
  def __init__(self, channel: MqttChannel) -> None:
38
46
  """Initialize the B01Props API."""
39
47
  self._channel = channel
40
48
  self.clean_summary = CleanSummaryTrait(channel)
49
+ self.map = MapTrait(channel)
41
50
 
42
51
  async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
43
52
  """Query the device for the values of the given Q7 properties."""
@@ -87,6 +96,17 @@ class Q7PropertiesApi(Trait):
87
96
  },
88
97
  )
89
98
 
99
+ async def clean_segments(self, segment_ids: list[int]) -> None:
100
+ """Start segment cleaning for the given ids (Q7 uses room ids)."""
101
+ await self.send(
102
+ command=RoborockB01Q7Methods.SET_ROOM_CLEAN,
103
+ params={
104
+ "clean_type": CleanTaskTypeMapping.ROOM.code,
105
+ "ctrl_value": SCDeviceCleanParam.START.code,
106
+ "room_ids": segment_ids,
107
+ },
108
+ )
109
+
90
110
  async def pause_clean(self) -> None:
91
111
  """Pause cleaning."""
92
112
  await self.send(
@@ -127,7 +147,7 @@ class Q7PropertiesApi(Trait):
127
147
  """Send a command to the device."""
128
148
  return await send_decoded_command(
129
149
  self._channel,
130
- Q7RequestMessage(dps=10000, command=command, params=params),
150
+ Q7RequestMessage(dps=B01_Q7_DPS, command=command, params=params),
131
151
  )
132
152
 
133
153
 
@@ -13,7 +13,7 @@ from roborock.devices.rpc.b01_q7_channel import send_decoded_command
13
13
  from roborock.devices.traits import Trait
14
14
  from roborock.devices.transport.mqtt_channel import MqttChannel
15
15
  from roborock.exceptions import RoborockException
16
- from roborock.protocols.b01_q7_protocol import Q7RequestMessage
16
+ from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, Q7RequestMessage
17
17
  from roborock.roborock_typing import RoborockB01Q7Methods
18
18
 
19
19
  __all__ = [
@@ -50,7 +50,7 @@ class CleanSummaryTrait(CleanRecordSummary, Trait):
50
50
  """Fetch the raw device clean record list (`service.get_record_list`)."""
51
51
  result = await send_decoded_command(
52
52
  self._channel,
53
- Q7RequestMessage(dps=10000, command=RoborockB01Q7Methods.GET_RECORD_LIST, params={}),
53
+ Q7RequestMessage(dps=B01_Q7_DPS, command=RoborockB01Q7Methods.GET_RECORD_LIST, params={}),
54
54
  )
55
55
 
56
56
  if not isinstance(result, dict):
@@ -0,0 +1,59 @@
1
+ """Map trait for B01 Q7 devices."""
2
+
3
+ import asyncio
4
+
5
+ from roborock.data import Q7MapList
6
+ from roborock.devices.rpc.b01_q7_channel import send_decoded_command, send_map_command
7
+ from roborock.devices.traits import Trait
8
+ from roborock.devices.transport.mqtt_channel import MqttChannel
9
+ from roborock.exceptions import RoborockException
10
+ from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, Q7RequestMessage
11
+ from roborock.roborock_typing import RoborockB01Q7Methods
12
+
13
+
14
+ class MapTrait(Q7MapList, Trait):
15
+ """Map retrieval + map metadata helpers for Q7 devices."""
16
+
17
+ def __init__(self, channel: MqttChannel) -> None:
18
+ super().__init__()
19
+ self._channel = channel
20
+ # Map uploads are serialized per-device to avoid response cross-wiring.
21
+ self._map_command_lock = asyncio.Lock()
22
+ self._loaded = False
23
+
24
+ async def refresh(self) -> None:
25
+ """Refresh cached map list metadata from the device."""
26
+ response = await send_decoded_command(
27
+ self._channel,
28
+ Q7RequestMessage(dps=B01_Q7_DPS, command=RoborockB01Q7Methods.GET_MAP_LIST, params={}),
29
+ )
30
+ if not isinstance(response, dict):
31
+ raise RoborockException(
32
+ f"Unexpected response type for GET_MAP_LIST: {type(response).__name__}: {response!r}"
33
+ )
34
+
35
+ if (parsed := Q7MapList.from_dict(response)) is None:
36
+ raise RoborockException(f"Failed to decode map list response: {response!r}")
37
+
38
+ self.map_list = parsed.map_list
39
+ self._loaded = True
40
+
41
+ async def _get_map_payload(self, *, map_id: int) -> bytes:
42
+ """Fetch raw map payload bytes for the given map id."""
43
+ request = Q7RequestMessage(
44
+ dps=B01_Q7_DPS,
45
+ command=RoborockB01Q7Methods.UPLOAD_BY_MAPID,
46
+ params={"map_id": map_id},
47
+ )
48
+ async with self._map_command_lock:
49
+ return await send_map_command(self._channel, request)
50
+
51
+ async def get_current_map_payload(self) -> bytes:
52
+ """Fetch raw map payload bytes for the currently selected map."""
53
+ if not self._loaded:
54
+ await self.refresh()
55
+
56
+ map_id = self.current_map_id
57
+ if map_id is None:
58
+ raise RoborockException(f"Unable to determine map_id from map list response: {self!r}")
59
+ return await self._get_map_payload(map_id=map_id)
@@ -9,6 +9,7 @@ class ChildLockTrait(ChildLockStatus, common.V1TraitMixin, common.RoborockSwitch
9
9
  """Trait for controlling the child lock of a Roborock device."""
10
10
 
11
11
  command = RoborockCommand.GET_CHILD_LOCK_STATUS
12
+ converter = common.DefaultConverter(ChildLockStatus)
12
13
  requires_feature = "is_set_child_supported"
13
14
 
14
15
  @property
@@ -1,7 +1,6 @@
1
1
  import logging
2
- from typing import Self
3
2
 
4
- from roborock.data import CleanRecord, CleanSummaryWithDetail
3
+ from roborock.data import CleanRecord, CleanSummaryWithDetail, RoborockBase
5
4
  from roborock.devices.traits.v1 import common
6
5
  from roborock.roborock_typing import RoborockCommand
7
6
  from roborock.util import unpack_list
@@ -9,48 +8,30 @@ from roborock.util import unpack_list
9
8
  _LOGGER = logging.getLogger(__name__)
10
9
 
11
10
 
12
- class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin):
13
- """Trait for managing the clean summary of Roborock devices."""
14
-
15
- command = RoborockCommand.GET_CLEAN_SUMMARY
16
-
17
- async def refresh(self) -> None:
18
- """Refresh the clean summary data and last clean record.
19
-
20
- Assumes that the clean summary has already been fetched.
21
- """
22
- await super().refresh()
23
- if not self.records:
24
- _LOGGER.debug("No clean records available in clean summary.")
25
- self.last_clean_record = None
26
- return
27
- last_record_id = self.records[0]
28
- self.last_clean_record = await self.get_clean_record(last_record_id)
11
+ class CleanSummaryConverter(common.V1TraitDataConverter):
12
+ """Converter for CleanSummaryWithDetail objects."""
29
13
 
30
- @classmethod
31
- def _parse_type_response(cls, response: common.V1ResponseData) -> Self:
14
+ def convert(self, response: common.V1ResponseData) -> RoborockBase:
32
15
  """Parse the response from the device into a CleanSummary."""
33
16
  if isinstance(response, dict):
34
- return cls.from_dict(response)
17
+ return CleanSummaryWithDetail.from_dict(response)
35
18
  elif isinstance(response, list):
36
19
  clean_time, clean_area, clean_count, records = unpack_list(response, 4)
37
- return cls(
20
+ return CleanSummaryWithDetail(
38
21
  clean_time=clean_time,
39
22
  clean_area=clean_area,
40
23
  clean_count=clean_count,
41
24
  records=records,
42
25
  )
43
26
  elif isinstance(response, int):
44
- return cls(clean_time=response)
27
+ return CleanSummaryWithDetail(clean_time=response)
45
28
  raise ValueError(f"Unexpected clean summary format: {response!r}")
46
29
 
47
- async def get_clean_record(self, record_id: int) -> CleanRecord:
48
- """Load a specific clean record by ID."""
49
- response = await self.rpc_channel.send_command(RoborockCommand.GET_CLEAN_RECORD, params=[record_id])
50
- return self._parse_clean_record_response(response)
51
30
 
52
- @classmethod
53
- def _parse_clean_record_response(cls, response: common.V1ResponseData) -> CleanRecord:
31
+ class CleanRecordConverter(common.V1TraitDataConverter):
32
+ """Convert server responses to a CleanRecord."""
33
+
34
+ def convert(self, response: common.V1ResponseData) -> CleanRecord:
54
35
  """Parse the response from the device into a CleanRecord."""
55
36
  if isinstance(response, list) and len(response) == 1:
56
37
  response = response[0]
@@ -81,3 +62,29 @@ class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin):
81
62
  begin, end, duration, area = unpack_list(response, 4)
82
63
  return CleanRecord(begin=begin, end=end, duration=duration, area=area)
83
64
  raise ValueError(f"Unexpected clean record format: {response!r}")
65
+
66
+
67
+ class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin):
68
+ """Trait for managing the clean summary of Roborock devices."""
69
+
70
+ command = RoborockCommand.GET_CLEAN_SUMMARY
71
+ converter = CleanSummaryConverter()
72
+ clean_record_converter = CleanRecordConverter()
73
+
74
+ async def refresh(self) -> None:
75
+ """Refresh the clean summary data and last clean record.
76
+
77
+ Assumes that the clean summary has already been fetched.
78
+ """
79
+ await super().refresh()
80
+ if not self.records:
81
+ _LOGGER.debug("No clean records available in clean summary.")
82
+ self.last_clean_record = None
83
+ return
84
+ last_record_id = self.records[0]
85
+ self.last_clean_record = await self.get_clean_record(last_record_id)
86
+
87
+ async def get_clean_record(self, record_id: int) -> CleanRecord:
88
+ """Load a specific clean record by ID."""
89
+ response = await self.rpc_channel.send_command(RoborockCommand.GET_CLEAN_RECORD, params=[record_id])
90
+ return self.clean_record_converter.convert(response)
@@ -5,8 +5,8 @@ This is an internal library and should not be used directly by consumers.
5
5
 
6
6
  import logging
7
7
  from abc import ABC, abstractmethod
8
- from dataclasses import dataclass, fields
9
- from typing import ClassVar, Self
8
+ from dataclasses import fields
9
+ from typing import ClassVar
10
10
 
11
11
  from roborock.data import RoborockBase
12
12
  from roborock.protocols.v1_protocol import V1RpcChannel
@@ -14,10 +14,24 @@ from roborock.roborock_typing import RoborockCommand
14
14
 
15
15
  _LOGGER = logging.getLogger(__name__)
16
16
 
17
+
17
18
  V1ResponseData = dict | list | int | str
18
19
 
19
20
 
20
- @dataclass
21
+ class V1TraitDataConverter(ABC):
22
+ """Converts responses to RoborockBase objects.
23
+
24
+ This is an internal class and should not be used directly by consumers.
25
+ """
26
+
27
+ @abstractmethod
28
+ def convert(self, response: V1ResponseData) -> RoborockBase:
29
+ """Convert the values to a dict that can be parsed as a RoborockBase."""
30
+
31
+ def __repr__(self) -> str:
32
+ return self.__class__.__name__
33
+
34
+
21
35
  class V1TraitMixin(ABC):
22
36
  """Base model that supports v1 traits.
23
37
 
@@ -42,37 +56,13 @@ class V1TraitMixin(ABC):
42
56
  """
43
57
 
44
58
  command: ClassVar[RoborockCommand]
59
+ """The RoborockCommand used to fetch the trait data from the device (internal only)."""
45
60
 
46
- @classmethod
47
- def _parse_type_response(cls, response: V1ResponseData) -> RoborockBase:
48
- """Parse the response from the device into a a RoborockBase.
49
-
50
- Subclasses should override this method to implement custom parsing
51
- logic as needed.
52
- """
53
- if not issubclass(cls, RoborockBase):
54
- raise NotImplementedError(f"Trait {cls} does not implement RoborockBase")
55
- # Subclasses can override to implement custom parsing logic
56
- if isinstance(response, list):
57
- response = response[0]
58
- if not isinstance(response, dict):
59
- raise ValueError(f"Unexpected {cls} response format: {response!r}")
60
- return cls.from_dict(response)
61
-
62
- def _parse_response(self, response: V1ResponseData) -> RoborockBase:
63
- """Parse the response from the device into a a RoborockBase.
64
-
65
- This is used by subclasses that want to override the class
66
- behavior with instance-specific data.
67
- """
68
- return self._parse_type_response(response)
69
-
70
- def __post_init__(self) -> None:
71
- """Post-initialization to set up the RPC channel.
61
+ converter: V1TraitDataConverter
62
+ """The converter used to parse the response from the device (internal only)."""
72
63
 
73
- This is called automatically after the dataclass is initialized by the
74
- device setup code.
75
- """
64
+ def __init__(self) -> None:
65
+ """Initialize the V1TraitMixin."""
76
66
  self._rpc_channel = None
77
67
 
78
68
  @property
@@ -85,32 +75,42 @@ class V1TraitMixin(ABC):
85
75
  async def refresh(self) -> None:
86
76
  """Refresh the contents of this trait."""
87
77
  response = await self.rpc_channel.send_command(self.command)
88
- new_data = self._parse_response(response)
89
- if not isinstance(new_data, RoborockBase):
90
- raise ValueError(f"Internal error, unexpected response type: {new_data!r}")
91
- _LOGGER.debug("Refreshed %s: %s", self.__class__.__name__, new_data)
92
- self._update_trait_values(new_data)
93
-
94
- def _update_trait_values(self, new_data: RoborockBase) -> None:
95
- """Update the values of this trait from another instance."""
96
- for field in fields(new_data):
97
- new_value = getattr(new_data, field.name, None)
98
- setattr(self, field.name, new_value)
99
-
100
-
101
- def _get_value_field(clazz: type[V1TraitMixin]) -> str:
102
- """Get the name of the field marked as the main value of the RoborockValueBase."""
103
- value_fields = [field.name for field in fields(clazz) if field.metadata.get("roborock_value", False)]
104
- if len(value_fields) != 1:
105
- raise ValueError(
106
- f"RoborockValueBase subclass {clazz} must have exactly one field marked as roborock_value, "
107
- f" but found: {value_fields}"
108
- )
109
- return value_fields[0]
110
-
111
-
112
- @dataclass(init=False, kw_only=True)
113
- class RoborockValueBase(V1TraitMixin, RoborockBase):
78
+ new_data = self.converter.convert(response)
79
+ merge_trait_values(self, new_data) # type: ignore[arg-type]
80
+
81
+
82
+ def merge_trait_values(target: RoborockBase, new_object: RoborockBase) -> bool:
83
+ """Update the target object with set fields in new_object."""
84
+ updated = False
85
+ for field in fields(new_object):
86
+ old_value = getattr(target, field.name, None)
87
+ new_value = getattr(new_object, field.name, None)
88
+ if new_value != old_value:
89
+ setattr(target, field.name, new_value)
90
+ updated = True
91
+ return updated
92
+
93
+
94
+ class DefaultConverter(V1TraitDataConverter):
95
+ """Converts responses to RoborockBase objects."""
96
+
97
+ def __init__(self, dataclass_type: type[RoborockBase]) -> None:
98
+ """Initialize the converter."""
99
+ self._dataclass_type = dataclass_type
100
+
101
+ def convert(self, response: V1ResponseData) -> RoborockBase:
102
+ """Convert the values to a dict that can be parsed as a RoborockBase.
103
+
104
+ Subclasses can override to implement custom parsing logic
105
+ """
106
+ if isinstance(response, list):
107
+ response = response[0]
108
+ if not isinstance(response, dict):
109
+ raise ValueError(f"Unexpected {self._dataclass_type.__name__} response format: {response!r}")
110
+ return self._dataclass_type.from_dict(response)
111
+
112
+
113
+ class SingleValueConverter(DefaultConverter):
114
114
  """Base class for traits that represent a single value.
115
115
 
116
116
  This class is intended to be subclassed by traits that represent a single
@@ -119,15 +119,18 @@ class RoborockValueBase(V1TraitMixin, RoborockBase):
119
119
  represents the main value of the trait.
120
120
  """
121
121
 
122
- @classmethod
123
- def _parse_response(cls, response: V1ResponseData) -> Self:
122
+ def __init__(self, dataclass_type: type[RoborockBase], value_field: str) -> None:
123
+ """Initialize the converter."""
124
+ super().__init__(dataclass_type)
125
+ self._value_field = value_field
126
+
127
+ def convert(self, response: V1ResponseData) -> RoborockBase:
124
128
  """Parse the response from the device into a RoborockValueBase."""
125
129
  if isinstance(response, list):
126
130
  response = response[0]
127
131
  if not isinstance(response, int):
128
132
  raise ValueError(f"Unexpected response format: {response!r}")
129
- value_field = _get_value_field(cls)
130
- return cls(**{value_field: response})
133
+ return super().convert({self._value_field: response})
131
134
 
132
135
 
133
136
  class RoborockSwitchBase(ABC):
@@ -41,6 +41,7 @@ class ConsumableTrait(Consumable, common.V1TraitMixin):
41
41
  """
42
42
 
43
43
  command = RoborockCommand.GET_CONSUMABLE
44
+ converter = common.DefaultConverter(Consumable)
44
45
 
45
46
  async def reset_consumable(self, consumable: ConsumableAttribute) -> None:
46
47
  """Reset a specific consumable attribute on the device."""