python-roborock 5.21.0__tar.gz → 5.23.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. {python_roborock-5.21.0 → python_roborock-5.23.0}/PKG-INFO +1 -1
  2. {python_roborock-5.21.0 → python_roborock-5.23.0}/pyproject.toml +1 -1
  3. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/cli.py +33 -0
  4. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +37 -0
  5. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/b01_q10/b01_q10_containers.py +50 -0
  6. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/__init__.py +7 -0
  7. python_roborock-5.23.0/roborock/devices/traits/b01/q10/clean_history.py +164 -0
  8. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/vacuum.py +27 -5
  9. {python_roborock-5.21.0 → python_roborock-5.23.0}/.gitignore +0 -0
  10. {python_roborock-5.21.0 → python_roborock-5.23.0}/LICENSE +0 -0
  11. {python_roborock-5.21.0 → python_roborock-5.23.0}/README.md +0 -0
  12. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/__init__.py +0 -0
  13. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/broadcast_protocol.py +0 -0
  14. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/callbacks.py +0 -0
  15. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/const.py +0 -0
  16. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/__init__.py +0 -0
  17. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/b01_q10/__init__.py +0 -0
  18. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/b01_q7/__init__.py +0 -0
  19. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  20. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  21. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/code_mappings.py +0 -0
  22. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/containers.py +0 -0
  23. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/dyad/__init__.py +0 -0
  24. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  25. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/dyad/dyad_containers.py +0 -0
  26. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/v1/__init__.py +0 -0
  27. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  28. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  29. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/v1/v1_containers.py +0 -0
  30. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/zeo/__init__.py +0 -0
  31. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  32. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/data/zeo/zeo_containers.py +0 -0
  33. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/device_features.py +0 -0
  34. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/README.md +0 -0
  35. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/__init__.py +0 -0
  36. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/cache.py +0 -0
  37. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/device.py +0 -0
  38. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/device_manager.py +0 -0
  39. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/file_cache.py +0 -0
  40. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/rpc/__init__.py +0 -0
  41. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/rpc/a01_channel.py +0 -0
  42. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  43. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  44. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/rpc/v1_channel.py +0 -0
  45. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/__init__.py +0 -0
  46. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/a01/__init__.py +0 -0
  47. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/__init__.py +0 -0
  48. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  49. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  50. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  51. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  52. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  53. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  54. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  55. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/map.py +0 -0
  56. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  57. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
  58. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  59. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q10/volume.py +0 -0
  60. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  61. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  62. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  63. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  64. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/common.py +0 -0
  65. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/traits_mixin.py +0 -0
  66. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/__init__.py +0 -0
  67. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  68. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  69. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/command.py +0 -0
  70. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/common.py +0 -0
  71. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  72. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/device_features.py +0 -0
  73. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  74. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  75. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  76. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/home.py +0 -0
  77. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/led_status.py +0 -0
  78. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/map_content.py +0 -0
  79. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/maps.py +0 -0
  80. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/network_info.py +0 -0
  81. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/rooms.py +0 -0
  82. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/routines.py +0 -0
  83. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  84. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/status.py +0 -0
  85. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  86. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/volume.py +0 -0
  87. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  88. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/transport/__init__.py +0 -0
  89. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/transport/channel.py +0 -0
  90. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/transport/local_channel.py +0 -0
  91. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  92. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/diagnostics.py +0 -0
  93. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/exceptions.py +0 -0
  94. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/map/__init__.py +0 -0
  95. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/map/b01_grid_layers.py +0 -0
  96. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/map/b01_map_parser.py +0 -0
  97. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/map/b01_q10_map_parser.py +0 -0
  98. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/map/b01_q10_overlays.py +0 -0
  99. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/map/map_parser.py +0 -0
  100. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/map/proto/__init__.py +0 -0
  101. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/map/proto/b01_scmap.proto +0 -0
  102. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  103. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/mqtt/__init__.py +0 -0
  104. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/mqtt/health_manager.py +0 -0
  105. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/mqtt/roborock_session.py +0 -0
  106. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/mqtt/session.py +0 -0
  107. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/protocol.py +0 -0
  108. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/protocols/__init__.py +0 -0
  109. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/protocols/a01_protocol.py +0 -0
  110. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  111. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  112. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/protocols/v1_protocol.py +0 -0
  113. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/py.typed +0 -0
  114. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/roborock_message.py +0 -0
  115. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/roborock_typing.py +0 -0
  116. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/util.py +0 -0
  117. {python_roborock-5.21.0 → python_roborock-5.23.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 5.21.0
3
+ Version: 5.23.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.21.0"
3
+ version = "5.23.0"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
6
6
  requires-python = ">=3.11, <4"
@@ -1482,6 +1482,39 @@ async def q10_vacuum_spot(ctx: click.Context, device_id: str) -> None:
1482
1482
  click.echo(f"Error: {e}")
1483
1483
 
1484
1484
 
1485
+ @session.command()
1486
+ @click.option("--device_id", required=True, help="Device ID")
1487
+ @click.option(
1488
+ "--segments",
1489
+ required=True,
1490
+ help="Comma-separated room/segment ids to clean (see the `rooms` command), e.g. 9,2",
1491
+ )
1492
+ @click.pass_context
1493
+ @async_command
1494
+ async def q10_clean_segments(ctx: click.Context, device_id: str, segments: str) -> None:
1495
+ """Start a room / segment clean on a Q10 device.
1496
+
1497
+ Room ids come from the `rooms` command (the device's map rooms).
1498
+ """
1499
+ context: RoborockContext = ctx.obj
1500
+ try:
1501
+ segment_ids = [int(s) for s in segments.split(",") if s.strip()]
1502
+ except ValueError:
1503
+ click.echo("--segments must be comma-separated integers, e.g. 9,2")
1504
+ return
1505
+ if not segment_ids:
1506
+ click.echo("No segment ids provided")
1507
+ return
1508
+ try:
1509
+ trait = await _q10_vacuum_trait(context, device_id)
1510
+ await trait.clean_segments(segment_ids)
1511
+ click.echo(f"Starting room clean of segments {segment_ids}...")
1512
+ except RoborockUnsupportedFeature:
1513
+ click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1514
+ except RoborockException as e:
1515
+ click.echo(f"Error: {e}")
1516
+
1517
+
1485
1518
  async def _q10_set(ctx: click.Context, device_id: str, apply: Callable[[Any], Any], message: str) -> None:
1486
1519
  """Run a Q10 settings write and report the result."""
1487
1520
  context: RoborockContext = ctx.obj
@@ -226,6 +226,43 @@ class YXDeviceCleanTask(RoborockModeEnum):
226
226
  PART = "part", 5
227
227
 
228
228
 
229
+ class YXCleanScope(RoborockModeEnum):
230
+ """Clean scope/type as stored in a *clean record* (``dpCleanRecord``, field 7).
231
+
232
+ This is the same conceptual axis as the live :class:`YXDeviceCleanTask`, but the
233
+ persisted record uses a different integer encoding -- e.g. a full clean records
234
+ ``0`` here vs ``1`` (``smart``) live, and a select-rooms clean records ``1`` here
235
+ vs ``2`` (``electoral``) live. Ground-truthed against the app's History labels;
236
+ code ``2`` was never observed on ss07 and is intentionally unmapped (so it
237
+ resolves to ``None`` rather than a guessed label).
238
+ """
239
+
240
+ UNKNOWN = "unknown", -1
241
+ FULL = "full", 0
242
+ SELECTIVE_ROOM = "selective_room", 1
243
+ ZONE = "zone", 3
244
+ SPOT = "spot", 4
245
+
246
+
247
+ class YXCleaningResult(RoborockModeEnum):
248
+ """How a clean ended, as stored in a clean record (``dpCleanRecord``, field 9)."""
249
+
250
+ UNKNOWN = "unknown", -1
251
+ INTERRUPTED = "interrupted", 0 # ended on a fault
252
+ COMPLETED = "completed", 1
253
+ STOPPED = "stopped", 2 # ended early without a fault
254
+
255
+
256
+ class YXStartMethod(RoborockModeEnum):
257
+ """What initiated a clean, as stored in a clean record (``dpCleanRecord``, field 10)."""
258
+
259
+ UNKNOWN = "unknown", -1
260
+ REMOTE = "remote", 0
261
+ APP = "app", 1
262
+ TIMER = "timer", 2 # schedule / timer
263
+ BUTTON = "button", 3 # device button
264
+
265
+
229
266
  class YXDeviceDustCollectionFrequency(RoborockModeEnum):
230
267
  # The app exposes "regular" (code 0) vs "frequent", where "frequent" selects
231
268
  # one of the every-N-cleans intervals below.
@@ -6,6 +6,7 @@ dataclass fields to device Data Points (DPS). This metadata is utilized by the
6
6
  automatically update objects from raw device responses.
7
7
  """
8
8
 
9
+ import datetime
9
10
  from dataclasses import dataclass, field
10
11
 
11
12
  from ..containers import RoborockBase
@@ -14,12 +15,15 @@ from .b01_q10_code_mappings import (
14
15
  YXAreaUnit,
15
16
  YXBackType,
16
17
  YXCarpetCleanType,
18
+ YXCleaningResult,
17
19
  YXCleanLine,
20
+ YXCleanScope,
18
21
  YXCleanType,
19
22
  YXDeviceCleanTask,
20
23
  YXDeviceDustCollectionFrequency,
21
24
  YXDeviceState,
22
25
  YXFanLevel,
26
+ YXStartMethod,
23
27
  YXWaterLevel,
24
28
  )
25
29
 
@@ -32,6 +36,52 @@ class dpCleanRecord(RoborockBase):
32
36
  data: list
33
37
 
34
38
 
39
+ @dataclass
40
+ class Q10CleanRecord(RoborockBase):
41
+ """A single Q10 (ss07) clean record decoded from a ``dpCleanRecord`` (DP 52) entry.
42
+
43
+ The device returns each record as a 12-field underscore-delimited string in the
44
+ ``data`` list of a ``{"op": "list"}`` query (or the ``id`` of an ``{"op": "notify"}``
45
+ push). The ``*_len`` values are internal blob-length metrics whose units aren't
46
+ confirmed; the original ``raw`` string is always retained. The enum fields resolve
47
+ an unmapped/unset code to ``None`` rather than guessing.
48
+ """
49
+
50
+ raw: str
51
+ record_id: str | None = None
52
+ start_time: int | None = None
53
+ """Clean start time, Unix seconds."""
54
+ clean_time: int | None = None
55
+ """Cleaning time, minutes."""
56
+ clean_area: int | None = None
57
+ """Cleaned area in square meters."""
58
+ map_len: int | None = None
59
+ """Length of the saved map blob for this record (0 = none stored)."""
60
+ path_len: int | None = None
61
+ """Length of the saved path blob for this record (0 = none stored)."""
62
+ virtual_len: int | None = None
63
+ """Length of the saved virtual-restriction blob for this record (0 = none stored)."""
64
+ clean_mode: YXCleanScope | None = None
65
+ """Clean scope/type (full / selective-room / zone / spot). Same axis as the live
66
+ :class:`YXDeviceCleanTask` but a different record encoding -- see :class:`YXCleanScope`."""
67
+ work_mode: YXCleanType | None = None
68
+ """Actual work performed (vac+mop / vacuum / mop) -- the same enum :class:`Q10Status`
69
+ uses for the live clean-mode DP. Records only ever carry 1/2/3 here."""
70
+ cleaning_result: YXCleaningResult | None = None
71
+ """How the clean ended: 0 interrupted (fault), 1 completed, 2 stopped (no fault)."""
72
+ start_method: YXStartMethod | None = None
73
+ """What initiated the clean: 0 remote, 1 app, 2 timer, 3 button."""
74
+ collect_dust_count: int | None = None
75
+ """Number of dock auto-empties during the clean."""
76
+
77
+ @property
78
+ def start_datetime(self) -> datetime.datetime | None:
79
+ """The start time as a timezone-aware (UTC) datetime."""
80
+ if self.start_time is not None:
81
+ return datetime.datetime.fromtimestamp(self.start_time).astimezone(datetime.UTC)
82
+ return None
83
+
84
+
35
85
  @dataclass
36
86
  class dpMultiMap(RoborockBase):
37
87
  op: str
@@ -12,6 +12,7 @@ from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message
12
12
 
13
13
  from .button_light import ButtonLightTrait
14
14
  from .child_lock import ChildLockTrait
15
+ from .clean_history import CleanHistoryTrait
15
16
  from .command import CommandTrait
16
17
  from .consumable import ConsumableTrait
17
18
  from .do_not_disturb import DoNotDisturbTrait
@@ -27,6 +28,7 @@ __all__ = [
27
28
  "Q10PropertiesApi",
28
29
  "ButtonLightTrait",
29
30
  "ChildLockTrait",
31
+ "CleanHistoryTrait",
30
32
  "ConsumableTrait",
31
33
  "DoNotDisturbTrait",
32
34
  "DustCollectionTrait",
@@ -78,6 +80,9 @@ class Q10PropertiesApi(Trait):
78
80
  map: MapContentTrait
79
81
  """Trait for fetching the current parsed map (image + rooms)."""
80
82
 
83
+ clean_history: CleanHistoryTrait
84
+ """Trait for fetching the device clean-record history (``dpCleanRecord``)."""
85
+
81
86
  def __init__(self, channel: MqttChannel) -> None:
82
87
  """Initialize the B01Props API."""
83
88
  self._channel = channel
@@ -93,6 +98,7 @@ class Q10PropertiesApi(Trait):
93
98
  self.network_info = NetworkInfoTrait()
94
99
  self.consumable = ConsumableTrait()
95
100
  self.map = MapContentTrait()
101
+ self.clean_history = CleanHistoryTrait(self.command)
96
102
  # Read-model traits updated from the device's DPS push stream.
97
103
  self._updatable_traits = [
98
104
  self.status,
@@ -102,6 +108,7 @@ class Q10PropertiesApi(Trait):
102
108
  self.dust_collection,
103
109
  self.network_info,
104
110
  self.consumable,
111
+ self.clean_history,
105
112
  ]
106
113
  self._subscribe_task: asyncio.Task[None] | None = None
107
114
 
@@ -0,0 +1,164 @@
1
+ """Clean history trait for Q10 B01 devices.
2
+
3
+ Unlike the Q7 (which exposes a synchronous ``service.get_record_list`` RPC), the
4
+ Q10 is push-driven: :meth:`CleanHistoryTrait.refresh` sends an ``{"op": "list"}``
5
+ query for ``dpCleanRecord`` (DP 52) over the ``dpCommon`` channel, and the device
6
+ publishes its clean-record list back on the subscribe stream, which
7
+ :meth:`CleanHistoryTrait.update_from_dps` then decodes.
8
+
9
+ Wire parsing is separated from state management: :class:`CleanRecordConverter` turns
10
+ a ``dpCleanRecord`` envelope into a :class:`CleanRecordPush`, and the trait applies it.
11
+ """
12
+
13
+ import logging
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from roborock.data.b01_q10.b01_q10_code_mappings import (
18
+ B01_Q10_DP,
19
+ YXCleaningResult,
20
+ YXCleanScope,
21
+ YXCleanType,
22
+ YXStartMethod,
23
+ )
24
+ from roborock.data.b01_q10.b01_q10_containers import Q10CleanRecord
25
+
26
+ from .command import CommandTrait
27
+ from .common import UpdatableTrait
28
+
29
+ __all__ = [
30
+ "CleanHistoryTrait",
31
+ "CleanRecordConverter",
32
+ "CleanRecordPush",
33
+ ]
34
+
35
+ _LOGGER = logging.getLogger(__name__)
36
+
37
+ _RECORD_FIELD_COUNT = 12
38
+
39
+
40
+ @dataclass
41
+ class CleanRecordPush:
42
+ """A parsed ``dpCleanRecord`` push: the records it carries and how to apply them.
43
+
44
+ ``replace`` is ``True`` for an ``{"op": "list"}`` reply (the full history, which
45
+ replaces the current state) and ``False`` for an ``{"op": "notify"}`` push (a
46
+ single just-finished record, which is upserted).
47
+ """
48
+
49
+ records: list[Q10CleanRecord] = field(default_factory=list)
50
+ replace: bool = False
51
+
52
+
53
+ class CleanRecordConverter:
54
+ """Converts a raw ``dpCleanRecord`` (DP 52) envelope into a :class:`CleanRecordPush`.
55
+
56
+ Mirrors the converter-per-object pattern used by the other Q10 traits: parsing the
57
+ wire payload lives here, separate from the trait that manages the record list.
58
+ """
59
+
60
+ def parse(self, envelope: dict[str, Any]) -> CleanRecordPush | None:
61
+ """Parse a decoded ``dpCleanRecord`` envelope into a :class:`CleanRecordPush`.
62
+
63
+ Returns ``None`` for an envelope that carries nothing usable (so the trait can
64
+ ignore it without changing state). Malformed individual records are skipped.
65
+ """
66
+ if envelope.get("op") == "notify":
67
+ record = CleanRecordConverter.parse_record(envelope.get("id"))
68
+ return CleanRecordPush([record], replace=False) if record is not None else None
69
+ data = envelope.get("data")
70
+ if not isinstance(data, list):
71
+ return None
72
+ records = [record for item in data if (record := CleanRecordConverter.parse_record(item)) is not None]
73
+ return CleanRecordPush(records, replace=True)
74
+
75
+ @staticmethod
76
+ def parse_record(raw: Any | None) -> Q10CleanRecord | None:
77
+ """Decode one underscore-delimited clean-record string into a :class:`Q10CleanRecord`.
78
+
79
+ The device joins 12 values with ``_``: recordId, startTime (Unix s), cleanTime
80
+ (min), cleanArea (m2), mapLen, pathLen, virtualLen, cleanMode, workMode,
81
+ cleaningResult, startMethod, collectDustCount. Returns ``None`` for anything but
82
+ a well-formed 12-field string; an unmapped enum code resolves to ``None`` on its
83
+ field (``raw`` keeps the original).
84
+ """
85
+ if not isinstance(raw, str):
86
+ return None
87
+ parts = raw.split("_")
88
+ if len(parts) != _RECORD_FIELD_COUNT:
89
+ return None
90
+ try:
91
+ return Q10CleanRecord(
92
+ raw=raw,
93
+ record_id=parts[0],
94
+ start_time=int(parts[1]),
95
+ clean_time=int(parts[2]),
96
+ clean_area=int(parts[3]),
97
+ map_len=int(parts[4]),
98
+ path_len=int(parts[5]),
99
+ virtual_len=int(parts[6]),
100
+ clean_mode=YXCleanScope.from_code_optional(int(parts[7])),
101
+ work_mode=YXCleanType.from_code_optional(int(parts[8])),
102
+ cleaning_result=YXCleaningResult.from_code_optional(int(parts[9])),
103
+ start_method=YXStartMethod.from_code_optional(int(parts[10])),
104
+ collect_dust_count=int(parts[11]),
105
+ )
106
+ except ValueError:
107
+ return None
108
+
109
+
110
+ class CleanHistoryTrait(UpdatableTrait):
111
+ """Access to the Q10 clean-record history (``dpCleanRecord``, DP 52).
112
+
113
+ A read-model trait updated from the DPS stream like the others, but it overrides
114
+ :meth:`update_from_dps` because the payload is a structured push (a record list,
115
+ or a single ``op:"notify"`` record) rather than a flat data-point-to-field map.
116
+ """
117
+
118
+ def __init__(self, command: CommandTrait) -> None:
119
+ """Initialize the clean history trait."""
120
+ UpdatableTrait.__init__(self, command, _LOGGER)
121
+ self._converter = CleanRecordConverter()
122
+ self.records: list[Q10CleanRecord] = []
123
+ """Decoded clean records, most recent first."""
124
+
125
+ @property
126
+ def last_record(self) -> Q10CleanRecord | None:
127
+ """The most recent clean record, or ``None`` if there are none."""
128
+ return self.records[0] if self.records else None
129
+
130
+ async def refresh(self) -> None:
131
+ """Request the clean-record list from the device.
132
+
133
+ This sends the query and returns immediately; the records arrive
134
+ asynchronously on the device stream and populate :attr:`records` once
135
+ :meth:`update_from_dps` processes the ``dpCleanRecord`` push.
136
+ """
137
+ if self._command is None:
138
+ raise ValueError("Trait is read-only; no command channel was provided")
139
+ await self._command.send(
140
+ B01_Q10_DP.COMMON,
141
+ params={str(B01_Q10_DP.CLEAN_RECORD.code): {"op": "list"}},
142
+ )
143
+
144
+ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
145
+ """Apply a ``dpCleanRecord`` push (a full list reply or a single notify)."""
146
+ envelope = decoded_dps.get(B01_Q10_DP.CLEAN_RECORD)
147
+ if not isinstance(envelope, dict):
148
+ return
149
+ push = self._converter.parse(envelope)
150
+ if push is None:
151
+ return
152
+ self._apply(push)
153
+
154
+ def _apply(self, push: CleanRecordPush) -> None:
155
+ """Merge or replace the records from ``push``, then sort newest-first and notify."""
156
+ if push.replace:
157
+ records = list(push.records)
158
+ else:
159
+ updated_ids = {record.record_id for record in push.records}
160
+ records = [record for record in self.records if record.record_id not in updated_ids]
161
+ records.extend(push.records)
162
+ records.sort(key=lambda record: record.start_time or 0, reverse=True)
163
+ self.records = records
164
+ self._notify_update()
@@ -3,6 +3,7 @@
3
3
  from roborock.data.b01_q10.b01_q10_code_mappings import (
4
4
  B01_Q10_DP,
5
5
  YXCleanType,
6
+ YXDeviceCleanTask,
6
7
  YXFanLevel,
7
8
  )
8
9
 
@@ -23,17 +24,38 @@ class VacuumTrait:
23
24
  async def start_clean(self) -> None:
24
25
  """Start a whole-home clean.
25
26
 
26
- The ``dpStartClean`` (201) command takes a bare integer task code:
27
- ``1`` = whole-home, ``2`` = segment/room, ``3`` = zone, ``4`` = build map,
28
- ``5`` = spot. Whole-home and spot take no extra parameters; segment and
29
- zone need a room/zone selection whose payload shape is not yet known, so
30
- only whole-home (here) and spot (:meth:`spot_clean`) are exposed.
27
+ The ``dpStartClean`` (201) command selects a task by code: ``1`` =
28
+ whole-home, ``2`` = segment/room (see :meth:`clean_segments`), ``3`` =
29
+ zone, ``4`` = build map, ``5`` = spot. Whole-home and spot accept the
30
+ bare integer code; segment cleaning needs a room selection (an object
31
+ payload) instead.
31
32
 
32
33
  Verified live against ss07 hardware: ``{"dps": {"201": 1}}`` starts a
33
34
  whole-home clean (clean_task_type -> 1).
34
35
  """
35
36
  await self._command.send(command=B01_Q10_DP.START_CLEAN, params=1)
36
37
 
38
+ async def clean_segments(self, segment_ids: list[int]) -> None:
39
+ """Start a room / segment clean for the given segment (room) ids.
40
+
41
+ The ids are the same room ids the device reports on its map (see the Q10
42
+ ``MapContentTrait`` -- ``map.rooms``, each with an ``id``).
43
+
44
+ Unlike whole-home and spot, ``dpStartClean`` (201) carries the room
45
+ selection as an object: ``{"cmd": <task>, "clean_paramters": [<id>, ...]}``,
46
+ where ``cmd`` is the segment-clean task code.
47
+
48
+ Verified live against ss07 hardware: sending
49
+ ``{"dps": {"201": {"cmd": 2, "clean_paramters": [9]}}}`` starts cleaning
50
+ room 9 (clean_task_type -> 2 / electoral).
51
+ """
52
+ await self._command.send(
53
+ command=B01_Q10_DP.START_CLEAN,
54
+ # "clean_paramters" intentionally mirrors the device's misspelling of
55
+ # "parameters" -- the firmware only accepts that exact key.
56
+ params={"cmd": YXDeviceCleanTask.ELECTORAL.code, "clean_paramters": segment_ids},
57
+ )
58
+
37
59
  async def spot_clean(self) -> None:
38
60
  """Start a spot / part clean around the robot's current position.
39
61