python-roborock 6.2.1__tar.gz → 7.1.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 (132) hide show
  1. {python_roborock-6.2.1 → python_roborock-7.1.1}/PKG-INFO +2 -2
  2. {python_roborock-6.2.1 → python_roborock-7.1.1}/pyproject.toml +1 -1
  3. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/cli.py +29 -12
  4. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/b01_q10/b01_q10_containers.py +22 -1
  5. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/b01_q7/b01_q7_code_mappings.py +12 -0
  6. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/code_mappings.py +10 -3
  7. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/containers.py +3 -2
  8. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/mower/__init__.py +1 -0
  9. python_roborock-7.1.1/roborock/data/mower/mower_code_mappings.py +72 -0
  10. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/mower/mower_containers.py +29 -3
  11. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/v1/v1_clean_modes.py +12 -18
  12. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/zeo/zeo_code_mappings.py +11 -12
  13. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/device_features.py +8 -0
  14. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/device.py +10 -0
  15. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/rpc/a01_channel.py +2 -0
  16. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/rpc/b01_q7_channel.py +23 -0
  17. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/a01/__init__.py +132 -6
  18. python_roborock-7.1.1/roborock/devices/traits/a01/device_feature.py +396 -0
  19. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/__init__.py +24 -7
  20. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/map.py +43 -6
  21. python_roborock-7.1.1/roborock/devices/traits/b01/q10/maps.py +58 -0
  22. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q7/__init__.py +14 -0
  23. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q7/map_content.py +36 -3
  24. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/status.py +1 -1
  25. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/diagnostics.py +1 -0
  26. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/map/b01_map_parser.py +9 -0
  27. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/map/b01_q10_map_parser.py +5 -4
  28. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/roborock_message.py +3 -0
  29. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/testing/__init__.py +20 -0
  30. python_roborock-7.1.1/roborock/testing/a01_simulator.py +287 -0
  31. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/testing/cloud.py +7 -1
  32. {python_roborock-6.2.1 → python_roborock-7.1.1}/.gitignore +0 -0
  33. {python_roborock-6.2.1 → python_roborock-7.1.1}/LICENSE +0 -0
  34. {python_roborock-6.2.1 → python_roborock-7.1.1}/README.md +0 -0
  35. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/__init__.py +0 -0
  36. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/broadcast_protocol.py +0 -0
  37. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/callbacks.py +0 -0
  38. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/const.py +0 -0
  39. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/__init__.py +0 -0
  40. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/b01_q10/__init__.py +0 -0
  41. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  42. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/b01_q7/__init__.py +0 -0
  43. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  44. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/dyad/__init__.py +0 -0
  45. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  46. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/dyad/dyad_containers.py +0 -0
  47. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/v1/__init__.py +0 -0
  48. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/v1/v1_code_mappings.py +0 -0
  49. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/v1/v1_containers.py +0 -0
  50. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/zeo/__init__.py +0 -0
  51. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/data/zeo/zeo_containers.py +0 -0
  52. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/README.md +0 -0
  53. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/__init__.py +0 -0
  54. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/cache.py +0 -0
  55. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/device_manager.py +0 -0
  56. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/file_cache.py +0 -0
  57. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/rpc/__init__.py +0 -0
  58. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  59. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/rpc/v1_channel.py +0 -0
  60. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/__init__.py +0 -0
  61. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/__init__.py +0 -0
  62. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  63. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  64. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  65. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/command.py +0 -0
  66. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/common.py +0 -0
  67. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  68. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  69. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  70. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  71. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/remote.py +0 -0
  72. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/status.py +0 -0
  73. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  74. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q10/volume.py +0 -0
  75. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  76. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/b01/q7/map.py +0 -0
  77. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/common.py +0 -0
  78. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/traits_mixin.py +0 -0
  79. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/__init__.py +0 -0
  80. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/child_lock.py +0 -0
  81. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/clean_summary.py +0 -0
  82. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/command.py +0 -0
  83. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/common.py +0 -0
  84. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/consumeable.py +0 -0
  85. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/device_features.py +0 -0
  86. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  87. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  88. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  89. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/home.py +0 -0
  90. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/led_status.py +0 -0
  91. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/map_content.py +0 -0
  92. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/maps.py +0 -0
  93. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/network_info.py +0 -0
  94. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/obstacle_photos.py +0 -0
  95. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/rooms.py +0 -0
  96. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/routines.py +0 -0
  97. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  98. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  99. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/volume.py +0 -0
  100. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  101. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/transport/__init__.py +0 -0
  102. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/transport/channel.py +0 -0
  103. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/transport/local_channel.py +0 -0
  104. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/devices/transport/mqtt_channel.py +0 -0
  105. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/exceptions.py +0 -0
  106. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/map/__init__.py +0 -0
  107. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/map/b01_grid_layers.py +0 -0
  108. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/map/b01_q10_overlays.py +0 -0
  109. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/map/b01_q10_render.py +0 -0
  110. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/map/map_parser.py +0 -0
  111. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/map/proto/__init__.py +0 -0
  112. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/map/proto/b01_scmap.proto +0 -0
  113. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  114. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/map/room_colors.py +0 -0
  115. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/mqtt/__init__.py +0 -0
  116. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/mqtt/health_manager.py +0 -0
  117. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/mqtt/roborock_session.py +0 -0
  118. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/mqtt/session.py +0 -0
  119. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/protocol.py +0 -0
  120. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/protocols/__init__.py +0 -0
  121. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/protocols/a01_protocol.py +0 -0
  122. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/protocols/b01_q10_protocol.py +0 -0
  123. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/protocols/b01_q7_protocol.py +0 -0
  124. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/protocols/v1_protocol.py +0 -0
  125. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/py.typed +0 -0
  126. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/roborock_typing.py +0 -0
  127. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/testing/b01_q10_simulator.py +0 -0
  128. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/testing/channel.py +0 -0
  129. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/testing/simulator.py +0 -0
  130. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/testing/v1_simulator.py +0 -0
  131. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/util.py +0 -0
  132. {python_roborock-6.2.1 → python_roborock-7.1.1}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-roborock
3
- Version: 6.2.1
3
+ Version: 7.1.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 = "6.2.1"
3
+ version = "7.1.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"
@@ -594,9 +594,9 @@ async def maps(ctx, device_id: str):
594
594
  await _display_v1_trait(context, device_id, lambda v1: v1.maps)
595
595
 
596
596
 
597
- # The Q10 pushes its map ~9s after a dpRequestDps; firmware throttles pushes to
598
- # ~once per 60-70s, so a single request is answered quickly but rapid re-requests
599
- # may not be. This bounds how long a one-shot CLI command waits for that push.
597
+ # The Q10 publishes its map asynchronously after a dpMultiMap list/get request.
598
+ # Firmware throttles pushes to ~once per 60-70s, so rapid re-requests may not be
599
+ # answered immediately. This bounds how long a one-shot CLI command waits.
600
600
  _Q10_MAP_PUSH_TIMEOUT = 30.0
601
601
 
602
602
 
@@ -607,13 +607,12 @@ async def _await_q10_map_push(
607
607
  timeout: float = _Q10_MAP_PUSH_TIMEOUT,
608
608
  allow_cached_on_timeout: bool = False,
609
609
  ) -> bool:
610
- """Nudge a Q10 to push its map/trace and wait for a fresh update.
610
+ """Request Q10 map content and wait for usable map-trait state.
611
611
 
612
- The Q10 map API is entirely push-driven: there is no synchronous get-map
613
- request. A ``dpRequestDps`` causes the device to publish a ``MAP_RESPONSE``,
614
- which the device's subscribe loop feeds into the map trait. Here we register
615
- an update listener, send the request, and wait for a newly pushed update to
616
- satisfy ``predicate``. Returns whether it did within ``timeout``.
612
+ A Q10 needs a saved-map ID before it can request content. The map list and
613
+ content have independent refresh schedules, so the list is requested only
614
+ when no ID is stored. The content then arrives as a later ``MAP_RESPONSE``
615
+ and is published through the standard trait update interface.
617
616
  """
618
617
  loop = asyncio.get_running_loop()
619
618
  updated: asyncio.Future[None] = loop.create_future()
@@ -624,8 +623,23 @@ async def _await_q10_map_push(
624
623
 
625
624
  unsub = properties.map.add_update_listener(on_update)
626
625
  try:
627
- await properties.refresh()
628
- await asyncio.wait_for(updated, timeout=timeout)
626
+ async with asyncio.timeout(timeout):
627
+ if properties.maps.current_map_id is None:
628
+ map_list_updated: asyncio.Future[None] = loop.create_future()
629
+
630
+ def on_map_list_update() -> None:
631
+ if properties.maps.current_map_id is not None and not map_list_updated.done():
632
+ map_list_updated.set_result(None)
633
+
634
+ unsub_maps = properties.maps.add_update_listener(on_map_list_update)
635
+ try:
636
+ await properties.maps.refresh()
637
+ if properties.maps.current_map_id is None:
638
+ await map_list_updated
639
+ finally:
640
+ unsub_maps()
641
+ await properties.map.refresh()
642
+ await updated
629
643
  return True
630
644
  except TimeoutError:
631
645
  return allow_cached_on_timeout and predicate()
@@ -706,7 +720,10 @@ async def q10_position(ctx, device_id: str, include_path: bool):
706
720
  click.echo("Feature not supported by device")
707
721
  return
708
722
  properties = device.b01_q10_properties
709
- got_trace = await _await_q10_map_push(properties, lambda: bool(properties.map.path))
723
+ got_trace = await _await_q10_map_push(
724
+ properties,
725
+ lambda: bool(properties.map.path),
726
+ )
710
727
  if not got_trace:
711
728
  click.echo("No live trace available (the robot only reports position while cleaning).")
712
729
  return
@@ -83,11 +83,32 @@ class Q10CleanRecord(RoborockBase):
83
83
  return None
84
84
 
85
85
 
86
+ @dataclass
87
+ class Q10MapInfo(RoborockBase):
88
+ """A saved map reported by ``dpMultiMap``.
89
+
90
+ Q10 firmware represents the map identifier as a string on the wire. The
91
+ value is sent back unchanged in a subsequent ``{"op": "get"}`` request.
92
+ """
93
+
94
+ id: str
95
+ name: str | None = None
96
+ timestamp: int | None = None
97
+
98
+
86
99
  @dataclass
87
100
  class dpMultiMap(RoborockBase):
101
+ """Response envelope for the Q10 ``dpMultiMap`` data point."""
102
+
88
103
  op: str
89
104
  result: int
90
- data: list
105
+ data: list[Q10MapInfo] = field(default_factory=list)
106
+
107
+ @property
108
+ def current_map_id(self) -> str | None:
109
+ """Return the first saved-map identifier, if one was reported."""
110
+ first = next((map_info for map_info in self.data if map_info.id), None)
111
+ return first.id if first else None
91
112
 
92
113
 
93
114
  @dataclass
@@ -1,3 +1,5 @@
1
+ from typing import Self, cast
2
+
1
3
  from ..code_mappings import RoborockModeEnum
2
4
 
3
5
 
@@ -15,6 +17,16 @@ class WorkStatusMapping(RoborockModeEnum):
15
17
  UPDATING = ("updating", 8)
16
18
  MOP_CLEANING = ("mop_cleaning", 9)
17
19
  MOP_AIRDRYING = ("mop_airdrying", 10)
20
+ WORKING_SLEEP = ("working_sleep", 11)
21
+ UNKNOWN = ("unknown", -1)
22
+
23
+ @classmethod
24
+ def from_code(cls, code: int) -> Self:
25
+ """Map unrecognized Q7 statuses to UNKNOWN without failing the response."""
26
+ try:
27
+ return super().from_code(code)
28
+ except ValueError:
29
+ return cast(Self, cls.UNKNOWN)
18
30
 
19
31
 
20
32
  class SCWindMapping(RoborockModeEnum):
@@ -72,14 +72,21 @@ class RoborockModeEnum(StrEnum):
72
72
 
73
73
  code: int
74
74
  """The integer code associated with the enum member."""
75
+ _display_name_: str | None
75
76
 
76
- def __new__(cls, value: str, code: int) -> Self:
77
+ def __new__(cls, value: str, code: int, display_name: str | None = None) -> Self:
77
78
  """Creates a new enum member."""
78
79
  member = str.__new__(cls, value)
79
80
  member._value_ = value
80
81
  member.code = code
82
+ member._display_name_ = display_name
81
83
  return member
82
84
 
85
+ @property
86
+ def display_name(self) -> str:
87
+ """Return the canonical user-facing name for the mode."""
88
+ return self._display_name_ or self.value
89
+
83
90
  @classmethod
84
91
  def from_code(cls, code: int) -> Self:
85
92
  for member in cls:
@@ -149,8 +156,8 @@ class RoborockModeEnum(StrEnum):
149
156
 
150
157
  @classmethod
151
158
  def keys(cls) -> list[str]:
152
- """Returns a list of all member values."""
153
- return [member.value for member in cls]
159
+ """Returns a de-duplicated list of canonical member names."""
160
+ return list(dict.fromkeys(member.display_name for member in cls))
154
161
 
155
162
  def __eq__(self, other: Any) -> bool:
156
163
  if isinstance(other, str):
@@ -162,13 +162,14 @@ class RoborockBase:
162
162
 
163
163
  return result
164
164
 
165
- def as_dict(self) -> dict:
165
+ def as_dict(self, exclude: set[str] | None = None) -> dict:
166
+ exclude_set = exclude or set()
166
167
  return asdict(
167
168
  self,
168
169
  dict_factory=lambda _fields: {
169
170
  _camelize(key): value.value if isinstance(value, Enum) else value
170
171
  for (key, value) in _fields
171
- if value is not None
172
+ if value is not None and key not in exclude_set
172
173
  },
173
174
  )
174
175
 
@@ -1,3 +1,4 @@
1
1
  """Dataclasses for Roborock mower devices."""
2
2
 
3
+ from .mower_code_mappings import *
3
4
  from .mower_containers import *
@@ -0,0 +1,72 @@
1
+ """Code mappings for Roborock mower devices."""
2
+
3
+ from roborock.data.code_mappings import RoborockEnum
4
+
5
+
6
+ class RoborockMowerStateCode(RoborockEnum):
7
+ """Detailed mower operating state reported by DPS 123."""
8
+
9
+ unknown = -999
10
+ idle = 0
11
+
12
+ map_initializing = 1
13
+ map_undocking = 2
14
+ map_undock_fault = 3
15
+ map_locating = 4
16
+ map_prepare_boundary = 5
17
+ map_prepare_island = 6
18
+ map_prepare_path = 7
19
+ map_boundary = 8
20
+ map_island = 9
21
+ map_path = 10
22
+ map_boundary_auto = 11
23
+ map_erasing = 12
24
+ map_save = 13
25
+ map_wait = 14
26
+ map_recoverable_fault = 15
27
+ map_fault = 16
28
+ map_emergency_stop = 17
29
+ map_waiting_fault = 18
30
+
31
+ mow_initializing = 51
32
+ mow_undocking = 52
33
+ mow_locating = 53
34
+ mow_adjust_cutter = 54
35
+ mow_zig_zag = 55
36
+ mow_edge = 56
37
+ mow_goto = 57
38
+ mow_suspend = 58
39
+ mow_recoverable_fault = 59
40
+ mow_fault = 60
41
+ mow_docked_rainfall = 61
42
+ mow_docked_do_not_disturb = 62
43
+ mow_docked_low_battery = 63
44
+ mow_wait = 64
45
+ mow_prepare_remote = 65
46
+ mow_remote = 66
47
+ mow_emergency_stop = 67
48
+ mow_docked_manual = 68
49
+ mow_dock_fault = 69
50
+ mow_remote_undocking = 70
51
+ mow_to_dock_initializing = 71
52
+ mow_to_dock_locating = 72
53
+ mow_to_dock_recoverable_fault = 73
54
+ mow_to_dock_fault = 74
55
+ mow_to_dock_emergency_stop = 75
56
+ mow_to_dock_charging = 76
57
+ mow_to_dock_charge_completed = 77
58
+
59
+ free = 101
60
+ free_initializing = 102
61
+ free_locating = 103
62
+ free_docked_manual = 104
63
+ free_docked_mow_end = 105
64
+ free_docked_plan_end = 106
65
+ free_emergency_stop = 107
66
+ free_recoverable_fault = 108
67
+ free_fault = 109
68
+
69
+ charge_charging = 151
70
+ charge_completed = 152
71
+ charge_waiting = 153
72
+ charge_fault = 154
@@ -1,11 +1,13 @@
1
1
  """Data containers for Roborock mower devices."""
2
2
 
3
- from dataclasses import dataclass, field
4
- from typing import Any
3
+ from dataclasses import dataclass, field, fields
4
+ from typing import Any, Self
5
5
 
6
6
  from roborock.data.containers import RoborockBase
7
7
  from roborock.roborock_message import RoborockMowerDataProtocol
8
8
 
9
+ from .mower_code_mappings import RoborockMowerStateCode
10
+
9
11
 
10
12
  @dataclass
11
13
  class MowerStatus(RoborockBase):
@@ -14,7 +16,9 @@ class MowerStatus(RoborockBase):
14
16
  error_code: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.ERROR_CODE})
15
17
  battery: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.BATTERY})
16
18
  mow_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_TYPE})
17
- mow_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_STATE})
19
+ mow_state: RoborockMowerStateCode | None = field(
20
+ default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_STATE}
21
+ )
18
22
  mapping_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MAPPING_TYPE})
19
23
  mapping_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MAPPING_STATE})
20
24
  ota_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.OTA_STATE})
@@ -41,3 +45,25 @@ class MowerStatus(RoborockBase):
41
45
  )
42
46
  afs_status: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.AFS_STATUS})
43
47
  network_channel: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.NETWORK_CHANNEL})
48
+
49
+ @classmethod
50
+ def from_dps(cls, dps: dict[int | str, Any] | None) -> Self:
51
+ """Create mower status from raw DPS data.
52
+
53
+ Cloud snapshots use string DPS keys while decoded MQTT messages may use
54
+ integer keys. Unknown and malformed keys are ignored.
55
+ """
56
+ field_by_dps = {
57
+ int(field_info.metadata["dps"]): field_info.name
58
+ for field_info in fields(cls)
59
+ if "dps" in field_info.metadata
60
+ }
61
+ values: dict[str, Any] = {}
62
+ for key, value in (dps or {}).items():
63
+ try:
64
+ dps_id = int(key)
65
+ except (TypeError, ValueError):
66
+ continue
67
+ if field_name := field_by_dps.get(dps_id):
68
+ values[field_name] = value
69
+ return cls.from_dict(values)
@@ -29,7 +29,7 @@ class CleanRoutes(RoborockModeEnum):
29
29
  DEEP = ("deep", 301)
30
30
  DEEP_PLUS = ("deep_plus", 303)
31
31
  FAST = ("fast", 304)
32
- DEEP_PLUS_CN = ("deep_plus", 305)
32
+ DEEP_PLUS_CN = ("deep_plus_cn", 305, "deep_plus")
33
33
  SMART_MODE = ("smart_mode", 306)
34
34
  CUSTOMIZED = ("custom", 302)
35
35
 
@@ -118,10 +118,7 @@ def get_clean_modes(features: DeviceFeatures) -> list[VacuumModes]:
118
118
  modes.append(VacuumModes.MAX_PLUS)
119
119
  if features.is_pure_clean_mop_supported:
120
120
  # If the vacuum is capable of 'pure mop clean' aka no vacuum
121
- if features.is_support_main_brush_up_down_supported:
122
- modes.append(VacuumModes.OFF_RAISE_MAIN_BRUSH)
123
- else:
124
- modes.append(VacuumModes.OFF)
121
+ modes.append(VacuumModes.OFF)
125
122
  else:
126
123
  # If not, we can add gentle
127
124
  modes.append(VacuumModes.GENTLE)
@@ -134,16 +131,19 @@ def get_clean_modes(features: DeviceFeatures) -> list[VacuumModes]:
134
131
 
135
132
  def get_clean_routes(features: DeviceFeatures, region: str) -> list[CleanRoutes]:
136
133
  """The routes that the vacuum will take while mopping"""
134
+ if not features.is_clean_route_setting_supported:
135
+ return []
137
136
  if features.is_none_pure_clean_mop_with_max_plus:
138
137
  return [CleanRoutes.FAST, CleanRoutes.STANDARD]
139
- supported = [CleanRoutes.STANDARD, CleanRoutes.DEEP]
138
+ supported = [CleanRoutes.STANDARD]
139
+ if not features.is_clean_efficiency_supported:
140
+ supported.append(CleanRoutes.DEEP)
140
141
  if features.is_careful_slow_mop_supported:
141
- if not (
142
- features.is_corner_clean_mode_supported
142
+ if (
143
+ not features.is_corner_clean_mode_supported
143
144
  and features.is_clean_route_deep_slow_plus_supported
144
145
  and region == "cn"
145
146
  ):
146
- # for some reason there is a china specific deep plus mode
147
147
  supported.append(CleanRoutes.DEEP_PLUS_CN)
148
148
  else:
149
149
  supported.append(CleanRoutes.DEEP_PLUS)
@@ -218,17 +218,11 @@ def get_cleaning_mode_options(features: DeviceFeatures) -> list[CleaningMode]:
218
218
 
219
219
 
220
220
  def get_mop_only_vacuum_mode(features: DeviceFeatures) -> VacuumModes:
221
- """Determine the vacuum mode to use when you just want to mop.
222
-
223
- There are three cases that must be handled:
224
- 1. The device does not support only mopping.
225
- 2. The device supports raising the vacuum brush while mopping
226
- 3. All other cases.
227
- """
221
+ """Return the vacuum mode used by the app for mop-only cleaning."""
228
222
  if not features.is_pure_clean_mop_supported:
229
223
  raise RoborockUnsupportedFeature("Mop-only cleaning is not supported")
230
- if features.is_support_main_brush_up_down_supported:
231
- return VacuumModes.OFF_RAISE_MAIN_BRUSH
224
+ # Main-brush lift is a device capability, not an alternate fan-power
225
+ # command. The app still sends CleanModeZero (105) for mop-only cleaning.
232
226
  return VacuumModes.OFF
233
227
 
234
228
 
@@ -1,8 +1,5 @@
1
1
  """Zeo (washing machine) device enums.
2
2
 
3
- Member-level comments show the manufacturer's official identifiers
4
- extracted from the React Native app plugin bundle.
5
-
6
3
  For enums that map between protocol-level positions and physical units
7
4
  (e.g. spin speed, temperature), the comment format is
8
5
  ``# L<N>, <physical-value>`` where ``L<N>`` is the protocol position
@@ -18,12 +15,7 @@ from ..code_mappings import RoborockEnum
18
15
 
19
16
 
20
17
  class ZeoFeatureBits(RoborockEnum):
21
- """Bit positions in DP 237 (FEATURE_BITS).
22
-
23
- Extracted from the official Roborock Washer app plugin bundle
24
- (index.ios.bundle, module 726). Each member's integer value
25
- is the exact bit offset the device reports at DP 237.
26
- """
18
+ """Bit positions in DP 237 (FEATURE_BITS)."""
27
19
 
28
20
  smart_hosting = 0
29
21
  silent_mode = 1
@@ -52,7 +44,7 @@ class ZeoFeatureBits(RoborockEnum):
52
44
 
53
45
 
54
46
  class ZeoMode(RoborockEnum):
55
- null = 0 # (not found in app bundle)
47
+ null = 0
56
48
  wash = 1
57
49
  wash_and_dry = 2
58
50
  dry = 3
@@ -60,6 +52,7 @@ class ZeoMode(RoborockEnum):
60
52
 
61
53
 
62
54
  class ZeoState(RoborockEnum):
55
+ null = 0
63
56
  standby = 1
64
57
  weighing = 2 # Checking
65
58
  soaking = 3
@@ -83,7 +76,7 @@ class ZeoState(RoborockEnum):
83
76
 
84
77
 
85
78
  class ZeoProgram(RoborockEnum):
86
- null = 0 # (not found in app bundle)
79
+ null = 0
87
80
  standard = 1 # Mixed
88
81
  quick = 2
89
82
  sanitize = 3 # Sterilization
@@ -152,6 +145,7 @@ class ZeoSoak(RoborockEnum):
152
145
 
153
146
 
154
147
  class ZeoTemperature(RoborockEnum):
148
+ null = 0
155
149
  normal = 1 # L1, 0 C
156
150
  low = 2 # L2, 30 C
157
151
  medium = 3 # L3, 40 C
@@ -203,11 +197,13 @@ class ZeoSoftenerType(RoborockEnum):
203
197
 
204
198
 
205
199
  class ZeoDetergentExpansionType(RoborockEnum):
200
+ null = 0
206
201
  concentrated_detergent = 1
207
202
  detergent = 2
208
203
 
209
204
 
210
205
  class ZeoSoftenerExpansionType(RoborockEnum):
206
+ null = 0
211
207
  softener = 1
212
208
  softener_expansion = 2
213
209
  wool_detergent = 3
@@ -241,6 +237,7 @@ class ZeoError(RoborockEnum):
241
237
 
242
238
 
243
239
  class ZeoDryingMethod(RoborockEnum):
240
+ null = 0
244
241
  l1 = 1 # L1, Saving
245
242
  l2 = 2 # L2, Standard
246
243
  l3 = 3 # L3, SuperFast
@@ -255,11 +252,13 @@ class ZeoSteamVolume(RoborockEnum):
255
252
 
256
253
 
257
254
  class ZeoDryAndCare(RoborockEnum):
255
+ null = 0
258
256
  soft = 1
259
257
  normal = 2
260
258
 
261
259
 
262
260
  class ZeoDryerStartError(RoborockEnum):
261
+ null = 0
263
262
  dryer_running = 1 # Washer-Dryer Pairing cannot start: Dryer is running.
264
263
  dryer_error = 2 # Washer-Dryer Pairing cannot start: Dryer has an error.
265
264
  dryer_done = 3 # Dryer drying is complete, please remove the clothes first.
@@ -269,7 +268,7 @@ class ZeoDryerStartError(RoborockEnum):
269
268
  dryer_network_fail = 7 # Please check the dryer network connection.
270
269
 
271
270
 
272
- # The following are App-internal lookup tables extracted from the bundle.
271
+ # The following are App-internal lookup tables.
273
272
  # They are NOT DP protocol enums — the device never receives these values.
274
273
  # They control how the official app adjusts recommended dosages or UI visibility.
275
274
  #
@@ -87,6 +87,7 @@ class NewFeatureStrBit(IntEnum):
87
87
  TWO_GEARS_NO_COLLISION = 118
88
88
  CARPET_SHAPE_TYPE = 119
89
89
  SR_MAP = 120
90
+ ROLLER_MOP = 128
90
91
 
91
92
 
92
93
  class ProductFeatures(StrEnum):
@@ -474,6 +475,7 @@ class DeviceFeatures(RoborockBase):
474
475
  )
475
476
  is_carpet_shape_type_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.CARPET_SHAPE_TYPE})
476
477
  is_sr_map_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.SR_MAP})
478
+ is_roller_mop_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.ROLLER_MOP})
477
479
 
478
480
  # Features from feature_info list
479
481
  is_led_status_switch_supported: bool = field(metadata={"robot_features": 119})
@@ -644,6 +646,12 @@ class DeviceFeatures(RoborockBase):
644
646
  if any(feat in available_features for feat in product_features): # type: ignore
645
647
  kwargs[f.name] = True
646
648
 
649
+ # The app combines runtime shake-mop, model shake/spin, and roller-mop
650
+ # capabilities when deciding whether mop routes can be configured.
651
+ kwargs["is_clean_route_setting_supported"] |= (
652
+ kwargs["is_shake_mop_set_supported"] or kwargs["is_roller_mop_supported"]
653
+ )
654
+
647
655
  return cls(**kwargs)
648
656
 
649
657
  def get_supported_features(self) -> list[str]:
@@ -202,6 +202,10 @@ class RoborockDevice(ABC, TraitsMixin):
202
202
  await self.v1_properties.start()
203
203
  elif self.b01_q10_properties is not None:
204
204
  await self.b01_q10_properties.start()
205
+ elif self.b01_q7_properties is not None:
206
+ await self.b01_q7_properties.start()
207
+ elif self.zeo is not None:
208
+ await self.zeo.start()
205
209
  except RoborockException:
206
210
  # Expected: start() can fail transiently. Unsubscribe before propagating
207
211
  # so the retry by connect_loop() gets a clean channel.
@@ -230,6 +234,10 @@ class RoborockDevice(ABC, TraitsMixin):
230
234
  self.v1_properties.close()
231
235
  if self.b01_q10_properties is not None:
232
236
  await self.b01_q10_properties.close()
237
+ if self.b01_q7_properties is not None:
238
+ await self.b01_q7_properties.close()
239
+ if self.zeo is not None:
240
+ self.zeo.close()
233
241
  if self._unsub:
234
242
  self._unsub()
235
243
  self._unsub = None
@@ -243,6 +251,8 @@ class RoborockDevice(ABC, TraitsMixin):
243
251
  extra: dict[str, Any] = {}
244
252
  if self.v1_properties:
245
253
  extra["traits"] = self.v1_properties.as_dict()
254
+ elif self.b01_q10_properties:
255
+ extra["traits"] = self.b01_q10_properties.as_dict()
246
256
  return redact_device_data(
247
257
  {
248
258
  "device": self.device_info.as_dict(),
@@ -98,8 +98,10 @@ async def send_decoded_command(
98
98
  try:
99
99
  await asyncio.wait_for(finished.wait(), timeout=_TIMEOUT)
100
100
  except TimeoutError as ex:
101
+ await mqtt_channel.health_manager.on_timeout()
101
102
  raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
102
103
  finally:
103
104
  unsub()
104
105
 
106
+ await mqtt_channel.health_manager.on_success()
105
107
  return result # type: ignore[return-value]
@@ -53,6 +53,10 @@ class Q7MapRpcChannel(Protocol):
53
53
  """Send a map command and get decoded bytes."""
54
54
  ...
55
55
 
56
+ async def subscribe_map_pushes(self, callback: Callable[[bytes], None]) -> Callable[[], None]:
57
+ """Subscribe to unsolicited map pushes, invoking callback with decoded SCMap bytes."""
58
+ ...
59
+
56
60
 
57
61
  def _matches_map_response(response_message: RoborockMessage, *, version: bytes | None) -> bytes | None:
58
62
  """Return raw map payload bytes for matching MAP_RESPONSE messages."""
@@ -208,6 +212,25 @@ class B01Q7Channel(Channel, Q7RpcChannel, Q7MapRpcChannel):
208
212
 
209
213
  return decode_map_payload(raw_payload, map_key=self._map_key)
210
214
 
215
+ async def subscribe_map_pushes(self, callback: Callable[[bytes], None]) -> Callable[[], None]:
216
+ """Subscribe to unsolicited ``MAP_RESPONSE`` pushes.
217
+
218
+ The device streams full SCMap frames on its own during cleaning; the
219
+ callback receives the decoded (inflated) SCMap bytes for each frame.
220
+ """
221
+
222
+ def on_message(message: RoborockMessage) -> None:
223
+ if (raw_payload := _matches_map_response(message, version=B01_VERSION)) is None:
224
+ return
225
+ try:
226
+ decoded = decode_map_payload(raw_payload, map_key=self._map_key)
227
+ except RoborockException as ex:
228
+ _LOGGER.debug("Failed to decode pushed B01 map payload: %s", ex)
229
+ return
230
+ callback(decoded)
231
+
232
+ return await self._mqtt_channel.subscribe(on_message)
233
+
211
234
 
212
235
  def create_b01_q7_channel(
213
236
  device: HomeDataDevice,