python-roborock 7.6.0__tar.gz → 7.7.1__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 (134) hide show
  1. {python_roborock-7.6.0 → python_roborock-7.7.1}/PKG-INFO +1 -1
  2. {python_roborock-7.6.0 → python_roborock-7.7.1}/pyproject.toml +1 -1
  3. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/b01_q10/b01_q10_code_mappings.py +2 -7
  4. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/b01_q10/b01_q10_containers.py +56 -0
  5. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/__init__.py +2 -1
  6. python_roborock-7.7.1/roborock/devices/traits/b01/q10/goto.py +158 -0
  7. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/map.py +15 -5
  8. python_roborock-7.7.1/roborock/devices/traits/b01/q10/vacuum.py +291 -0
  9. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/map/b01_q10_map_parser.py +6 -1
  10. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/protocols/b01_q10_protocol.py +48 -0
  11. python_roborock-7.6.0/roborock/devices/traits/b01/q10/vacuum.py +0 -109
  12. {python_roborock-7.6.0 → python_roborock-7.7.1}/.gitignore +0 -0
  13. {python_roborock-7.6.0 → python_roborock-7.7.1}/LICENSE +0 -0
  14. {python_roborock-7.6.0 → python_roborock-7.7.1}/README.md +0 -0
  15. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/__init__.py +0 -0
  16. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/broadcast_protocol.py +0 -0
  17. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/callbacks.py +0 -0
  18. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/cli.py +0 -0
  19. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/const.py +0 -0
  20. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/__init__.py +0 -0
  21. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/b01_q10/__init__.py +0 -0
  22. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/b01_q7/__init__.py +0 -0
  23. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  24. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  25. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/code_mappings.py +0 -0
  26. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/containers.py +0 -0
  27. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/dyad/__init__.py +0 -0
  28. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  29. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/dyad/dyad_containers.py +0 -0
  30. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/mower/__init__.py +0 -0
  31. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/mower/mower_code_mappings.py +0 -0
  32. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/mower/mower_containers.py +0 -0
  33. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/v1/__init__.py +0 -0
  34. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/v1/v1_clean_modes.py +0 -0
  35. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/v1/v1_code_mappings.py +0 -0
  36. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/v1/v1_containers.py +0 -0
  37. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/zeo/__init__.py +0 -0
  38. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  39. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/data/zeo/zeo_containers.py +0 -0
  40. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/device_features.py +0 -0
  41. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/README.md +0 -0
  42. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/__init__.py +0 -0
  43. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/cache.py +0 -0
  44. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/device.py +0 -0
  45. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/device_manager.py +0 -0
  46. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/file_cache.py +0 -0
  47. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/rpc/__init__.py +0 -0
  48. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/rpc/a01_channel.py +0 -0
  49. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  50. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  51. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/rpc/v1_channel.py +0 -0
  52. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/__init__.py +0 -0
  53. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/a01/__init__.py +0 -0
  54. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/a01/device_feature.py +0 -0
  55. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/__init__.py +0 -0
  56. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  57. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  58. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  59. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/command.py +0 -0
  60. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/common.py +0 -0
  61. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  62. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  63. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  64. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/maps.py +0 -0
  65. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  66. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/remote.py +0 -0
  67. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/status.py +0 -0
  68. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q10/volume.py +0 -0
  69. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  70. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  71. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q7/map.py +0 -0
  72. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  73. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/common.py +0 -0
  74. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/traits_mixin.py +0 -0
  75. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/__init__.py +0 -0
  76. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/child_lock.py +0 -0
  77. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/clean_summary.py +0 -0
  78. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/command.py +0 -0
  79. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/common.py +0 -0
  80. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/consumeable.py +0 -0
  81. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/device_features.py +0 -0
  82. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  83. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  84. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  85. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/home.py +0 -0
  86. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/led_status.py +0 -0
  87. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/map_content.py +0 -0
  88. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/maps.py +0 -0
  89. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/network_info.py +0 -0
  90. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/obstacle_photos.py +0 -0
  91. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/rooms.py +0 -0
  92. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/routines.py +0 -0
  93. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  94. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/status.py +0 -0
  95. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  96. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/volume.py +0 -0
  97. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  98. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/transport/__init__.py +0 -0
  99. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/transport/channel.py +0 -0
  100. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/transport/local_channel.py +0 -0
  101. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/devices/transport/mqtt_channel.py +0 -0
  102. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/diagnostics.py +0 -0
  103. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/exceptions.py +0 -0
  104. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/map/__init__.py +0 -0
  105. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/map/b01_grid_layers.py +0 -0
  106. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/map/b01_map_parser.py +0 -0
  107. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/map/b01_q10_overlays.py +0 -0
  108. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/map/b01_q10_render.py +0 -0
  109. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/map/map_parser.py +0 -0
  110. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/map/proto/__init__.py +0 -0
  111. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/map/proto/b01_scmap.proto +0 -0
  112. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  113. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/map/room_colors.py +0 -0
  114. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/mqtt/__init__.py +0 -0
  115. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/mqtt/health_manager.py +0 -0
  116. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/mqtt/roborock_session.py +0 -0
  117. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/mqtt/session.py +0 -0
  118. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/protocol.py +0 -0
  119. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/protocols/__init__.py +0 -0
  120. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/protocols/a01_protocol.py +0 -0
  121. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/protocols/b01_q7_protocol.py +0 -0
  122. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/protocols/v1_protocol.py +0 -0
  123. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/py.typed +0 -0
  124. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/roborock_message.py +0 -0
  125. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/roborock_typing.py +0 -0
  126. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/testing/__init__.py +0 -0
  127. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/testing/a01_simulator.py +0 -0
  128. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/testing/b01_q10_simulator.py +0 -0
  129. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/testing/channel.py +0 -0
  130. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/testing/cloud.py +0 -0
  131. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/testing/simulator.py +0 -0
  132. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/testing/v1_simulator.py +0 -0
  133. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/util.py +0 -0
  134. {python_roborock-7.6.0 → python_roborock-7.7.1}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-roborock
3
- Version: 7.6.0
3
+ Version: 7.7.1
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 = "7.6.0"
3
+ version = "7.7.1"
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"
@@ -101,13 +101,8 @@ class B01_Q10_DP(RoborockModeEnum):
101
101
  VOICE_VERSION = ("dpVoiceVersion", 108)
102
102
  ROBOT_COUNTRY_CODE = ("dpRobotCountryCode", 109)
103
103
  HEARTBEAT = ("dpHeartbeat", 110)
104
- # NOTE: ss07 hardware also pushes data points 112 and 113 in its full status
105
- # dump. They are absent from the official app's vacuum plugin and stayed 0
106
- # across every observed state (docked/charging, segment cleaning, lifted-off-
107
- # ground fault, returning to dock, dustbin removed), so their meaning is not
108
- # yet known. They are intentionally left unmapped; ``decode_rpc_response``
109
- # silently ignores unknown codes via ``from_code_optional``, so they do not
110
- # produce "not a valid code" warnings. Map them here once identified.
104
+ UNKNOWN_112 = ("dpUnknown112", 112)
105
+ UNKNOWN_113 = ("dpUnknown113", 113)
111
106
  STATUS = ("dpStatus", 121)
112
107
  BATTERY = ("dpBattery", 122)
113
108
  FAN_LEVEL = ("dpFanLevel", 123) # NOTE: typo "dpfunLevel" in source code
@@ -28,6 +28,62 @@ from .b01_q10_code_mappings import (
28
28
  YXWaterLevel,
29
29
  )
30
30
 
31
+ _ROBOROCK_COORDINATE_OFFSET_MM = 25_500
32
+ _Q10_TRACE_UNIT_MM = 2.5
33
+ _Q10_VECTOR_UNIT_MM = 5
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Q10RoborockPoint:
38
+ """A point in the common Roborock millimetre coordinate space.
39
+
40
+ Q10 trace and vector coordinates are firmware details. Public Q10 APIs use
41
+ this coordinate system, matching other Roborock devices and placing the dock
42
+ at ``(25500, 25500)``.
43
+ """
44
+
45
+ x: int
46
+ y: int
47
+
48
+ @classmethod
49
+ def from_trace(cls, x: int, y: int) -> "Q10RoborockPoint":
50
+ """Convert Q10 trace coordinates to common Roborock coordinates."""
51
+ for value in (x, y):
52
+ if isinstance(value, bool) or not isinstance(value, int):
53
+ raise ValueError("trace coordinates must be integers")
54
+ return cls(
55
+ x=round(_ROBOROCK_COORDINATE_OFFSET_MM + x * _Q10_TRACE_UNIT_MM),
56
+ y=round(_ROBOROCK_COORDINATE_OFFSET_MM + y * _Q10_TRACE_UNIT_MM),
57
+ )
58
+
59
+ @classmethod
60
+ def from_vector(cls, x: int, y: int) -> "Q10RoborockPoint":
61
+ """Convert Q10 vector coordinates to common Roborock coordinates."""
62
+ for value in (x, y):
63
+ if isinstance(value, bool) or not isinstance(value, int):
64
+ raise ValueError("vector coordinates must be integers")
65
+ if not -(2**15) <= value < 2**15:
66
+ raise ValueError("vector coordinates are outside the Q10 map range")
67
+ return cls(
68
+ x=_ROBOROCK_COORDINATE_OFFSET_MM + x * _Q10_VECTOR_UNIT_MM,
69
+ y=_ROBOROCK_COORDINATE_OFFSET_MM + y * _Q10_VECTOR_UNIT_MM,
70
+ )
71
+
72
+ def to_vector(self) -> tuple[int, int]:
73
+ """Convert common Roborock coordinates to the Q10 vector grid."""
74
+ coordinates: list[int] = []
75
+ for value in (self.x, self.y):
76
+ if isinstance(value, bool) or not isinstance(value, int):
77
+ raise ValueError("coordinates must be integers")
78
+ relative_mm = value - _ROBOROCK_COORDINATE_OFFSET_MM
79
+ if relative_mm % _Q10_VECTOR_UNIT_MM:
80
+ raise ValueError("coordinates must align to the Q10 5 mm grid")
81
+ coordinate = relative_mm // _Q10_VECTOR_UNIT_MM
82
+ if not -(2**15) <= coordinate < 2**15:
83
+ raise ValueError("coordinates are outside the Q10 map range")
84
+ coordinates.append(coordinate)
85
+ return coordinates[0], coordinates[1]
86
+
31
87
 
32
88
  @dataclass
33
89
  class dpCleanRecord(RoborockBase):
@@ -96,7 +96,6 @@ class Q10PropertiesApi(Trait):
96
96
  """Initialize the B01Props API."""
97
97
  self._channel = channel
98
98
  self.command = CommandTrait(channel)
99
- self.vacuum = VacuumTrait(self.command)
100
99
  self.remote = RemoteTrait(self.command)
101
100
  self.status = StatusTrait()
102
101
  self.volume = SoundVolumeTrait(self.command)
@@ -109,6 +108,7 @@ class Q10PropertiesApi(Trait):
109
108
  self._map_dps = MapDpsTrait()
110
109
  self.maps = MapsTrait(self.command)
111
110
  self.map = MapContentTrait(self._map_dps, self.maps, self.command)
111
+ self.vacuum = VacuumTrait(self.command, self.status, self.map)
112
112
  self.clean_history = CleanHistoryTrait(self.command)
113
113
  # Read-model traits updated from the device's DPS push stream.
114
114
  self._updatable_traits = [
@@ -131,6 +131,7 @@ class Q10PropertiesApi(Trait):
131
131
 
132
132
  async def close(self) -> None:
133
133
  """Close any resources held by the trait."""
134
+ await self.vacuum.close()
134
135
  if self._subscribe_task is not None:
135
136
  self._subscribe_task.cancel()
136
137
  try:
@@ -0,0 +1,158 @@
1
+ """State management for an emulated Q10 goto action."""
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from math import hypot
8
+
9
+ from roborock.callbacks import CallbackList
10
+ from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceCleanTask, YXDeviceState
11
+ from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+ _TERMINAL_STATES = {
15
+ YXDeviceState.IDLE,
16
+ YXDeviceState.PAUSED,
17
+ YXDeviceState.RETURNING_HOME,
18
+ YXDeviceState.CHARGING,
19
+ }
20
+
21
+
22
+ class GotoActionCommand(StrEnum):
23
+ """A command requested by a Q10 goto action."""
24
+
25
+ PAUSE = "pause"
26
+ STOP = "stop"
27
+ COMPLETE = "complete"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class GotoSnapshot:
32
+ """Device state needed to advance a goto action."""
33
+
34
+ position: Q10RoborockPoint | None
35
+ trace_sequence: int | None
36
+ clean_task_type: YXDeviceCleanTask | None
37
+ status: YXDeviceState | None
38
+
39
+
40
+ class GotoAction:
41
+ """Decide how one emulated goto should react to device updates.
42
+
43
+ The action owns no tasks and sends no device commands. ``VacuumTrait`` feeds
44
+ it push-derived snapshots and performs commands requested by its callbacks.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ target: Q10RoborockPoint,
50
+ previous_trace_sequence: int | None,
51
+ *,
52
+ tolerance: int,
53
+ ) -> None:
54
+ """Initialize a goto action waiting for a new trace session."""
55
+ self._target = target
56
+ self._previous_trace_sequence = previous_trace_sequence
57
+ self._tolerance = tolerance
58
+ self._owned_trace_sequence: int | None = None
59
+ self._owned_task_seen = False
60
+ self._command_pending = False
61
+ self._timeout_requested = False
62
+ self._finished = False
63
+ self._latest_snapshot: GotoSnapshot | None = None
64
+ self._callbacks: CallbackList[GotoActionCommand] = CallbackList(logger=_LOGGER)
65
+
66
+ def add_update_listener(self, callback: Callable[[GotoActionCommand], None]) -> Callable[[], None]:
67
+ """Register a callback for the next command requested by the action."""
68
+ return self._callbacks.add_callback(callback)
69
+
70
+ def update(self, snapshot: GotoSnapshot) -> None:
71
+ """Process the latest push-derived device state."""
72
+ self._latest_snapshot = snapshot
73
+ self._evaluate(snapshot)
74
+
75
+ def retry(self) -> None:
76
+ """Re-evaluate the latest state after a requested command failed."""
77
+ if self._finished or self._latest_snapshot is None:
78
+ return
79
+ self._command_pending = False
80
+ if self._timeout_requested:
81
+ self._evaluate_timeout(self._latest_snapshot)
82
+ else:
83
+ self._evaluate(self._latest_snapshot)
84
+
85
+ def timeout(self, snapshot: GotoSnapshot) -> None:
86
+ """Request a stop only if this action still owns the current zone task."""
87
+ if self._finished:
88
+ return
89
+ self._latest_snapshot = snapshot
90
+ self._timeout_requested = True
91
+ if self._command_pending:
92
+ return
93
+ self._evaluate_timeout(snapshot)
94
+
95
+ def _evaluate_timeout(self, snapshot: GotoSnapshot) -> None:
96
+ """Derive the safe timeout command from the latest device state."""
97
+ if self.owns(snapshot):
98
+ self._emit(GotoActionCommand.STOP)
99
+ else:
100
+ self._emit(GotoActionCommand.COMPLETE)
101
+
102
+ def complete(self) -> None:
103
+ """Mark the action complete after its requested command succeeds."""
104
+ self._finished = True
105
+ self._command_pending = False
106
+
107
+ def owns(self, snapshot: GotoSnapshot) -> bool:
108
+ """Return whether this action owns the current Q10 zone-clean session."""
109
+ return (
110
+ self._owned_trace_sequence is not None
111
+ and snapshot.trace_sequence == self._owned_trace_sequence
112
+ and snapshot.clean_task_type is YXDeviceCleanTask.DIVIDE_AREAS
113
+ )
114
+
115
+ def _evaluate(self, snapshot: GotoSnapshot) -> None:
116
+ """Derive the next command from the latest device state."""
117
+ if self._finished or self._command_pending:
118
+ return
119
+
120
+ if self._owned_trace_sequence is None:
121
+ if snapshot.trace_sequence is not None and snapshot.trace_sequence != self._previous_trace_sequence:
122
+ self._owned_trace_sequence = snapshot.trace_sequence
123
+ elif snapshot.trace_sequence != self._owned_trace_sequence:
124
+ _LOGGER.debug("Q10 goto task was replaced by another cleaning session")
125
+ self._emit(GotoActionCommand.COMPLETE)
126
+ return
127
+
128
+ if (
129
+ self._owned_trace_sequence is not None
130
+ and snapshot.clean_task_type is YXDeviceCleanTask.DIVIDE_AREAS
131
+ and snapshot.status not in _TERMINAL_STATES
132
+ ):
133
+ self._owned_task_seen = True
134
+
135
+ if self._owned_task_seen and (
136
+ snapshot.clean_task_type is not YXDeviceCleanTask.DIVIDE_AREAS or snapshot.status in _TERMINAL_STATES
137
+ ):
138
+ self._emit(GotoActionCommand.COMPLETE)
139
+ return
140
+
141
+ if (
142
+ self.owns(snapshot)
143
+ and snapshot.position is not None
144
+ and hypot(
145
+ snapshot.position.x - self._target.x,
146
+ snapshot.position.y - self._target.y,
147
+ )
148
+ <= self._tolerance
149
+ ):
150
+ self._emit(GotoActionCommand.PAUSE)
151
+
152
+ def _emit(self, command: GotoActionCommand) -> None:
153
+ """Publish a requested command once until it is handled."""
154
+ if command is GotoActionCommand.COMPLETE:
155
+ self._finished = True
156
+ else:
157
+ self._command_pending = True
158
+ self._callbacks(command)
@@ -19,6 +19,7 @@ from typing import Any
19
19
 
20
20
  from roborock.data import RoborockBase
21
21
  from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState
22
+ from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
22
23
  from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
23
24
  from roborock.exceptions import RoborockException
24
25
  from roborock.map.b01_q10_map_parser import (
@@ -127,13 +128,20 @@ class MapContentTrait(TraitUpdateListener):
127
128
 
128
129
  @property
129
130
  def path(self) -> list[Q10Point]:
130
- """Full path for live status and callers drawing their own map overlay."""
131
+ """Full path in the Q10 trace coordinate space used by the map renderer."""
131
132
  return self._trace_packet.points if self._trace_packet else []
132
133
 
133
134
  @property
134
- def robot_position(self) -> Q10Point | None:
135
- """Current position for live status and caller-rendered map overlays."""
136
- return self._trace_packet.robot_position if self._trace_packet else None
135
+ def robot_position(self) -> Q10RoborockPoint | None:
136
+ """Current position in the common Roborock millimetre coordinate space."""
137
+ if self._trace_packet is None or (position := self._trace_packet.robot_position) is None:
138
+ return None
139
+ return position.to_roborock()
140
+
141
+ @property
142
+ def trace_sequence(self) -> int | None:
143
+ """Current cleaning-session sequence from the trace stream."""
144
+ return self._trace_packet.sequence if self._trace_packet else None
137
145
 
138
146
  @property
139
147
  def robot_heading(self) -> int | None:
@@ -181,7 +189,9 @@ class MapContentTrait(TraitUpdateListener):
181
189
  data = {
182
190
  "rooms": [room.as_dict() for room in self.rooms],
183
191
  "path": [point.as_dict() for point in self.path],
184
- "robotPosition": self.robot_position.as_dict() if self.robot_position is not None else None,
192
+ "robotPosition": (
193
+ {"x": position.x, "y": position.y} if (position := self.robot_position) is not None else None
194
+ ),
185
195
  "robotHeading": self.robot_heading,
186
196
  }
187
197
  for key in exclude_set:
@@ -0,0 +1,291 @@
1
+ """Traits for Q10 B01 devices."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Callable
6
+ from math import hypot
7
+
8
+ from roborock.data.b01_q10.b01_q10_code_mappings import (
9
+ B01_Q10_DP,
10
+ YXCleanType,
11
+ YXDeviceCleanTask,
12
+ YXFanLevel,
13
+ )
14
+ from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
15
+ from roborock.exceptions import RoborockException
16
+ from roborock.protocols.b01_q10_protocol import CleanParams, encode_clean_params
17
+
18
+ from .command import CommandTrait
19
+ from .goto import GotoAction, GotoActionCommand, GotoSnapshot
20
+ from .map import MapContentTrait
21
+ from .status import StatusTrait
22
+
23
+ _GOTO_HALF_ZONE_SIZE = 200
24
+ _GOTO_TOLERANCE = 200
25
+ _GOTO_TIMEOUT = 300
26
+ _GOTO_RETRY_INTERVAL = 1
27
+
28
+ _LOGGER = logging.getLogger(__name__)
29
+
30
+
31
+ class VacuumTrait:
32
+ """Trait for sending vacuum commands.
33
+
34
+ This is a wrapper around the CommandTrait for sending vacuum related
35
+ commands to Q10 devices.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ command: CommandTrait,
41
+ status: StatusTrait,
42
+ map_content: MapContentTrait,
43
+ ) -> None:
44
+ """Initialize the VacuumTrait."""
45
+ self._command = command
46
+ self._status = status
47
+ self._map = map_content
48
+ self._goto_action: GotoAction | None = None
49
+ self._goto_action_remove_listener: Callable[[], None] | None = None
50
+ self._goto_timeout_task: asyncio.Task[None] | None = None
51
+ self._goto_command_task: asyncio.Task[None] | None = None
52
+ self._remove_map_listener = self._map.add_update_listener(self._goto_source_updated)
53
+ self._remove_status_listener = self._status.add_update_listener(self._goto_source_updated)
54
+
55
+ async def close(self) -> None:
56
+ """Cancel background work owned by the trait."""
57
+ self.cancel_goto()
58
+ self._remove_map_listener()
59
+ self._remove_status_listener()
60
+
61
+ def cancel_goto(self) -> None:
62
+ """Cancel an emulated goto replaced by another command."""
63
+ if self._goto_action is not None:
64
+ self._goto_action.complete()
65
+ self._goto_action = None
66
+ if self._goto_action_remove_listener is not None:
67
+ self._goto_action_remove_listener()
68
+ self._goto_action_remove_listener = None
69
+ current_task = asyncio.current_task()
70
+ for task_name in ("_goto_timeout_task", "_goto_command_task"):
71
+ if (task := getattr(self, task_name)) is not None:
72
+ if task is not current_task:
73
+ task.cancel()
74
+ setattr(self, task_name, None)
75
+
76
+ def _goto_snapshot(self) -> GotoSnapshot:
77
+ """Return the latest state used by an active goto action."""
78
+ return GotoSnapshot(
79
+ position=self._map.robot_position,
80
+ trace_sequence=self._map.trace_sequence,
81
+ clean_task_type=self._status.clean_task_type,
82
+ status=self._status.status,
83
+ )
84
+
85
+ def _goto_source_updated(self) -> None:
86
+ """Feed push-derived map or status state to the active goto action."""
87
+ if self._goto_action is not None:
88
+ self._goto_action.update(self._goto_snapshot())
89
+
90
+ def _goto_action_updated(self, action: GotoAction, command: GotoActionCommand) -> None:
91
+ """Schedule a device command requested by the active goto action."""
92
+ if action is not self._goto_action:
93
+ return
94
+ if command is GotoActionCommand.COMPLETE:
95
+ self.cancel_goto()
96
+ return
97
+ if self._goto_command_task is None:
98
+ self._goto_command_task = asyncio.create_task(
99
+ self._async_handle_goto_command(action, command),
100
+ name="roborock_q10_goto_command",
101
+ )
102
+
103
+ async def _async_handle_goto_command(self, action: GotoAction, command: GotoActionCommand) -> None:
104
+ """Perform a pause or stop requested by the active goto action."""
105
+ current_task = asyncio.current_task()
106
+ dp_command = B01_Q10_DP.PAUSE if command is GotoActionCommand.PAUSE else B01_Q10_DP.STOP
107
+ try:
108
+ await self._command.send(command=dp_command, params=0)
109
+ except RoborockException as err:
110
+ if command is GotoActionCommand.PAUSE:
111
+ _LOGGER.warning("Failed to pause completed Q10 goto task; retrying: %s", err)
112
+ await asyncio.sleep(_GOTO_RETRY_INTERVAL)
113
+ if action is self._goto_action:
114
+ self._goto_command_task = None
115
+ action.retry()
116
+ return
117
+ _LOGGER.warning("Failed to stop timed-out Q10 goto task: %s", err)
118
+ if action is self._goto_action:
119
+ action.complete()
120
+ self.cancel_goto()
121
+ if self._goto_command_task is current_task:
122
+ self._goto_command_task = None
123
+
124
+ async def _async_timeout_goto(self, action: GotoAction) -> None:
125
+ """Tell the active goto action when its safety timeout expires."""
126
+ try:
127
+ await asyncio.sleep(_GOTO_TIMEOUT)
128
+ except asyncio.CancelledError:
129
+ return
130
+ if action is self._goto_action:
131
+ action.timeout(self._goto_snapshot())
132
+
133
+ async def start_clean(self) -> None:
134
+ """Start a whole-home clean.
135
+
136
+ The ``dpStartClean`` (201) command selects a task by code: ``1`` =
137
+ whole-home, ``2`` = segment/room (see :meth:`clean_segments`), ``3`` =
138
+ zone, ``4`` = build map, ``5`` = spot. Whole-home and spot accept the
139
+ bare integer code; segment cleaning needs a room selection (an object
140
+ payload) instead.
141
+
142
+ Verified live against ss07 hardware: ``{"dps": {"201": 1}}`` starts a
143
+ whole-home clean (clean_task_type -> 1).
144
+ """
145
+ await self._command.send(command=B01_Q10_DP.START_CLEAN, params=1)
146
+ self.cancel_goto()
147
+
148
+ async def clean_segments(self, segment_ids: list[int]) -> None:
149
+ """Start a room / segment clean for the given segment (room) ids.
150
+
151
+ The ids are the same room ids the device reports on its map (see the Q10
152
+ ``MapContentTrait`` -- ``map.rooms``, each with an ``id``).
153
+
154
+ Unlike whole-home and spot, ``dpStartClean`` (201) carries the room
155
+ selection as an object: ``{"cmd": <task>, "clean_paramters": [<id>, ...]}``,
156
+ where ``cmd`` is the segment-clean task code.
157
+
158
+ Verified live against ss07 hardware: sending
159
+ ``{"dps": {"201": {"cmd": 2, "clean_paramters": [9]}}}`` starts cleaning
160
+ room 9 (clean_task_type -> 2 / electoral).
161
+ """
162
+ await self._command.send(
163
+ command=B01_Q10_DP.START_CLEAN,
164
+ # "clean_paramters" intentionally mirrors the device's misspelling of
165
+ # "parameters" -- the firmware only accepts that exact key.
166
+ params={"cmd": YXDeviceCleanTask.ELECTORAL.code, "clean_paramters": segment_ids},
167
+ )
168
+ self.cancel_goto()
169
+
170
+ async def clean_zone(
171
+ self,
172
+ first_corner: Q10RoborockPoint,
173
+ second_corner: Q10RoborockPoint,
174
+ *,
175
+ clean_count: int = 1,
176
+ ) -> None:
177
+ """Clean one rectangular zone in the common Roborock coordinate space."""
178
+ encoded_zone = encode_clean_params(CleanParams(first_corner, second_corner, clean_count))
179
+ await self._command.send(
180
+ command=B01_Q10_DP.START_CLEAN,
181
+ params={
182
+ "cmd": YXDeviceCleanTask.DIVIDE_AREAS.code,
183
+ # "clean_paramters" is the spelling required by the firmware.
184
+ "clean_paramters": encoded_zone,
185
+ },
186
+ )
187
+ self.cancel_goto()
188
+
189
+ async def goto_position(self, target: Q10RoborockPoint) -> None:
190
+ """Move to a coordinate using an owned 40 cm zone-clean task."""
191
+ target.to_vector()
192
+ snapshot = self._goto_snapshot()
193
+ if (position := snapshot.position) is not None and hypot(
194
+ position.x - target.x, position.y - target.y
195
+ ) <= _GOTO_TOLERANCE:
196
+ if self._goto_action is not None and self._goto_action.owns(snapshot):
197
+ await self._command.send(command=B01_Q10_DP.PAUSE, params=0)
198
+ self.cancel_goto()
199
+ return
200
+
201
+ encoded_zone = encode_clean_params(
202
+ CleanParams(
203
+ Q10RoborockPoint(
204
+ target.x - _GOTO_HALF_ZONE_SIZE,
205
+ target.y - _GOTO_HALF_ZONE_SIZE,
206
+ ),
207
+ Q10RoborockPoint(
208
+ target.x + _GOTO_HALF_ZONE_SIZE,
209
+ target.y + _GOTO_HALF_ZONE_SIZE,
210
+ ),
211
+ )
212
+ )
213
+ await self._command.send(
214
+ command=B01_Q10_DP.START_CLEAN,
215
+ params={
216
+ "cmd": YXDeviceCleanTask.DIVIDE_AREAS.code,
217
+ "clean_paramters": encoded_zone,
218
+ },
219
+ )
220
+ self.cancel_goto()
221
+ action = GotoAction(
222
+ target,
223
+ snapshot.trace_sequence,
224
+ tolerance=_GOTO_TOLERANCE,
225
+ )
226
+ self._goto_action = action
227
+ self._goto_action_remove_listener = action.add_update_listener(
228
+ lambda command: self._goto_action_updated(action, command)
229
+ )
230
+ self._goto_timeout_task = asyncio.create_task(
231
+ self._async_timeout_goto(action),
232
+ name="roborock_q10_goto_timeout",
233
+ )
234
+ action.update(self._goto_snapshot())
235
+
236
+ async def spot_clean(self) -> None:
237
+ """Start a spot / part clean around the robot's current position.
238
+
239
+ Verified live: ``{"dps": {"201": 5}}`` (clean_task_type -> 5).
240
+ """
241
+ await self._command.send(command=B01_Q10_DP.START_CLEAN, params=5)
242
+ self.cancel_goto()
243
+
244
+ async def pause_clean(self) -> None:
245
+ """Pause the current task. Verified live: ``{"dps": {"204": 0}}``."""
246
+ await self._command.send(command=B01_Q10_DP.PAUSE, params=0)
247
+ self.cancel_goto()
248
+
249
+ async def resume_clean(self) -> None:
250
+ """Resume a paused task. Verified live: ``{"dps": {"205": 0}}``."""
251
+ await self._command.send(command=B01_Q10_DP.RESUME, params=0)
252
+ self.cancel_goto()
253
+
254
+ async def stop_clean(self) -> None:
255
+ """Stop / cancel the current task. Verified live: ``{"dps": {"206": 0}}``."""
256
+ await self._command.send(command=B01_Q10_DP.STOP, params=0)
257
+ self.cancel_goto()
258
+
259
+ async def return_to_dock(self) -> None:
260
+ """Send the robot back to the dock to charge.
261
+
262
+ Uses ``dpStartBack`` (202) with the back-dock task code ``5`` (charge),
263
+ matching the official app. Verified live: ``{"dps": {"202": 5}}`` puts the
264
+ robot into the returning state. (The other back-dock codes are ``1`` =
265
+ wash mop en route and ``4`` = collect dust en route.)
266
+ """
267
+ await self._command.send(command=B01_Q10_DP.START_BACK, params=5)
268
+ self.cancel_goto()
269
+
270
+ async def empty_dustbin(self) -> None:
271
+ """Empty the dustbin at the dock.
272
+
273
+ Verified live: ``{"dps": {"203": 2}}`` triggers dust collection
274
+ (status -> emptying_the_bin). This is a dock task (``dpStartDockTask``),
275
+ distinct from the en-route collect-dust back-dock code.
276
+ """
277
+ await self._command.send(command=B01_Q10_DP.START_DOCK_TASK, params=2)
278
+
279
+ async def set_clean_mode(self, mode: YXCleanType) -> None:
280
+ """Set the cleaning mode (vacuum, mop, or both)."""
281
+ await self._command.send(
282
+ command=B01_Q10_DP.CLEAN_MODE,
283
+ params=mode.code,
284
+ )
285
+
286
+ async def set_fan_level(self, level: YXFanLevel) -> None:
287
+ """Set the fan suction level."""
288
+ await self._command.send(
289
+ command=B01_Q10_DP.FAN_LEVEL,
290
+ params=level.code,
291
+ )
@@ -29,6 +29,7 @@ from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
29
29
  from vacuum_map_parser_base.config.image_config import ImageConfig
30
30
  from vacuum_map_parser_base.map_data import ImageData, MapData, Point
31
31
 
32
+ from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
32
33
  from roborock.data.containers import RoborockBase
33
34
  from roborock.exceptions import RoborockException
34
35
 
@@ -222,11 +223,15 @@ class Q10MapPacket:
222
223
 
223
224
  @dataclass
224
225
  class Q10Point(RoborockBase):
225
- """A single point in Q10 map/trace coordinate space."""
226
+ """A point in the Q10 firmware's dock-relative trace coordinate space."""
226
227
 
227
228
  x: int
228
229
  y: int
229
230
 
231
+ def to_roborock(self) -> Q10RoborockPoint:
232
+ """Convert this trace point to common Roborock coordinates."""
233
+ return Q10RoborockPoint.from_trace(self.x, self.y)
234
+
230
235
 
231
236
  @dataclass
232
237
  class Q10TracePacket: