python-roborock 5.30.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.30.0 → python_roborock-5.31.0}/PKG-INFO +1 -1
  2. {python_roborock-5.30.0 → python_roborock-5.31.0}/pyproject.toml +1 -1
  3. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/testing/__init__.py +6 -0
  4. python_roborock-5.31.0/roborock/testing/b01_q10_simulator.py +213 -0
  5. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/testing/channel.py +17 -2
  6. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/testing/cloud.py +16 -9
  7. {python_roborock-5.30.0 → python_roborock-5.31.0}/.gitignore +0 -0
  8. {python_roborock-5.30.0 → python_roborock-5.31.0}/LICENSE +0 -0
  9. {python_roborock-5.30.0 → python_roborock-5.31.0}/README.md +0 -0
  10. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/__init__.py +0 -0
  11. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/broadcast_protocol.py +0 -0
  12. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/callbacks.py +0 -0
  13. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/cli.py +0 -0
  14. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/const.py +0 -0
  15. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/__init__.py +0 -0
  16. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/b01_q10/__init__.py +0 -0
  17. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  18. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  19. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/b01_q7/__init__.py +0 -0
  20. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  21. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  22. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/code_mappings.py +0 -0
  23. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/containers.py +0 -0
  24. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/dyad/__init__.py +0 -0
  25. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  26. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/dyad/dyad_containers.py +0 -0
  27. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/mower/__init__.py +0 -0
  28. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/mower/mower_containers.py +0 -0
  29. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/v1/__init__.py +0 -0
  30. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  31. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  32. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/v1/v1_containers.py +0 -0
  33. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/zeo/__init__.py +0 -0
  34. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  35. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/data/zeo/zeo_containers.py +0 -0
  36. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/device_features.py +0 -0
  37. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/README.md +0 -0
  38. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/__init__.py +0 -0
  39. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/cache.py +0 -0
  40. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/device.py +0 -0
  41. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/device_manager.py +0 -0
  42. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/file_cache.py +0 -0
  43. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/rpc/__init__.py +0 -0
  44. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/rpc/a01_channel.py +0 -0
  45. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  46. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  47. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/rpc/v1_channel.py +0 -0
  48. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/__init__.py +0 -0
  49. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/a01/__init__.py +0 -0
  50. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/__init__.py +0 -0
  51. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  52. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  53. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  54. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  55. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  56. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  57. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  58. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  59. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  60. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/map.py +0 -0
  61. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  62. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
  63. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  64. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  65. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q10/volume.py +0 -0
  66. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  67. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  68. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  69. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  70. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/common.py +0 -0
  71. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/traits_mixin.py +0 -0
  72. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/__init__.py +0 -0
  73. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  74. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  75. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/command.py +0 -0
  76. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/common.py +0 -0
  77. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  78. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/device_features.py +0 -0
  79. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  80. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  81. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  82. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/home.py +0 -0
  83. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/led_status.py +0 -0
  84. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/map_content.py +0 -0
  85. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/maps.py +0 -0
  86. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/network_info.py +0 -0
  87. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/rooms.py +0 -0
  88. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/routines.py +0 -0
  89. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  90. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/status.py +0 -0
  91. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  92. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/volume.py +0 -0
  93. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  94. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/transport/__init__.py +0 -0
  95. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/transport/channel.py +0 -0
  96. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/transport/local_channel.py +0 -0
  97. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  98. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/diagnostics.py +0 -0
  99. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/exceptions.py +0 -0
  100. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/map/__init__.py +0 -0
  101. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/map/b01_grid_layers.py +0 -0
  102. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/map/b01_map_parser.py +0 -0
  103. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/map/b01_q10_map_parser.py +0 -0
  104. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/map/b01_q10_overlays.py +0 -0
  105. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/map/map_parser.py +0 -0
  106. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/map/proto/__init__.py +0 -0
  107. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/map/proto/b01_scmap.proto +0 -0
  108. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  109. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/mqtt/__init__.py +0 -0
  110. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/mqtt/health_manager.py +0 -0
  111. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/mqtt/roborock_session.py +0 -0
  112. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/mqtt/session.py +0 -0
  113. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/protocol.py +0 -0
  114. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/protocols/__init__.py +0 -0
  115. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/protocols/a01_protocol.py +0 -0
  116. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  117. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  118. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/protocols/v1_protocol.py +0 -0
  119. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/py.typed +0 -0
  120. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/roborock_message.py +0 -0
  121. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/roborock_typing.py +0 -0
  122. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/testing/simulator.py +0 -0
  123. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/testing/v1_simulator.py +0 -0
  124. {python_roborock-5.30.0 → python_roborock-5.31.0}/roborock/util.py +0 -0
  125. {python_roborock-5.30.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.30.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.30.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"
@@ -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