python-roborock 5.35.0__tar.gz → 5.37.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 (127) hide show
  1. {python_roborock-5.35.0 → python_roborock-5.37.0}/PKG-INFO +1 -1
  2. {python_roborock-5.35.0 → python_roborock-5.37.0}/pyproject.toml +1 -1
  3. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/device_features.py +6 -0
  4. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/device_manager.py +1 -0
  5. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/rpc/a01_channel.py +13 -2
  6. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/rpc/v1_channel.py +52 -0
  7. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/__init__.py +17 -0
  8. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/common.py +1 -1
  9. python_roborock-5.37.0/roborock/devices/traits/v1/obstacle_photos.py +104 -0
  10. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/transport/mqtt_channel.py +7 -3
  11. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/mqtt/roborock_session.py +12 -6
  12. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/mqtt/session.py +25 -1
  13. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/protocols/a01_protocol.py +5 -1
  14. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/protocols/v1_protocol.py +45 -0
  15. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/roborock_typing.py +1 -0
  16. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/testing/channel.py +6 -1
  17. {python_roborock-5.35.0 → python_roborock-5.37.0}/.gitignore +0 -0
  18. {python_roborock-5.35.0 → python_roborock-5.37.0}/LICENSE +0 -0
  19. {python_roborock-5.35.0 → python_roborock-5.37.0}/README.md +0 -0
  20. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/__init__.py +0 -0
  21. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/broadcast_protocol.py +0 -0
  22. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/callbacks.py +0 -0
  23. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/cli.py +0 -0
  24. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/const.py +0 -0
  25. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/__init__.py +0 -0
  26. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/b01_q10/__init__.py +0 -0
  27. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  28. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  29. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/b01_q7/__init__.py +0 -0
  30. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  31. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  32. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/code_mappings.py +0 -0
  33. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/containers.py +0 -0
  34. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/dyad/__init__.py +0 -0
  35. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  36. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/dyad/dyad_containers.py +0 -0
  37. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/mower/__init__.py +0 -0
  38. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/mower/mower_containers.py +0 -0
  39. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/v1/__init__.py +0 -0
  40. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  41. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  42. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/v1/v1_containers.py +0 -0
  43. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/zeo/__init__.py +0 -0
  44. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  45. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/data/zeo/zeo_containers.py +0 -0
  46. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/README.md +0 -0
  47. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/__init__.py +0 -0
  48. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/cache.py +0 -0
  49. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/device.py +0 -0
  50. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/file_cache.py +0 -0
  51. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/rpc/__init__.py +0 -0
  52. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  53. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  54. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/__init__.py +0 -0
  55. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/a01/__init__.py +0 -0
  56. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/__init__.py +0 -0
  57. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  58. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  59. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  60. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  61. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  62. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  63. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  64. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  65. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  66. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/map.py +0 -0
  67. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  68. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
  69. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  70. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  71. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q10/volume.py +0 -0
  72. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  73. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  74. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  75. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  76. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/common.py +0 -0
  77. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/traits_mixin.py +0 -0
  78. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  79. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  80. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/command.py +0 -0
  81. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  82. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/device_features.py +0 -0
  83. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  84. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  85. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  86. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/home.py +0 -0
  87. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/led_status.py +0 -0
  88. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/map_content.py +0 -0
  89. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/maps.py +0 -0
  90. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/network_info.py +0 -0
  91. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/rooms.py +0 -0
  92. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/routines.py +0 -0
  93. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  94. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/status.py +0 -0
  95. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  96. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/volume.py +0 -0
  97. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  98. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/transport/__init__.py +0 -0
  99. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/transport/channel.py +0 -0
  100. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/devices/transport/local_channel.py +0 -0
  101. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/diagnostics.py +0 -0
  102. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/exceptions.py +0 -0
  103. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/map/__init__.py +0 -0
  104. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/map/b01_grid_layers.py +0 -0
  105. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/map/b01_map_parser.py +0 -0
  106. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/map/b01_q10_map_parser.py +0 -0
  107. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/map/b01_q10_overlays.py +0 -0
  108. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/map/b01_q10_render.py +0 -0
  109. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/map/map_parser.py +0 -0
  110. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/map/proto/__init__.py +0 -0
  111. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/map/proto/b01_scmap.proto +0 -0
  112. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  113. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/mqtt/__init__.py +0 -0
  114. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/mqtt/health_manager.py +0 -0
  115. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/protocol.py +0 -0
  116. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/protocols/__init__.py +0 -0
  117. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  118. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  119. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/py.typed +0 -0
  120. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/roborock_message.py +0 -0
  121. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/testing/__init__.py +0 -0
  122. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/testing/b01_q10_simulator.py +0 -0
  123. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/testing/cloud.py +0 -0
  124. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/testing/simulator.py +0 -0
  125. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/testing/v1_simulator.py +0 -0
  126. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/util.py +0 -0
  127. {python_roborock-5.35.0 → python_roborock-5.37.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 5.35.0
3
+ Version: 5.37.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/python-roborock/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 = "5.35.0"
3
+ version = "5.37.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"
@@ -539,6 +539,12 @@ class DeviceFeatures(RoborockBase):
539
539
  ]
540
540
  }
541
541
  )
542
+ is_ai_recognition_setting_supported: bool = field(
543
+ metadata={"product_features": [ProductFeatures.AIRECOGNITION_SETTING]}
544
+ )
545
+ is_ai_recognition_obstacle_supported: bool = field(
546
+ metadata={"product_features": [ProductFeatures.AIRECOGNITION_OBSTACLE]}
547
+ )
542
548
  is_pure_clean_mop_supported: bool = field(metadata={"product_features": [ProductFeatures.CLEANMODE_PURECLEANMOP]})
543
549
  is_new_remote_view_supported: bool = field(metadata={"product_features": [ProductFeatures.REMOTE_BACK]})
544
550
  is_max_plus_mode_supported: bool = field(metadata={"product_features": [ProductFeatures.CLEANMODE_MAXPLUS]})
@@ -246,6 +246,7 @@ async def create_device_manager(
246
246
  channel.rpc_channel,
247
247
  channel.mqtt_rpc_channel,
248
248
  channel.map_rpc_channel,
249
+ channel.blob_rpc_channel,
249
250
  channel.add_dps_listener,
250
251
  web_api,
251
252
  device_cache=device_cache,
@@ -7,6 +7,7 @@ from typing import Any, overload
7
7
 
8
8
  from roborock.devices.transport.mqtt_channel import MqttChannel
9
9
  from roborock.exceptions import RoborockException
10
+ from roborock.mqtt.session import MqttQos
10
11
  from roborock.protocols.a01_protocol import (
11
12
  decode_rpc_response,
12
13
  encode_mqtt_payload,
@@ -30,6 +31,7 @@ async def send_decoded_command(
30
31
  mqtt_channel: MqttChannel,
31
32
  params: dict[RoborockDyadDataProtocol, Any],
32
33
  value_encoder: Callable[[Any], Any] | None = None,
34
+ qos: MqttQos = MqttQos.AT_MOST_ONCE,
33
35
  ) -> dict[RoborockDyadDataProtocol, Any]: ...
34
36
 
35
37
 
@@ -38,6 +40,7 @@ async def send_decoded_command(
38
40
  mqtt_channel: MqttChannel,
39
41
  params: dict[RoborockZeoProtocol, Any],
40
42
  value_encoder: Callable[[Any], Any] | None = None,
43
+ qos: MqttQos = MqttQos.AT_MOST_ONCE,
41
44
  ) -> dict[RoborockZeoProtocol, Any]: ...
42
45
 
43
46
 
@@ -45,8 +48,16 @@ async def send_decoded_command(
45
48
  mqtt_channel: MqttChannel,
46
49
  params: dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any],
47
50
  value_encoder: Callable[[Any], Any] | None = None,
51
+ qos: MqttQos = MqttQos.AT_MOST_ONCE,
48
52
  ) -> dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any]:
49
- """Send a command on the MQTT channel and get a decoded response."""
53
+ """Send a command on the MQTT channel and get a decoded response.
54
+
55
+ Args:
56
+ mqtt_channel: The MQTT channel to send the command on.
57
+ params: The parameters to send.
58
+ value_encoder: A function to encode the values of the dictionary.
59
+ qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
60
+ """
50
61
  _LOGGER.debug("Sending MQTT command: %s", params)
51
62
  roborock_message = encode_mqtt_payload(params, value_encoder)
52
63
 
@@ -54,7 +65,7 @@ async def send_decoded_command(
54
65
  # block waiting for a response. Queries are handled below.
55
66
  param_values = {int(k): v for k, v in params.items()}
56
67
  if not (query_values := param_values.get(_ID_QUERY)):
57
- await mqtt_channel.publish(roborock_message)
68
+ await mqtt_channel.publish(roborock_message, qos=qos)
58
69
  return {}
59
70
 
60
71
  # Merge any results together than contain the requested data. This
@@ -29,6 +29,7 @@ from roborock.protocols.v1_protocol import (
29
29
  ResponseMessage,
30
30
  SecurityData,
31
31
  V1RpcChannel,
32
+ create_blob_response_decoder,
32
33
  create_map_response_decoder,
33
34
  create_security_data,
34
35
  decode_data_protocol_message,
@@ -161,6 +162,50 @@ class RpcChannel(V1RpcChannel):
161
162
  return result
162
163
 
163
164
 
165
+ class BlobRpcChannel(V1RpcChannel):
166
+ """RPC channel that adds the security envelope required for blob requests."""
167
+
168
+ def __init__(
169
+ self,
170
+ rpc_channel: V1RpcChannel,
171
+ raw_blob_rpc_channel: V1RpcChannel,
172
+ security_data: SecurityData,
173
+ ) -> None:
174
+ """Initialize the blob RPC channel."""
175
+ self._rpc_channel = rpc_channel
176
+ self._raw_blob_rpc_channel = raw_blob_rpc_channel
177
+ self._security_data = security_data
178
+
179
+ async def send_command(
180
+ self,
181
+ method: CommandType,
182
+ *,
183
+ response_type: type[_T] | None = None,
184
+ params: ParamsType = None,
185
+ ) -> _T | Any:
186
+ """Send a blob command after adding the device security parameters."""
187
+ public_key = await self._rpc_channel.send_command(RoborockCommand.GET_RANDOM_PKEY)
188
+ if not isinstance(public_key, dict) or not isinstance(public_key.get("pub_key"), dict):
189
+ raise RoborockException("get_random_pkey response did not contain a public key")
190
+ security = self._security_data.to_dict()["security"]
191
+ blob_params = {
192
+ "security": {
193
+ "pub_key": public_key["pub_key"],
194
+ "cipher_suite": 0,
195
+ },
196
+ "endpoint": security["endpoint"],
197
+ "nonce": security["nonce"],
198
+ "data_filter": params,
199
+ }
200
+ if response_type is not None:
201
+ return await self._raw_blob_rpc_channel.send_command(
202
+ method,
203
+ response_type=response_type,
204
+ params=blob_params,
205
+ )
206
+ return await self._raw_blob_rpc_channel.send_command(method, params=blob_params)
207
+
208
+
164
209
  class V1Channel(Channel):
165
210
  """Unified V1 protocol channel with automatic MQTT/local connection handling.
166
211
 
@@ -245,6 +290,13 @@ class V1Channel(Channel):
245
290
  decoder = create_map_response_decoder(security_data=self._security_data)
246
291
  return RpcChannel(lambda: [self._create_mqtt_rpc_strategy(decoder)], self._logger)
247
292
 
293
+ @property
294
+ def blob_rpc_channel(self) -> V1RpcChannel:
295
+ """Return the blob RPC channel used for fetching binary content."""
296
+ decoder = create_blob_response_decoder()
297
+ raw_blob_rpc_channel = RpcChannel(lambda: [self._create_mqtt_rpc_strategy(decoder)], self._logger)
298
+ return BlobRpcChannel(self.rpc_channel, raw_blob_rpc_channel, self._security_data)
299
+
248
300
  def _create_local_rpc_strategy(self) -> RpcStrategy | None:
249
301
  """Create the RPC strategy for local transport."""
250
302
  if self._local_channel is None or not self.is_local_connected:
@@ -83,6 +83,7 @@ from . import (
83
83
  map_content,
84
84
  maps,
85
85
  network_info,
86
+ obstacle_photos,
86
87
  rooms,
87
88
  routines,
88
89
  smart_wash_params,
@@ -105,6 +106,7 @@ from .led_status import LedStatusTrait
105
106
  from .map_content import MapContentTrait
106
107
  from .maps import MapsTrait
107
108
  from .network_info import NetworkInfoTrait
109
+ from .obstacle_photos import ObstaclePhotoTrait
108
110
  from .rooms import RoomsTrait
109
111
  from .routines import RoutinesTrait
110
112
  from .smart_wash_params import SmartWashParamsTrait
@@ -131,6 +133,7 @@ __all__ = [
131
133
  "map_content",
132
134
  "maps",
133
135
  "network_info",
136
+ "obstacle_photos",
134
137
  "rooms",
135
138
  "routines",
136
139
  "smart_wash_params",
@@ -171,6 +174,7 @@ class PropertiesApi(Trait):
171
174
  dust_collection_mode: DustCollectionModeTrait | None = None
172
175
  wash_towel_mode: WashTowelModeTrait | None = None
173
176
  smart_wash_params: SmartWashParamsTrait | None = None
177
+ obstacle_photos: ObstaclePhotoTrait | None = None
174
178
 
175
179
  def __init__(
176
180
  self,
@@ -180,6 +184,7 @@ class PropertiesApi(Trait):
180
184
  rpc_channel: V1RpcChannel,
181
185
  mqtt_rpc_channel: V1RpcChannel,
182
186
  map_rpc_channel: V1RpcChannel,
187
+ blob_rpc_channel: V1RpcChannel,
183
188
  add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]],
184
189
  web_api: UserWebApiClient,
185
190
  device_cache: DeviceCache,
@@ -191,6 +196,7 @@ class PropertiesApi(Trait):
191
196
  self._rpc_channel = rpc_channel
192
197
  self._mqtt_rpc_channel = mqtt_rpc_channel
193
198
  self._map_rpc_channel = map_rpc_channel
199
+ self._blob_rpc_channel = blob_rpc_channel
194
200
  self._web_api = web_api
195
201
  self._device_cache = device_cache
196
202
  self._region = region
@@ -229,6 +235,8 @@ class PropertiesApi(Trait):
229
235
  # to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive)
230
236
  if hasattr(trait, "mqtt_rpc_channel"):
231
237
  return self._mqtt_rpc_channel
238
+ elif hasattr(trait, "blob_rpc_channel"):
239
+ return self._blob_rpc_channel
232
240
  elif hasattr(trait, "map_rpc_channel"):
233
241
  return self._map_rpc_channel
234
242
  else:
@@ -272,6 +280,11 @@ class PropertiesApi(Trait):
272
280
  wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment]
273
281
  self.wash_towel_mode = wash_towel_mode
274
282
 
283
+ if self.obstacle_photos is None and self._is_supported(ObstaclePhotoTrait, "obstacle_photos", dock_features):
284
+ obstacle_photos = ObstaclePhotoTrait(self._rpc_channel)
285
+ obstacle_photos._rpc_channel = self._get_rpc_channel(obstacle_photos)
286
+ self.obstacle_photos = obstacle_photos
287
+
275
288
  # Dynamically create any traits that need to be populated
276
289
  for item in fields(self):
277
290
  if (trait := getattr(self, item.name, None)) is not None:
@@ -283,6 +296,8 @@ class PropertiesApi(Trait):
283
296
 
284
297
  # Union args may not be in declared order
285
298
  item_type = union_args[0] if union_args[1] is type(None) else union_args[1]
299
+ if item_type is ObstaclePhotoTrait:
300
+ continue
286
301
  if not self._is_supported(item_type, item.name, dock_features):
287
302
  _LOGGER.debug("Trait '%s' not supported, skipping", item.name)
288
303
  continue
@@ -369,6 +384,7 @@ def create(
369
384
  rpc_channel: V1RpcChannel,
370
385
  mqtt_rpc_channel: V1RpcChannel,
371
386
  map_rpc_channel: V1RpcChannel,
387
+ blob_rpc_channel: V1RpcChannel,
372
388
  add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]],
373
389
  web_api: UserWebApiClient,
374
390
  device_cache: DeviceCache,
@@ -383,6 +399,7 @@ def create(
383
399
  rpc_channel,
384
400
  mqtt_rpc_channel,
385
401
  map_rpc_channel,
402
+ blob_rpc_channel,
386
403
  add_dps_listener,
387
404
  web_api,
388
405
  device_cache,
@@ -16,7 +16,7 @@ from roborock.roborock_typing import RoborockCommand
16
16
  _LOGGER = logging.getLogger(__name__)
17
17
 
18
18
 
19
- V1ResponseData = dict | list | int | str
19
+ V1ResponseData = dict | list | int | str | bytes
20
20
 
21
21
 
22
22
  class V1TraitDataConverter(ABC):
@@ -0,0 +1,104 @@
1
+ """Trait for fetching obstacle photos from V1 vacuums."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from roborock.data import RoborockBase
6
+ from roborock.devices.traits.v1 import common
7
+ from roborock.exceptions import RoborockException
8
+ from roborock.protocols.v1_protocol import V1RpcChannel
9
+ from roborock.roborock_typing import RoborockCommand
10
+
11
+ _PHOTO_TYPE_SMALL = 1
12
+ _PHOTO_DATA_BLOCK_TYPE = 3
13
+ _MAP_OBJECT_PHOTO_ENABLED_BIT = 10
14
+ _TYPE_SIZE = 2
15
+ _HEADER_SIZE_SIZE = 2
16
+ _PAYLOAD_SIZE_SIZE = 4
17
+ _MIN_BLOCK_HEADER_SIZE = _TYPE_SIZE + _HEADER_SIZE_SIZE + _PAYLOAD_SIZE_SIZE
18
+ _IMAGE_HEADERS = (b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff")
19
+
20
+
21
+ @dataclass
22
+ class ObstaclePhoto(RoborockBase):
23
+ """Obstacle photo content."""
24
+
25
+ photo_id: str
26
+ image_content: bytes
27
+
28
+
29
+ class ObstaclePhotoConverter(common.V1TraitDataConverter):
30
+ """Convert a decrypted get_photo payload to an obstacle photo."""
31
+
32
+ def convert(self, response: common.V1ResponseData) -> ObstaclePhoto:
33
+ """Parse the response from the device into an obstacle photo."""
34
+ if not isinstance(response, bytes):
35
+ raise ValueError(f"Unexpected ObstaclePhotoTrait response format: {type(response)}")
36
+ return ObstaclePhoto(photo_id="", image_content=parse_photo_data(response))
37
+
38
+
39
+ def parse_photo_data(response: bytes) -> bytes:
40
+ """Parse the get_photo response payload and return image bytes.
41
+
42
+ Roborock's app parses get_photo as a sequence of little-endian typed blocks.
43
+ Block type 3 contains the image bytes.
44
+ """
45
+ offset = 0
46
+ while offset + _MIN_BLOCK_HEADER_SIZE <= len(response):
47
+ block_type = int.from_bytes(response[offset : offset + _TYPE_SIZE], "little")
48
+ header_size = int.from_bytes(
49
+ response[offset + _TYPE_SIZE : offset + _TYPE_SIZE + _HEADER_SIZE_SIZE],
50
+ "little",
51
+ )
52
+ payload_size = int.from_bytes(
53
+ response[offset + _TYPE_SIZE + _HEADER_SIZE_SIZE : offset + _MIN_BLOCK_HEADER_SIZE],
54
+ "little",
55
+ )
56
+ next_offset = offset + header_size + payload_size
57
+ if header_size < _MIN_BLOCK_HEADER_SIZE or next_offset > len(response):
58
+ raise RoborockException("Invalid obstacle photo payload")
59
+
60
+ if block_type == _PHOTO_DATA_BLOCK_TYPE:
61
+ image_content = response[offset + header_size : next_offset]
62
+ if not image_content.startswith(_IMAGE_HEADERS):
63
+ raise RoborockException("Obstacle photo payload is not a supported image")
64
+ return image_content
65
+
66
+ offset = next_offset
67
+
68
+ raise RoborockException("Obstacle photo payload does not contain photo data")
69
+
70
+
71
+ class ObstaclePhotoTrait(RoborockBase, common.V1TraitMixin):
72
+ """Trait for fetching obstacle photos."""
73
+
74
+ command = RoborockCommand.GET_PHOTO
75
+ converter = ObstaclePhotoConverter()
76
+ blob_rpc_channel = True
77
+ requires_feature = "is_ai_recognition_obstacle_supported"
78
+
79
+ def __init__(self, standard_rpc_channel: V1RpcChannel) -> None:
80
+ """Initialize the obstacle photo trait."""
81
+ super().__init__()
82
+ self._standard_rpc_channel = standard_rpc_channel
83
+
84
+ async def get_enabled(self) -> bool:
85
+ """Return whether map object photo capture is enabled on the vacuum."""
86
+ response = await self._standard_rpc_channel.send_command(RoborockCommand.GET_CAMERA_STATUS)
87
+ if not isinstance(response, list) or not response or not isinstance(response[0], int):
88
+ raise RoborockException("get_camera_status response did not contain camera status")
89
+ return bool((response[0] >> _MAP_OBJECT_PHOTO_ENABLED_BIT) & 1)
90
+
91
+ async def get_photo(self, photo_id: str) -> ObstaclePhoto:
92
+ """Fetch the small obstacle photo for a map photo ID.
93
+
94
+ Photo IDs are available as ``Obstacle.details.photo_name`` in
95
+ ``ParsedMapData.map_data.obstacles_with_photo`` and
96
+ ``ParsedMapData.map_data.ignored_obstacles_with_photo``.
97
+ """
98
+ response = await self.rpc_channel.send_command(
99
+ self.command,
100
+ params={"img_id": photo_id, "type": _PHOTO_TYPE_SMALL},
101
+ )
102
+ photo = self.converter.convert(response)
103
+ photo.photo_id = photo_id
104
+ return photo
@@ -8,7 +8,7 @@ from roborock.callbacks import decoder_callback
8
8
  from roborock.data import HomeDataDevice, RRiot, UserData
9
9
  from roborock.exceptions import RoborockException
10
10
  from roborock.mqtt.health_manager import HealthManager
11
- from roborock.mqtt.session import MqttParams, MqttSession, MqttSessionException
11
+ from roborock.mqtt.session import MqttParams, MqttQos, MqttSession, MqttSessionException
12
12
  from roborock.protocol import create_mqtt_decoder, create_mqtt_encoder
13
13
  from roborock.roborock_message import RoborockMessage
14
14
  from roborock.util import RoborockLoggerAdapter
@@ -89,11 +89,15 @@ class MqttChannel(Channel):
89
89
  finally:
90
90
  unsub()
91
91
 
92
- async def publish(self, message: RoborockMessage) -> None:
92
+ async def publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
93
93
  """Publish a command message.
94
94
 
95
95
  The caller is responsible for handling any responses and associating them
96
96
  with the incoming request.
97
+
98
+ Args:
99
+ message: The message to publish.
100
+ qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
97
101
  """
98
102
  try:
99
103
  encoded_msg = self._encoder(message)
@@ -101,7 +105,7 @@ class MqttChannel(Channel):
101
105
  self._logger.exception("Error encoding MQTT message: %s", e)
102
106
  raise RoborockException(f"Failed to encode MQTT message: {e}") from e
103
107
  try:
104
- return await self._mqtt_session.publish(self._publish_topic, encoded_msg)
108
+ return await self._mqtt_session.publish(self._publish_topic, encoded_msg, qos=qos)
105
109
  except MqttSessionException as e:
106
110
  self._logger.debug("Error publishing MQTT message: %s", e)
107
111
  raise RoborockException(f"Failed to publish MQTT message: {e}") from e
@@ -22,7 +22,7 @@ from roborock.callbacks import CallbackMap
22
22
  from roborock.diagnostics import Diagnostics, redact_topic_name
23
23
 
24
24
  from .health_manager import HealthManager
25
- from .session import MqttParams, MqttSession, MqttSessionException, MqttSessionUnauthorized
25
+ from .session import MqttParams, MqttQos, MqttSession, MqttSessionException, MqttSessionUnauthorized
26
26
 
27
27
  _LOGGER = logging.getLogger(__name__)
28
28
  _MQTT_LOGGER = logging.getLogger(f"{__name__}.aiomqtt")
@@ -361,8 +361,14 @@ class RoborockMqttSession(MqttSession):
361
361
 
362
362
  return delayed_unsub
363
363
 
364
- async def publish(self, topic: str, message: bytes) -> None:
365
- """Publish a message on the topic."""
364
+ async def publish(self, topic: str, message: bytes, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
365
+ """Publish a message on the topic.
366
+
367
+ Args:
368
+ topic: The MQTT topic to publish to.
369
+ message: The message payload.
370
+ qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
371
+ """
366
372
  _LOGGER.debug("Sending message to topic %s: %s", topic, message)
367
373
  client: aiomqtt.Client
368
374
  async with self._client_lock:
@@ -371,7 +377,7 @@ class RoborockMqttSession(MqttSession):
371
377
  client = self._client
372
378
  try:
373
379
  with self._diagnostics.timer("publish"):
374
- await client.publish(topic, message)
380
+ await client.publish(topic, message, qos=qos)
375
381
  except MqttError as err:
376
382
  raise MqttSessionException(f"Error publishing message: {err}") from err
377
383
 
@@ -417,13 +423,13 @@ class LazyMqttSession(MqttSession):
417
423
  await self._maybe_start()
418
424
  return await self._session.subscribe(device_id, callback)
419
425
 
420
- async def publish(self, topic: str, message: bytes) -> None:
426
+ async def publish(self, topic: str, message: bytes, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
421
427
  """Publish a message on the specified topic.
422
428
 
423
429
  This will raise an exception if the message could not be sent.
424
430
  """
425
431
  await self._maybe_start()
426
- return await self._session.publish(topic, message)
432
+ return await self._session.publish(topic, message, qos=qos)
427
433
 
428
434
  async def close(self) -> None:
429
435
  """Cancels the mqtt loop.
@@ -3,6 +3,7 @@
3
3
  from abc import ABC, abstractmethod
4
4
  from collections.abc import Callable
5
5
  from dataclasses import dataclass, field
6
+ from enum import IntEnum
6
7
 
7
8
  from roborock.diagnostics import Diagnostics
8
9
  from roborock.exceptions import RoborockException
@@ -10,6 +11,24 @@ from roborock.mqtt.health_manager import HealthManager
10
11
 
11
12
  DEFAULT_TIMEOUT = 30.0
12
13
 
14
+
15
+ class MqttQos(IntEnum):
16
+ """MQTT Quality of Service levels.
17
+
18
+ A01 devices (Zeo, Dyad) require ``AT_LEAST_ONCE`` for DP200 (start)
19
+ commands. Other protocol versions use ``AT_MOST_ONCE``.
20
+ """
21
+
22
+ AT_MOST_ONCE = 0
23
+ """Fire-and-forget. No acknowledgment required."""
24
+
25
+ AT_LEAST_ONCE = 1
26
+ """Guaranteed delivery with possible duplicates. Broker sends PUBACK."""
27
+
28
+ EXACTLY_ONCE = 2
29
+ """Guaranteed delivery with no duplicates. Broker sends PUBREC/PUBREL/PUBCOMP."""
30
+
31
+
13
32
  SessionUnauthorizedHook = Callable[[], None]
14
33
 
15
34
 
@@ -76,10 +95,15 @@ class MqttSession(ABC):
76
95
  """
77
96
 
78
97
  @abstractmethod
79
- async def publish(self, topic: str, message: bytes) -> None:
98
+ async def publish(self, topic: str, message: bytes, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
80
99
  """Publish a message on the specified topic.
81
100
 
82
101
  This will raise an exception if the message could not be sent.
102
+
103
+ Args:
104
+ topic: The MQTT topic to publish to.
105
+ message: The message payload.
106
+ qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
83
107
  """
84
108
 
85
109
  @abstractmethod
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import logging
5
+ import time
5
6
  from collections.abc import Callable
6
7
  from typing import Any
7
8
 
@@ -42,7 +43,10 @@ def encode_mqtt_payload(
42
43
  """
43
44
  if value_encoder is None:
44
45
  value_encoder = _no_encode
45
- dps_data = {"dps": {key: value_encoder(value) for key, value in data.items()}}
46
+ dps_data = {
47
+ "dps": {key: value_encoder(value) for key, value in data.items()},
48
+ "t": int(time.time()),
49
+ }
46
50
  payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size)
47
51
  return RoborockMessage(
48
52
  protocol=RoborockMessageProtocol.RPC_REQUEST,
@@ -3,10 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import base64
6
+ import gzip
6
7
  import json
7
8
  import logging
8
9
  import secrets
9
10
  import struct
11
+ import zlib
10
12
  from collections.abc import Callable
11
13
  from dataclasses import dataclass, field
12
14
  from enum import StrEnum
@@ -24,6 +26,7 @@ _LOGGER = logging.getLogger(__name__)
24
26
  __all__ = [
25
27
  "SecurityData",
26
28
  "create_security_data",
29
+ "create_blob_response_decoder",
27
30
  "decode_data_protocol_message",
28
31
  "decode_rpc_response",
29
32
  "V1RpcChannel",
@@ -261,6 +264,48 @@ class MapResponse:
261
264
  """The map data, decrypted and decompressed."""
262
265
 
263
266
 
267
+ @dataclass
268
+ class BlobResponse:
269
+ """Data structure for V1 blob responses."""
270
+
271
+ request_id: int
272
+ """The request ID of the blob response."""
273
+
274
+ data: bytes
275
+ """The blob data, decompressed."""
276
+
277
+
278
+ def create_blob_response_decoder() -> Callable[[RoborockMessage], BlobResponse | None]:
279
+ """Create a decoder for V1 blob response messages.
280
+
281
+ Obstacle photos are acknowledged through the normal RPC response with
282
+ ``["ok"]`` and delivered later as a protocol-301 blob frame. The frame starts
283
+ with ``ROBOROCK``, stores the RPC request id at bytes 8-11, the header size
284
+ at bytes 16-17, and the gzip payload length at bytes 20-23.
285
+ """
286
+
287
+ def _decode_blob_response(message: RoborockMessage) -> BlobResponse | None:
288
+ """Decode a V1 blob response message."""
289
+ if message.protocol != RoborockMessageProtocol.MAP_RESPONSE:
290
+ return None
291
+ payload = message.payload
292
+ if not payload or not payload.startswith(b"ROBOROCK") or len(payload) < 24:
293
+ return None
294
+ request_id = int.from_bytes(payload[8:12], "little")
295
+ header_size = int.from_bytes(payload[16:18], "little")
296
+ payload_size = int.from_bytes(payload[20:24], "little")
297
+ end_offset = header_size + payload_size
298
+ if header_size < 24 or end_offset > len(payload):
299
+ raise RoborockException("Invalid V1 blob response format")
300
+ try:
301
+ data = Utils.decompress(payload[header_size:end_offset])
302
+ except (gzip.BadGzipFile, EOFError, zlib.error) as err:
303
+ raise RoborockException("Failed to decode blob message payload") from err
304
+ return BlobResponse(request_id=request_id, data=data)
305
+
306
+ return _decode_blob_response
307
+
308
+
264
309
  def create_map_response_decoder(security_data: SecurityData) -> Callable[[RoborockMessage], MapResponse | None]:
265
310
  """Create a decoder for V1 map response messages."""
266
311
 
@@ -117,6 +117,7 @@ class RoborockCommand(str, Enum):
117
117
  GET_NETWORK_INFO = "get_network_info"
118
118
  GET_OFFLINE_MAP_STATUS = "get_offline_map_status"
119
119
  GET_PERSIST = "get_persist_map"
120
+ GET_PHOTO = "get_photo"
120
121
  GET_PROP = "get_prop"
121
122
  GET_RANDOM_PKEY = "get_random_pkey"
122
123
  GET_RECOVER_MAP = "get_recover_map"
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, MagicMock
12
12
 
13
13
  from roborock.devices.transport.channel import Channel
14
14
  from roborock.mqtt.health_manager import HealthManager
15
+ from roborock.mqtt.session import MqttQos
15
16
  from roborock.protocols.v1_protocol import LocalProtocolVersion
16
17
  from roborock.roborock_message import RoborockMessage
17
18
 
@@ -83,6 +84,7 @@ class FakeChannel(Channel):
83
84
  self.close = MagicMock(side_effect=self._close)
84
85
 
85
86
  self.protocol_version = LocalProtocolVersion.V1
87
+
86
88
  self.restart = AsyncMock()
87
89
  self.health_manager = HealthManager(self.restart)
88
90
 
@@ -102,10 +104,13 @@ class FakeChannel(Channel):
102
104
  """Return true if locally connected."""
103
105
  return self._is_connected and self._is_local
104
106
 
105
- async def _publish(self, message: RoborockMessage) -> None:
107
+ async def _publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
106
108
  """Default publish implementation.
107
109
 
108
110
  Records the message in ``published_messages`` and executes ``publish_handler``.
111
+
112
+ The ``qos`` parameter is accepted for compatibility with
113
+ ``MqttChannel.publish`` but not simulated by the fake channel.
109
114
  """
110
115
  self.published_messages.append(message)
111
116
  if self.publish_side_effect: