python-roborock 5.29.0__tar.gz → 5.31.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 (125) hide show
  1. {python_roborock-5.29.0 → python_roborock-5.31.0}/PKG-INFO +1 -1
  2. {python_roborock-5.29.0 → python_roborock-5.31.0}/pyproject.toml +1 -1
  3. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/containers.py +19 -0
  4. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/v1/v1_containers.py +27 -7
  5. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/__init__.py +1 -0
  6. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/device_features.py +45 -18
  7. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/testing/__init__.py +6 -0
  8. python_roborock-5.31.0/roborock/testing/b01_q10_simulator.py +213 -0
  9. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/testing/channel.py +17 -2
  10. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/testing/cloud.py +16 -9
  11. {python_roborock-5.29.0 → python_roborock-5.31.0}/.gitignore +0 -0
  12. {python_roborock-5.29.0 → python_roborock-5.31.0}/LICENSE +0 -0
  13. {python_roborock-5.29.0 → python_roborock-5.31.0}/README.md +0 -0
  14. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/__init__.py +0 -0
  15. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/broadcast_protocol.py +0 -0
  16. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/callbacks.py +0 -0
  17. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/cli.py +0 -0
  18. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/const.py +0 -0
  19. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/__init__.py +0 -0
  20. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/b01_q10/__init__.py +0 -0
  21. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  22. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  23. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/b01_q7/__init__.py +0 -0
  24. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  25. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  26. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/code_mappings.py +0 -0
  27. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/dyad/__init__.py +0 -0
  28. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  29. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/dyad/dyad_containers.py +0 -0
  30. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/mower/__init__.py +0 -0
  31. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/mower/mower_containers.py +0 -0
  32. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/v1/__init__.py +0 -0
  33. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  34. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  35. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/zeo/__init__.py +0 -0
  36. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  37. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/data/zeo/zeo_containers.py +0 -0
  38. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/device_features.py +0 -0
  39. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/README.md +0 -0
  40. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/__init__.py +0 -0
  41. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/cache.py +0 -0
  42. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/device.py +0 -0
  43. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/device_manager.py +0 -0
  44. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/file_cache.py +0 -0
  45. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/rpc/__init__.py +0 -0
  46. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/rpc/a01_channel.py +0 -0
  47. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  48. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  49. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/rpc/v1_channel.py +0 -0
  50. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/__init__.py +0 -0
  51. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/a01/__init__.py +0 -0
  52. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/__init__.py +0 -0
  53. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  54. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  55. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  56. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  57. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  58. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  59. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  60. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  61. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  62. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/map.py +0 -0
  63. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  64. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
  65. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  66. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  67. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/volume.py +0 -0
  68. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  69. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  70. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  71. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  72. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/common.py +0 -0
  73. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/traits_mixin.py +0 -0
  74. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  75. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  76. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/command.py +0 -0
  77. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/common.py +0 -0
  78. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  79. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  80. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  81. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  82. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/home.py +0 -0
  83. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/led_status.py +0 -0
  84. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/map_content.py +0 -0
  85. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/maps.py +0 -0
  86. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/network_info.py +0 -0
  87. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/rooms.py +0 -0
  88. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/routines.py +0 -0
  89. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  90. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/status.py +0 -0
  91. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  92. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/volume.py +0 -0
  93. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  94. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/transport/__init__.py +0 -0
  95. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/transport/channel.py +0 -0
  96. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/transport/local_channel.py +0 -0
  97. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  98. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/diagnostics.py +0 -0
  99. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/exceptions.py +0 -0
  100. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/map/__init__.py +0 -0
  101. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/map/b01_grid_layers.py +0 -0
  102. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/map/b01_map_parser.py +0 -0
  103. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/map/b01_q10_map_parser.py +0 -0
  104. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/map/b01_q10_overlays.py +0 -0
  105. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/map/map_parser.py +0 -0
  106. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/map/proto/__init__.py +0 -0
  107. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/map/proto/b01_scmap.proto +0 -0
  108. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  109. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/mqtt/__init__.py +0 -0
  110. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/mqtt/health_manager.py +0 -0
  111. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/mqtt/roborock_session.py +0 -0
  112. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/mqtt/session.py +0 -0
  113. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/protocol.py +0 -0
  114. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/protocols/__init__.py +0 -0
  115. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/protocols/a01_protocol.py +0 -0
  116. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  117. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  118. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/protocols/v1_protocol.py +0 -0
  119. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/py.typed +0 -0
  120. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/roborock_message.py +0 -0
  121. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/roborock_typing.py +0 -0
  122. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/testing/simulator.py +0 -0
  123. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/testing/v1_simulator.py +0 -0
  124. {python_roborock-5.29.0 → python_roborock-5.31.0}/roborock/util.py +0 -0
  125. {python_roborock-5.29.0 → python_roborock-5.31.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.29.0
3
+ Version: 5.31.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.29.0"
3
+ version = "5.31.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"
@@ -60,6 +60,25 @@ def _attr_repr(obj: Any) -> str:
60
60
  return f"{type(obj).__name__}({', '.join(parts)})"
61
61
 
62
62
 
63
+ def field_metadata(**kwargs):
64
+ """Decorator to attach capability check metadata to a property.
65
+
66
+ This attaches a `_field_metadata` dictionary to the underlying getter function,
67
+ which is then preserved when decorated with `@property`.
68
+
69
+ Supported metadata keys:
70
+ - `feature` (str): Name of a capability property on `DeviceFeaturesTrait`.
71
+ - `dock_feature` (str): Name of a capability property on `RoborockDockFeatures`.
72
+ - `dps` (str/int): RoborockDataProtocol ID to check against supported schema IDs.
73
+ """
74
+
75
+ def decorator(func):
76
+ func._field_metadata = kwargs
77
+ return func
78
+
79
+ return decorator
80
+
81
+
63
82
  @dataclass(repr=False)
64
83
  class RoborockBase:
65
84
  """Base class for all Roborock data classes."""
@@ -39,7 +39,7 @@ from roborock.const import (
39
39
  from roborock.exceptions import RoborockException
40
40
  from roborock.roborock_message import RoborockDataProtocol
41
41
 
42
- from ..containers import NamedRoomMapping, RoborockBase, RoborockBaseTimer, _attr_repr
42
+ from ..containers import NamedRoomMapping, RoborockBase, RoborockBaseTimer, _attr_repr, field_metadata
43
43
  from .v1_clean_modes import WashTowelModes
44
44
  from .v1_code_mappings import (
45
45
  CleanFluidStatus,
@@ -117,6 +117,15 @@ class StatusField(FieldNameBase):
117
117
  CHARGE_STATUS = "charge_status"
118
118
  DRY_STATUS = "dry_status"
119
119
  ERROR_CODE = "error_code"
120
+ WATER_BOX_CARRIAGE_STATUS = "water_box_carriage_status"
121
+ WATER_BOX_STATUS = "water_box_status"
122
+ WATER_SHORTAGE_STATUS = "water_shortage_status"
123
+ DIRTY_WATER_BOX_STATUS = "dirty_water_box_status"
124
+ CLEAR_WATER_BOX_STATUS = "clear_water_box_status"
125
+ CLEAN_FLUID_STATUS = "clean_fluid_status"
126
+ CLEAN_PERCENT = "clean_percent"
127
+ DOCK_ERROR_STATUS = "dock_error_status"
128
+ RDT = "rdt"
120
129
 
121
130
 
122
131
  @dataclass
@@ -246,12 +255,14 @@ class Status(RoborockBase):
246
255
  return (self.dss & 3) == 2
247
256
 
248
257
  @property
258
+ @field_metadata(dock_feature="is_washable")
249
259
  def clear_water_box_status(self) -> ClearWaterBoxStatus | None:
250
260
  if self.dss:
251
261
  return ClearWaterBoxStatus((self.dss >> 2) & 3)
252
262
  return None
253
263
 
254
264
  @property
265
+ @field_metadata(dock_feature="is_washable")
255
266
  def dirty_water_box_status(self) -> DirtyWaterBoxStatus | None:
256
267
  if self.dss:
257
268
  return DirtyWaterBoxStatus((self.dss >> 4) & 3)
@@ -270,6 +281,9 @@ class Status(RoborockBase):
270
281
  return None
271
282
 
272
283
  @property
284
+ @field_metadata(
285
+ dock_feature="is_clean_fluid_auto_delivery_supported",
286
+ )
273
287
  def clean_fluid_status(self) -> CleanFluidStatus | None:
274
288
  if self.dss:
275
289
  value = (self.dss >> 10) & 3
@@ -313,7 +327,7 @@ class StatusV2(RoborockBase):
313
327
  in_returning: int | None = None
314
328
  in_fresh_state: int | None = None
315
329
  lab_status: int | None = None
316
- water_box_status: int | None = None
330
+ water_box_status: int | None = field(default=None, metadata={"feature": "is_support_water_mode"})
317
331
  back_type: int | None = None
318
332
  wash_phase: int | None = None
319
333
  wash_ready: int | None = None
@@ -323,14 +337,14 @@ class StatusV2(RoborockBase):
323
337
  is_locating: int | None = None
324
338
  lock_status: int | None = None
325
339
  water_box_mode: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.WATER_BOX_MODE})
326
- water_box_carriage_status: int | None = None
340
+ water_box_carriage_status: int | None = field(default=None, metadata={"feature": "is_support_water_mode"})
327
341
  mop_forbidden_enable: int | None = None
328
342
  camera_status: int | None = None
329
343
  is_exploring: int | None = None
330
344
  home_sec_status: int | None = None
331
345
  home_sec_enable_password: int | None = None
332
346
  adbumper_status: list[int] | None = None
333
- water_shortage_status: int | None = None
347
+ water_shortage_status: int | None = field(default=None, metadata={"feature": "is_support_water_mode"})
334
348
  dock_type: RoborockDockTypeCode | None = None
335
349
  dust_collection_status: int | None = None
336
350
  auto_dust_collection: int | None = None
@@ -339,7 +353,8 @@ class StatusV2(RoborockBase):
339
353
  debug_mode: int | None = None
340
354
  collision_avoid_status: int | None = None
341
355
  switch_map_mode: int | None = None
342
- dock_error_status: RoborockDockErrorCode | None = None
356
+ dock_error_status: RoborockDockErrorCode | None = field(default=None, metadata={"dock_feature": "has_dock"})
357
+
343
358
  charge_status: RoborockChargeStatus | None = field(
344
359
  default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS}
345
360
  )
@@ -349,8 +364,8 @@ class StatusV2(RoborockBase):
349
364
  distance_off: int | None = None
350
365
  in_warmup: int | None = None
351
366
  dry_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.DRYING_STATUS})
352
- rdt: int | None = None
353
- clean_percent: int | None = None
367
+ rdt: int | None = field(default=None, metadata={"feature": "is_supported_drying"})
368
+ clean_percent: int | None = field(default=None, metadata={"feature": "is_support_clean_estimate"})
354
369
  rss: int | None = None
355
370
  dss: int | None = None
356
371
  common_status: int | None = None
@@ -389,12 +404,14 @@ class StatusV2(RoborockBase):
389
404
  return (self.dss & 3) == 2
390
405
 
391
406
  @property
407
+ @field_metadata(dock_feature="is_washable")
392
408
  def clear_water_box_status(self) -> ClearWaterBoxStatus | None:
393
409
  if self.dss:
394
410
  return ClearWaterBoxStatus((self.dss >> 2) & 3)
395
411
  return None
396
412
 
397
413
  @property
414
+ @field_metadata(dock_feature="is_washable")
398
415
  def dirty_water_box_status(self) -> DirtyWaterBoxStatus | None:
399
416
  if self.dss:
400
417
  return DirtyWaterBoxStatus((self.dss >> 4) & 3)
@@ -413,6 +430,9 @@ class StatusV2(RoborockBase):
413
430
  return None
414
431
 
415
432
  @property
433
+ @field_metadata(
434
+ dock_feature="is_clean_fluid_auto_delivery_supported",
435
+ )
416
436
  def clean_fluid_status(self) -> CleanFluidStatus | None:
417
437
  if self.dss:
418
438
  value = (self.dss >> 10) & 3
@@ -264,6 +264,7 @@ class PropertiesApi(Trait):
264
264
  # Dock type also acts like a device feature for some traits.
265
265
  dock_type = await self._dock_type()
266
266
  dock_features = RoborockDockFeatures.from_dock_type(dock_type, has_am=self.status.has_am)
267
+ self.device_features.dock_features = dock_features
267
268
 
268
269
  # Initialize traits with special arguments before the generic loop
269
270
  if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_features):
@@ -1,12 +1,33 @@
1
- from dataclasses import Field, fields
1
+ from dataclasses import fields
2
+ from typing import Any
2
3
 
3
4
  from roborock.data import AppInitStatus, HomeDataProduct, RoborockBase
5
+ from roborock.data.v1 import RoborockDockTypeCode
4
6
  from roborock.data.v1.v1_containers import FieldNameBase
5
- from roborock.device_features import DeviceFeatures
7
+ from roborock.device_features import DeviceFeatures, RoborockDockFeatures
6
8
  from roborock.devices.cache import DeviceCache
7
9
  from roborock.devices.traits.v1 import common
8
10
  from roborock.roborock_typing import RoborockCommand
9
11
 
12
+ # Cache of metadata for each trait class
13
+ _metadata_cache: dict[type[RoborockBase], dict[str, dict[str, Any]]] = {}
14
+
15
+
16
+ def _get_field_metadata(cls: type[RoborockBase]) -> dict[str, Any]:
17
+ """Helper to get metadata from either class properties or dataclass fields."""
18
+ if cls not in _metadata_cache:
19
+ metadata_map = {}
20
+ # Inspect properties with @field_metadata
21
+ for name in dir(cls):
22
+ prop = getattr(cls, name, None)
23
+ if isinstance(prop, property):
24
+ metadata_map[name] = getattr(prop.fget, "_field_metadata", {})
25
+ # Inspect dataclass fields metadata
26
+ for f in fields(cls):
27
+ metadata_map[f.name] = f.metadata
28
+ _metadata_cache[cls] = metadata_map
29
+ return _metadata_cache[cls]
30
+
10
31
 
11
32
  class DeviceTraitsConverter(common.V1TraitDataConverter):
12
33
  """Converter for APP_GET_INIT_STATUS responses into DeviceFeatures."""
@@ -40,31 +61,37 @@ class DeviceFeaturesTrait(DeviceFeatures, common.V1TraitMixin):
40
61
  self.converter = DeviceTraitsConverter(product)
41
62
  self._product = product
42
63
  self._device_cache = device_cache
64
+ # Dock features are populated after device feature discovery
65
+ # is triggered.
66
+ self.dock_features: RoborockDockFeatures = RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o0_dock)
43
67
  # All fields of DeviceFeatures are required. Initialize them to False
44
68
  # so we have some known state.
45
69
  for field in fields(self):
46
70
  setattr(self, field.name, False)
47
71
 
48
- @staticmethod
49
- def _get_dataclass_field(cls: type[RoborockBase], field_name: FieldNameBase) -> Field:
50
- """Look up a dataclass field by its FieldNameBase name."""
51
- for f in fields(cls):
52
- if f.name == field_name:
53
- return f
54
- raise ValueError(f"Field {field_name!r} not found in {cls}")
55
-
56
72
  def is_field_supported(self, cls: type[RoborockBase], field_name: FieldNameBase) -> bool:
57
73
  """Determines if the specified field is supported by this device.
58
74
 
59
- We use the `dps` dataclass field metadata to get the `RoborockDataProtocol`
60
- integer ID and check it against the set of supported schema IDs for the
61
- device returned in the product information.
75
+ We inspect the metadata defined for the field (either via dataclass field metadata
76
+ or the `@field_metadata` decorator on properties). Supported checks include:
77
+
78
+ - `feature`: Maps to a boolean capability property on `DeviceFeatures` / `DeviceFeaturesTrait`
79
+ (e.g. `is_support_water_mode`).
80
+ - `dock_feature`: Maps to a boolean capability property on `RoborockDockFeatures` (e.g. `is_washable`).
81
+ - `dps`: Maps to a `RoborockDataProtocol` ID checked against the product's supported schema IDs.
62
82
  """
63
- dataclass_field = self._get_dataclass_field(cls, field_name)
64
- if (dps := dataclass_field.metadata.get("dps")) is None:
65
- # No DPS metadata — field is assumed always supported
66
- return True
67
- return int(dps) in self._product.supported_schema_ids
83
+ if self.dock_features is None:
84
+ raise ValueError("DeviceFeaturesTrait was invoked but was not fully initialized")
85
+ metadata_map = _get_field_metadata(cls)
86
+ if (field_metadata := metadata_map.get(field_name)) is not None:
87
+ if (feature := field_metadata.get("feature")) is not None:
88
+ return getattr(self, feature, False)
89
+ if (dock_feature := field_metadata.get("dock_feature")) is not None:
90
+ return getattr(self.dock_features, dock_feature, False)
91
+ if (dps := field_metadata.get("dps")) is not None:
92
+ return int(dps) in self._product.supported_schema_ids
93
+ # No metadata, field is assumed always supported
94
+ return True
68
95
 
69
96
  async def refresh(self) -> None:
70
97
  """Refresh the contents of this trait.
@@ -63,6 +63,10 @@ async def test_start_vacuum_service():
63
63
  ```
64
64
  """
65
65
 
66
+ from roborock.testing.b01_q10_simulator import (
67
+ DEFAULT_Q10_STATUS,
68
+ Q10VacuumSimulator,
69
+ )
66
70
  from roborock.testing.channel import FakeChannel
67
71
  from roborock.testing.cloud import FakeRoborockCloud, FakeWebApiClient
68
72
  from roborock.testing.simulator import (
@@ -92,10 +96,12 @@ __all__ = [
92
96
  "DEFAULT_LOCAL_KEY",
93
97
  "DEFAULT_NETWORK_INFO",
94
98
  "DEFAULT_PRODUCT_ID",
99
+ "DEFAULT_Q10_STATUS",
95
100
  "DEFAULT_STATUS",
96
101
  "FakeChannel",
97
102
  "FakeRoborockCloud",
98
103
  "FakeWebApiClient",
104
+ "Q10VacuumSimulator",
99
105
  "RoborockDeviceSimulator",
100
106
  "V1VacuumSimulator",
101
107
  ]
@@ -0,0 +1,213 @@
1
+ """Stateful simulator for Roborock Q10 (B01) devices."""
2
+
3
+ import copy
4
+ import json
5
+ import logging
6
+ from typing import Any
7
+
8
+ from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory
9
+ from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
10
+ from roborock.testing.simulator import RoborockDeviceSimulator
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+ B01_VERSION = b"B01"
15
+ DEFAULT_PRODUCT_ID = "product-id-q10"
16
+
17
+ DEFAULT_Q10_STATUS = {
18
+ 101: {
19
+ 104: 0,
20
+ 105: False,
21
+ 109: "us",
22
+ 207: 0,
23
+ 25: 1,
24
+ 26: 74,
25
+ 29: 0,
26
+ 30: 0,
27
+ 31: 0,
28
+ 37: 1,
29
+ 40: 1,
30
+ 45: 0,
31
+ 47: 0,
32
+ 50: 0,
33
+ 51: True,
34
+ 53: False,
35
+ 6: 0,
36
+ 60: 1,
37
+ 67: 0,
38
+ 7: 0,
39
+ 76: 0,
40
+ 78: 0,
41
+ 79: {"timeZoneCity": "America/Los_Angeles", "timeZoneSec": -28800},
42
+ 80: 0,
43
+ 81: {"ipAdress": "1.1.1.2", "mac": "99:AA:88:BB:77:CC", "signal": -50, "wifiName": "wifi-network-name"},
44
+ 83: 1,
45
+ 86: 1,
46
+ 87: 100,
47
+ 88: 0,
48
+ 90: 0,
49
+ 92: {"disturb_dust_enable": 1, "disturb_light": 1, "disturb_resume_clean": 1, "disturb_voice": 1},
50
+ 93: 1,
51
+ 96: 0,
52
+ },
53
+ 121: 8,
54
+ 122: 100,
55
+ 123: 2,
56
+ 124: 1,
57
+ 125: 0,
58
+ 126: 0,
59
+ 127: 0,
60
+ 136: 1,
61
+ 137: 1,
62
+ 138: 0,
63
+ 139: 5,
64
+ }
65
+
66
+
67
+ class Q10VacuumSimulator(RoborockDeviceSimulator):
68
+ """Stateful firmware simulator for Roborock Q10 (B01 ss07) devices."""
69
+
70
+ def __init__(
71
+ self,
72
+ duid: str,
73
+ status: dict[int, Any] | None = None,
74
+ device_info: HomeDataDevice | None = None,
75
+ product: HomeDataProduct | None = None,
76
+ ):
77
+ product = product or HomeDataProduct(
78
+ id=DEFAULT_PRODUCT_ID,
79
+ name="Roborock Q10",
80
+ model="roborock.vacuum.ss07",
81
+ category=RoborockCategory.VACUUM,
82
+ )
83
+ device_info = device_info or HomeDataDevice(
84
+ duid=duid,
85
+ name="Roborock Q10",
86
+ local_key="fake_localkey_16bytes",
87
+ product_id=product.id,
88
+ sn="fake_serial_number",
89
+ pv="B01",
90
+ )
91
+ super().__init__(duid, device_info, product, has_local_channel=False)
92
+ self.status = copy.deepcopy(status or DEFAULT_Q10_STATUS)
93
+
94
+ async def _handle_publish(self, message: RoborockMessage, channel: Any) -> None:
95
+ """Process incoming Q10 RPC command payload."""
96
+ if not message.payload:
97
+ return
98
+
99
+ try:
100
+ payload = json.loads(message.payload.decode())
101
+ except (json.JSONDecodeError, UnicodeDecodeError) as ex:
102
+ _LOGGER.warning("Simulator failed to parse incoming B01 payload: %s", ex)
103
+ return
104
+
105
+ datapoints = payload.get("dps", {})
106
+ if not isinstance(datapoints, dict):
107
+ return
108
+
109
+ updated_dps: dict[int, Any] = {}
110
+
111
+ for code_str, params in datapoints.items():
112
+ try:
113
+ code = int(code_str)
114
+ except ValueError:
115
+ continue
116
+
117
+ # Command: REQUEST_DPS (102) -> trigger status push
118
+ if code == 102:
119
+ # REQUEST_DPS triggers a dump of all status properties
120
+ # (both root properties and nested properties under 101)
121
+ self.trigger_push_update()
122
+ continue
123
+
124
+ # Command: START_CLEAN (201)
125
+ elif code == 201:
126
+ # 1 = sweep/mop, cmd inside dict = segment clean
127
+ self.status[121] = 5 # YXDeviceState.CLEANING
128
+ if isinstance(params, dict) and "cmd" in params:
129
+ self.status[138] = params.get("cmd", 0) # clean_task_type
130
+ else:
131
+ self.status[138] = 1 # CLEANING
132
+ updated_dps[121] = self.status[121]
133
+ updated_dps[138] = self.status[138]
134
+
135
+ # Command: START_BACK (202)
136
+ elif code == 202:
137
+ # 5 = returning to charge
138
+ self.status[121] = 6 # YXDeviceState.RETURNING_HOME
139
+ self.status[139] = 5 # BACK_CHARGING
140
+ updated_dps[121] = self.status[121]
141
+ updated_dps[139] = self.status[139]
142
+
143
+ # Command: START_DOCK_TASK (203)
144
+ elif code == 203:
145
+ # Empty dustbin
146
+ self.status[121] = 22 # EMPTYING_THE_BIN
147
+ updated_dps[121] = self.status[121]
148
+
149
+ # Command: PAUSE (204)
150
+ elif code == 204:
151
+ self.status[121] = 10 # YXDeviceState.PAUSED
152
+ updated_dps[121] = self.status[121]
153
+
154
+ # Command: RESUME (205)
155
+ elif code == 205:
156
+ self.status[121] = 5 # YXDeviceState.CLEANING
157
+ updated_dps[121] = self.status[121]
158
+
159
+ # Command: STOP (206)
160
+ elif code == 206:
161
+ self.status[121] = 3 # YXDeviceState.IDLE
162
+ self.status[138] = 0 # YXDeviceCleanTask.UNKNOWN/IDLE
163
+ updated_dps[121] = self.status[121]
164
+ updated_dps[138] = self.status[138]
165
+
166
+ # Command: FAN_LEVEL (123)
167
+ elif code == 123:
168
+ self.status[123] = params
169
+ updated_dps[123] = params
170
+
171
+ # Command: WATER_LEVEL (124)
172
+ elif code == 124:
173
+ self.status[124] = params
174
+ updated_dps[124] = params
175
+
176
+ # Command: CLEAN_MODE (137)
177
+ elif code == 137:
178
+ self.status[137] = params
179
+ updated_dps[137] = params
180
+
181
+ # Custom settings properties routed to sub-traits (DND, child lock, volume, dust collections)
182
+ elif code in (26, 47, 25, 37, 50):
183
+ # These live in the nested dpCommon (101) dict
184
+ common_dict = self.status[101]
185
+ if isinstance(common_dict, dict):
186
+ common_dict[code] = params
187
+ # We return them nested under 101
188
+ if 101 not in updated_dps:
189
+ updated_dps[101] = {}
190
+ updated_dps[101][code] = params
191
+
192
+ if updated_dps:
193
+ # Send the changed datapoints back to the client
194
+ self.push_dps(updated_dps)
195
+
196
+ def push_dps(self, dps_updates: dict[int, Any]) -> None:
197
+ """Push a set of status datapoint changes to the client."""
198
+ payload = {
199
+ "dps": {
200
+ str(k): ({str(sub_k): sub_v for sub_k, sub_v in v.items()} if isinstance(v, dict) and k == 101 else v)
201
+ for k, v in dps_updates.items()
202
+ }
203
+ }
204
+ message = RoborockMessage(
205
+ protocol=RoborockMessageProtocol.RPC_RESPONSE,
206
+ version=B01_VERSION,
207
+ payload=json.dumps(payload).encode("utf-8"),
208
+ )
209
+ self.mqtt_channel.notify_subscribers(message)
210
+
211
+ def trigger_push_update(self) -> None:
212
+ """Send the full status dump to the client."""
213
+ self.push_dps(self.status)
@@ -5,7 +5,8 @@ subscription, and publishing logic at the message boundary. It acts as an
5
5
  in-memory replacement for `MqttChannel` and `LocalChannel` during testing.
6
6
  """
7
7
 
8
- from collections.abc import Awaitable, Callable
8
+ import asyncio
9
+ from collections.abc import AsyncGenerator, Awaitable, Callable
9
10
  from typing import Any
10
11
  from unittest.mock import AsyncMock, MagicMock
11
12
 
@@ -53,7 +54,7 @@ class FakeChannel(Channel):
53
54
  self._is_local = is_local
54
55
 
55
56
  # A callback to intercept published messages (e.g., bound simulator handler).
56
- # Can be either synchronous or asynchronous: Callable[[RoborockMessage], Awaitable[Any]].
57
+ # Must be asynchronous: Callable[[RoborockMessage], Awaitable[Any]].
57
58
  # By default, routes to self._default_publish_handler to handle the response_queue.
58
59
  self.publish_handler: Callable[[RoborockMessage], Awaitable[Any]] | None = self._default_publish_handler
59
60
 
@@ -146,3 +147,17 @@ class FakeChannel(Channel):
146
147
  self.publish.side_effect = self._publish
147
148
  self.subscribe.side_effect = self._subscribe
148
149
  self.connect.side_effect = self._connect
150
+
151
+ async def subscribe_stream(self) -> AsyncGenerator[RoborockMessage, None]:
152
+ """Stream messages received via this channel."""
153
+ queue: asyncio.Queue[RoborockMessage] = asyncio.Queue()
154
+
155
+ def callback(message: RoborockMessage) -> None:
156
+ queue.put_nowait(message)
157
+
158
+ unsub = await self.subscribe(callback)
159
+ try:
160
+ while True:
161
+ yield await queue.get()
162
+ finally:
163
+ unsub()
@@ -16,6 +16,7 @@ from aioresponses import CallbackResult, aioresponses
16
16
  from roborock.data import HomeData, Reference, RRiot, UserData
17
17
  from roborock.devices.rpc.v1_channel import create_v1_channel as original_create_v1_channel
18
18
  from roborock.devices.transport.mqtt_channel import create_mqtt_channel as original_create_mqtt_channel
19
+ from roborock.testing.b01_q10_simulator import Q10VacuumSimulator
19
20
  from roborock.testing.simulator import RoborockDeviceSimulator
20
21
  from roborock.testing.v1_simulator import V1VacuumSimulator
21
22
 
@@ -242,11 +243,6 @@ class FakeRoborockCloud:
242
243
 
243
244
  # Wrapper function for create_v1_channel
244
245
  def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache):
245
- if device.pv in ("A01", "B01"):
246
- raise NotImplementedError(
247
- f"Simulating protocol {device.pv} is not yet supported. "
248
- "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices."
249
- )
250
246
  server = self.simulated_devices.get(device.duid)
251
247
  if server is not None:
252
248
  if not isinstance(server, V1VacuumSimulator):
@@ -255,18 +251,29 @@ class FakeRoborockCloud:
255
251
  f"simulator, but create_v1_channel requires a V1VacuumSimulator."
256
252
  )
257
253
  return server.v1_channel
254
+ if device.pv in ("A01", "B01"):
255
+ raise NotImplementedError(
256
+ f"Simulating protocol {device.pv} is not yet supported. "
257
+ "TODO: Implement stateful simulators for B01 (Q7) and A01 (Zeo/Dyad) devices."
258
+ )
258
259
  return original_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache)
259
260
 
260
261
  # Wrapper function for create_mqtt_channel
261
262
  def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device):
263
+ server = self.simulated_devices.get(device.duid)
264
+ if server:
265
+ if device.pv == "B01" and not isinstance(server, Q10VacuumSimulator):
266
+ raise NotImplementedError(
267
+ f"Simulating protocol {device.pv} is not yet supported. "
268
+ "Only Q10VacuumSimulator is supported for B01 protocols currently."
269
+ )
270
+ server.mqtt_channel._is_connected = True
271
+ return server.mqtt_channel
262
272
  if device.pv in ("A01", "B01"):
263
273
  raise NotImplementedError(
264
274
  f"Simulating protocol {device.pv} is not yet supported. "
265
- "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices."
275
+ "TODO: Implement stateful simulators for B01 (Q7) and A01 (Zeo/Dyad) devices."
266
276
  )
267
- server = self.simulated_devices.get(device.duid)
268
- if server:
269
- return server.mqtt_channel
270
277
  return original_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
271
278
 
272
279
  # Route Web requests using the dynamic FakeWebApiClient