python-roborock 5.34.0__tar.gz → 5.36.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.34.0 → python_roborock-5.36.0}/PKG-INFO +1 -1
  2. {python_roborock-5.34.0 → python_roborock-5.36.0}/pyproject.toml +1 -1
  3. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/rpc/a01_channel.py +13 -2
  4. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/__init__.py +8 -3
  5. python_roborock-5.36.0/roborock/devices/traits/b01/q10/map.py +151 -0
  6. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/transport/mqtt_channel.py +7 -3
  7. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/mqtt/roborock_session.py +12 -6
  8. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/mqtt/session.py +25 -1
  9. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/protocols/a01_protocol.py +5 -1
  10. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/testing/channel.py +6 -1
  11. python_roborock-5.34.0/roborock/devices/traits/b01/q10/map.py +0 -110
  12. {python_roborock-5.34.0 → python_roborock-5.36.0}/.gitignore +0 -0
  13. {python_roborock-5.34.0 → python_roborock-5.36.0}/LICENSE +0 -0
  14. {python_roborock-5.34.0 → python_roborock-5.36.0}/README.md +0 -0
  15. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/__init__.py +0 -0
  16. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/broadcast_protocol.py +0 -0
  17. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/callbacks.py +0 -0
  18. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/cli.py +0 -0
  19. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/const.py +0 -0
  20. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/__init__.py +0 -0
  21. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/b01_q10/__init__.py +0 -0
  22. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  23. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  24. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/b01_q7/__init__.py +0 -0
  25. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  26. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  27. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/code_mappings.py +0 -0
  28. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/containers.py +0 -0
  29. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/dyad/__init__.py +0 -0
  30. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  31. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/dyad/dyad_containers.py +0 -0
  32. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/mower/__init__.py +0 -0
  33. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/mower/mower_containers.py +0 -0
  34. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/v1/__init__.py +0 -0
  35. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  36. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  37. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/v1/v1_containers.py +0 -0
  38. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/zeo/__init__.py +0 -0
  39. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  40. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/data/zeo/zeo_containers.py +0 -0
  41. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/device_features.py +0 -0
  42. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/README.md +0 -0
  43. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/__init__.py +0 -0
  44. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/cache.py +0 -0
  45. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/device.py +0 -0
  46. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/device_manager.py +0 -0
  47. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/file_cache.py +0 -0
  48. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/rpc/__init__.py +0 -0
  49. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  50. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  51. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/rpc/v1_channel.py +0 -0
  52. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/__init__.py +0 -0
  53. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/a01/__init__.py +0 -0
  54. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/__init__.py +0 -0
  55. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  56. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  57. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  58. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  59. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  60. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  61. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  62. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  63. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  64. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
  65. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  66. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  67. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q10/volume.py +0 -0
  68. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  69. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  70. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  71. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  72. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/common.py +0 -0
  73. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/traits_mixin.py +0 -0
  74. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/__init__.py +0 -0
  75. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  76. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  77. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/command.py +0 -0
  78. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/common.py +0 -0
  79. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  80. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/device_features.py +0 -0
  81. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  82. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  83. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  84. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/home.py +0 -0
  85. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/led_status.py +0 -0
  86. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/map_content.py +0 -0
  87. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/maps.py +0 -0
  88. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/network_info.py +0 -0
  89. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/rooms.py +0 -0
  90. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/routines.py +0 -0
  91. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  92. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/status.py +0 -0
  93. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  94. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/volume.py +0 -0
  95. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  96. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/transport/__init__.py +0 -0
  97. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/transport/channel.py +0 -0
  98. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/devices/transport/local_channel.py +0 -0
  99. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/diagnostics.py +0 -0
  100. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/exceptions.py +0 -0
  101. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/map/__init__.py +0 -0
  102. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/map/b01_grid_layers.py +0 -0
  103. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/map/b01_map_parser.py +0 -0
  104. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/map/b01_q10_map_parser.py +0 -0
  105. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/map/b01_q10_overlays.py +0 -0
  106. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/map/b01_q10_render.py +0 -0
  107. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/map/map_parser.py +0 -0
  108. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/map/proto/__init__.py +0 -0
  109. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/map/proto/b01_scmap.proto +0 -0
  110. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  111. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/mqtt/__init__.py +0 -0
  112. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/mqtt/health_manager.py +0 -0
  113. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/protocol.py +0 -0
  114. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/protocols/__init__.py +0 -0
  115. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  116. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  117. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/protocols/v1_protocol.py +0 -0
  118. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/py.typed +0 -0
  119. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/roborock_message.py +0 -0
  120. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/roborock_typing.py +0 -0
  121. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/testing/__init__.py +0 -0
  122. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/testing/b01_q10_simulator.py +0 -0
  123. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/testing/cloud.py +0 -0
  124. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/testing/simulator.py +0 -0
  125. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/testing/v1_simulator.py +0 -0
  126. {python_roborock-5.34.0 → python_roborock-5.36.0}/roborock/util.py +0 -0
  127. {python_roborock-5.34.0 → python_roborock-5.36.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.34.0
3
+ Version: 5.36.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.34.0"
3
+ version = "5.36.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"
@@ -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
@@ -16,7 +16,7 @@ from .command import CommandTrait
16
16
  from .consumable import ConsumableTrait
17
17
  from .do_not_disturb import DoNotDisturbTrait
18
18
  from .dust_collection import DustCollectionTrait
19
- from .map import MapContentTrait
19
+ from .map import MapContentTrait, MapDpsTrait
20
20
  from .network_info import NetworkInfoTrait
21
21
  from .remote import RemoteTrait
22
22
  from .status import StatusTrait
@@ -77,7 +77,10 @@ class Q10PropertiesApi(Trait):
77
77
  """Trait exposing remaining life of consumables."""
78
78
 
79
79
  map: MapContentTrait
80
- """Trait for fetching the current parsed map (image + rooms)."""
80
+ """Composed map image plus caller-facing map and trace data."""
81
+
82
+ _map_dps: MapDpsTrait
83
+ """Private source of restricted zones and virtual walls received through DPS."""
81
84
 
82
85
  clean_history: CleanHistoryTrait
83
86
  """Trait for fetching the device clean-record history (``dpCleanRecord``)."""
@@ -96,7 +99,8 @@ class Q10PropertiesApi(Trait):
96
99
  self.button_light = ButtonLightTrait(self.command)
97
100
  self.network_info = NetworkInfoTrait()
98
101
  self.consumable = ConsumableTrait()
99
- self.map = MapContentTrait()
102
+ self._map_dps = MapDpsTrait()
103
+ self.map = MapContentTrait(self._map_dps)
100
104
  self.clean_history = CleanHistoryTrait(self.command)
101
105
  # Read-model traits updated from the device's DPS push stream.
102
106
  self._updatable_traits = [
@@ -108,6 +112,7 @@ class Q10PropertiesApi(Trait):
108
112
  self.network_info,
109
113
  self.consumable,
110
114
  self.clean_history,
115
+ self._map_dps,
111
116
  ]
112
117
  self._subscribe_task: asyncio.Task[None] | None = None
113
118
 
@@ -0,0 +1,151 @@
1
+ """Push-driven map traits for B01 Q10 devices.
2
+
3
+ Map-related state arrives on three independent streams:
4
+
5
+ * map packets are decoded from map-protocol responses;
6
+ * trace packets are decoded from trace-protocol responses;
7
+ * restricted zones and virtual walls arrive as ordinary DPS values.
8
+
9
+ ``MapDpsTrait`` owns the low-level DPS read model. ``MapContentTrait`` depends
10
+ on it and combines that state with the latest map/trace packets through the pure
11
+ functions in :mod:`roborock.map.b01_q10_render`. The high-level trait keeps only
12
+ the latest value from each source and one replace-whole rendered image;
13
+ calibration, path placement and overlay placement remain inside the renderer.
14
+ """
15
+
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from roborock.data import RoborockBase
21
+ from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
22
+ from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
23
+ from roborock.exceptions import RoborockException
24
+ from roborock.map.b01_q10_map_parser import (
25
+ B01Q10MapParserConfig,
26
+ Q10MapPacket,
27
+ Q10Point,
28
+ Q10Room,
29
+ Q10TracePacket,
30
+ )
31
+ from roborock.map.b01_q10_overlays import parse_virtual_wall_blob, parse_zone_blob
32
+ from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
33
+
34
+ from .common import UpdatableTrait
35
+
36
+ _LOGGER = logging.getLogger(__name__)
37
+
38
+
39
+ @dataclass
40
+ class MapDps(RoborockBase):
41
+ """Low-level map values delivered in the Q10 DPS stream."""
42
+
43
+ restricted_zone_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.RESTRICTED_ZONE_UP})
44
+ virtual_wall_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.VIRTUAL_WALL_UP})
45
+
46
+
47
+ class MapDpsTrait(MapDps, UpdatableTrait):
48
+ """Private read model for map-related DPS values and decoded overlays."""
49
+
50
+ _CONVERTER = DpsDataConverter.from_dataclass(MapDps)
51
+
52
+ def __init__(self) -> None:
53
+ MapDps.__init__(self)
54
+ UpdatableTrait.__init__(self, command=None, logger=_LOGGER)
55
+ self._overlays = Q10MapOverlays()
56
+
57
+ @property
58
+ def overlays(self) -> Q10MapOverlays:
59
+ """Overlays decoded once from the latest relevant DPS update."""
60
+ return self._overlays
61
+
62
+ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
63
+ """Decode overlay blobs when they arrive, then notify dependents."""
64
+ if not self._CONVERTER.update_from_dps(self, decoded_dps):
65
+ return
66
+ self._overlays = Q10MapOverlays(
67
+ zones=tuple(parse_zone_blob(self.restricted_zone_up)),
68
+ virtual_walls=tuple(parse_virtual_wall_blob(self.virtual_wall_up)),
69
+ )
70
+ self._notify_update()
71
+
72
+
73
+ class MapContentTrait(TraitUpdateListener):
74
+ """High-level composed Q10 map view.
75
+
76
+ The latest map and trace packets are combined with the injected
77
+ :class:`MapDpsTrait` whenever any of those three sources changes.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ map_dps: MapDpsTrait,
83
+ *,
84
+ map_parser_config: B01Q10MapParserConfig | None = None,
85
+ ) -> None:
86
+ TraitUpdateListener.__init__(self, logger=_LOGGER)
87
+ self._config = map_parser_config or B01Q10MapParserConfig()
88
+ self._map_dps = map_dps
89
+ self._map_packet: Q10MapPacket | None = None
90
+ self._trace_packet: Q10TracePacket | None = None
91
+ self._image_content: bytes | None = None
92
+ self._map_dps.add_update_listener(self._map_dps_updated)
93
+
94
+ @property
95
+ def image_content(self) -> bytes | None:
96
+ """The composed map PNG, if the latest map rendered successfully."""
97
+ return self._image_content
98
+
99
+ @property
100
+ def rooms(self) -> list[Q10Room]:
101
+ """Rooms reported by the device."""
102
+ return self._map_packet.rooms if self._map_packet else []
103
+
104
+ @property
105
+ def path(self) -> list[Q10Point]:
106
+ """Full path for live status and callers drawing their own map overlay."""
107
+ return self._trace_packet.points if self._trace_packet else []
108
+
109
+ @property
110
+ def robot_position(self) -> Q10Point | None:
111
+ """Current position for live status and caller-rendered map overlays."""
112
+ return self._trace_packet.robot_position if self._trace_packet else None
113
+
114
+ @property
115
+ def robot_heading(self) -> int | None:
116
+ """Current heading for orienting a robot marker on a caller-rendered map."""
117
+ return self._trace_packet.heading if self._trace_packet else None
118
+
119
+ def update_from_map_packet(self, packet: Q10MapPacket) -> None:
120
+ """Store a map-protocol update and render the latest sources."""
121
+ self._map_packet = packet
122
+ self._render()
123
+ self._notify_update()
124
+
125
+ def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
126
+ """Store a trace-protocol update and render the latest sources."""
127
+ self._trace_packet = packet
128
+ self._render()
129
+ self._notify_update()
130
+
131
+ def _map_dps_updated(self) -> None:
132
+ """Render after the low-level DPS source changes."""
133
+ if self._map_packet is None:
134
+ return
135
+ self._render()
136
+ self._notify_update()
137
+
138
+ def _render(self) -> None:
139
+ """Render the required map with the latest optional trace and overlays."""
140
+ if self._map_packet is None:
141
+ return
142
+ try:
143
+ self._image_content = render_q10_map(
144
+ self._map_packet,
145
+ self._trace_packet,
146
+ self._map_dps.overlays,
147
+ config=self._config,
148
+ )
149
+ except RoborockException as ex:
150
+ _LOGGER.debug("Failed to render Q10 map packet: %s", ex)
151
+ self._image_content = None
@@ -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,
@@ -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:
@@ -1,110 +0,0 @@
1
- """Map content trait for B01 Q10 devices.
2
-
3
- Unlike the v1 / Q7 maps, the Q10 has no synchronous "get map" command, so this
4
- trait is purely push-driven and mirrors the Q10 ``StatusTrait`` contract:
5
-
6
- - The device pushes its current map/path as protocol-301 ``MAP_RESPONSE``
7
- messages (a ``dpRequestDps`` nudges it to do so). The protocol layer decodes
8
- those into :class:`Q10MapPacket` / :class:`Q10TracePacket` objects and the
9
- ``Q10PropertiesApi`` subscribe loop routes them to
10
- :meth:`MapContentTrait.update_from_map_packet` /
11
- :meth:`MapContentTrait.update_from_trace_packet`.
12
- - Those methods render/cache the content and notify update listeners (register
13
- via :meth:`add_update_listener`).
14
- - ``image_content``, ``map_data``, ``rooms``, ``path`` and ``robot_position``
15
- are readable and reflect the most recently pushed map.
16
-
17
- Unlike the Q7, the Q10 map payload is unencrypted, so no map key is required.
18
- """
19
-
20
- import logging
21
- from dataclasses import dataclass, field
22
-
23
- from vacuum_map_parser_base.map_data import MapData
24
-
25
- from roborock.data import RoborockBase
26
- from roborock.devices.traits.common import TraitUpdateListener
27
- from roborock.map.b01_q10_map_parser import (
28
- B01Q10MapParser,
29
- B01Q10MapParserConfig,
30
- Q10MapPacket,
31
- Q10Point,
32
- Q10Room,
33
- Q10TracePacket,
34
- )
35
-
36
- _LOGGER = logging.getLogger(__name__)
37
-
38
- _TRUNCATE_LENGTH = 20
39
-
40
-
41
- @dataclass
42
- class MapContent(RoborockBase):
43
- """Dataclass representing Q10 map content."""
44
-
45
- image_content: bytes | None = None
46
- """The rendered image of the map in PNG format."""
47
-
48
- map_data: MapData | None = None
49
- """Parsed map data (image metadata + room names)."""
50
-
51
- rooms: list[Q10Room] = field(default_factory=list)
52
- """Rooms (segments) reported by the device, with ids and names."""
53
-
54
- path: list[Q10Point] = field(default_factory=list)
55
- """Full path of the current cleaning session (oldest point first).
56
-
57
- The robot accumulates this server-side and serves the whole trajectory so
58
- far in one packet, so it is complete even if we connect mid-session. Only
59
- populated while a cleaning session is active."""
60
-
61
- robot_position: Q10Point | None = None
62
- """Current robot position (the most recent path point), if known."""
63
-
64
- def __repr__(self) -> str:
65
- img = self.image_content
66
- if img and len(img) > _TRUNCATE_LENGTH:
67
- img = img[: _TRUNCATE_LENGTH - 3] + b"..."
68
- return f"MapContent(image_content={img!r}, rooms={self.rooms!r})"
69
-
70
-
71
- class MapContentTrait(MapContent, TraitUpdateListener):
72
- """Trait holding the most recently pushed parsed map content for Q10 devices.
73
-
74
- The Q10 has no synchronous get-map request; the device pushes map and trace
75
- packets, which the protocol layer decodes and the ``Q10PropertiesApi``
76
- subscribe loop feeds into :meth:`update_from_map_packet` /
77
- :meth:`update_from_trace_packet`. Consumers read the cached fields and/or
78
- register a callback with :meth:`add_update_listener` to be notified when new
79
- map content arrives.
80
- """
81
-
82
- def __init__(
83
- self,
84
- *,
85
- map_parser_config: B01Q10MapParserConfig | None = None,
86
- ) -> None:
87
- super().__init__()
88
- TraitUpdateListener.__init__(self, logger=_LOGGER)
89
- self._map_parser = B01Q10MapParser(map_parser_config)
90
-
91
- def update_from_map_packet(self, packet: Q10MapPacket) -> None:
92
- """Render a pushed full-map packet into the cached image/rooms.
93
-
94
- Rendering failures are logged and skipped (listeners are not notified) so
95
- a single bad push cannot tear down the subscribe loop.
96
- """
97
- parsed = self._map_parser.parse_packet(packet)
98
- if parsed.image_content is None:
99
- _LOGGER.debug("Failed to render Q10 map image")
100
- return
101
- self.image_content = parsed.image_content
102
- self.map_data = parsed.map_data
103
- self.rooms = packet.rooms
104
- self._notify_update()
105
-
106
- def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
107
- """Cache the path/robot position from a pushed trace packet."""
108
- self.path = packet.points
109
- self.robot_position = packet.robot_position
110
- self._notify_update()