python-roborock 5.26.0__tar.gz → 5.27.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 (124) hide show
  1. {python_roborock-5.26.0 → python_roborock-5.27.0}/PKG-INFO +1 -1
  2. {python_roborock-5.26.0 → python_roborock-5.27.0}/pyproject.toml +1 -1
  3. python_roborock-5.27.0/roborock/testing/__init__.py +101 -0
  4. python_roborock-5.27.0/roborock/testing/channel.py +126 -0
  5. python_roborock-5.27.0/roborock/testing/cloud.py +247 -0
  6. python_roborock-5.27.0/roborock/testing/simulator.py +110 -0
  7. python_roborock-5.27.0/roborock/testing/v1_simulator.py +395 -0
  8. {python_roborock-5.26.0 → python_roborock-5.27.0}/.gitignore +0 -0
  9. {python_roborock-5.26.0 → python_roborock-5.27.0}/LICENSE +0 -0
  10. {python_roborock-5.26.0 → python_roborock-5.27.0}/README.md +0 -0
  11. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/__init__.py +0 -0
  12. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/broadcast_protocol.py +0 -0
  13. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/callbacks.py +0 -0
  14. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/cli.py +0 -0
  15. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/const.py +0 -0
  16. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/__init__.py +0 -0
  17. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q10/__init__.py +0 -0
  18. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  19. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  20. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q7/__init__.py +0 -0
  21. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  22. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  23. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/code_mappings.py +0 -0
  24. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/containers.py +0 -0
  25. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/dyad/__init__.py +0 -0
  26. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  27. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/dyad/dyad_containers.py +0 -0
  28. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/mower/__init__.py +0 -0
  29. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/mower/mower_containers.py +0 -0
  30. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/v1/__init__.py +0 -0
  31. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  32. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  33. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/v1/v1_containers.py +0 -0
  34. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/zeo/__init__.py +0 -0
  35. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  36. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/zeo/zeo_containers.py +0 -0
  37. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/device_features.py +0 -0
  38. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/README.md +0 -0
  39. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/__init__.py +0 -0
  40. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/cache.py +0 -0
  41. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/device.py +0 -0
  42. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/device_manager.py +0 -0
  43. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/file_cache.py +0 -0
  44. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/rpc/__init__.py +0 -0
  45. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/rpc/a01_channel.py +0 -0
  46. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  47. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  48. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/rpc/v1_channel.py +0 -0
  49. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/__init__.py +0 -0
  50. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/a01/__init__.py +0 -0
  51. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/__init__.py +0 -0
  52. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  53. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  54. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  55. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  56. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  57. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  58. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  59. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  60. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  61. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/map.py +0 -0
  62. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  63. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
  64. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  65. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  66. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/volume.py +0 -0
  67. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  68. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  69. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  70. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  71. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/common.py +0 -0
  72. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/traits_mixin.py +0 -0
  73. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/__init__.py +0 -0
  74. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  75. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  76. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/command.py +0 -0
  77. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/common.py +0 -0
  78. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  79. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/device_features.py +0 -0
  80. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  81. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  82. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  83. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/home.py +0 -0
  84. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/led_status.py +0 -0
  85. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/map_content.py +0 -0
  86. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/maps.py +0 -0
  87. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/network_info.py +0 -0
  88. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/rooms.py +0 -0
  89. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/routines.py +0 -0
  90. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  91. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/status.py +0 -0
  92. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  93. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/volume.py +0 -0
  94. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  95. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/transport/__init__.py +0 -0
  96. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/transport/channel.py +0 -0
  97. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/transport/local_channel.py +0 -0
  98. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  99. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/diagnostics.py +0 -0
  100. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/exceptions.py +0 -0
  101. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/__init__.py +0 -0
  102. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/b01_grid_layers.py +0 -0
  103. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/b01_map_parser.py +0 -0
  104. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/b01_q10_map_parser.py +0 -0
  105. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/b01_q10_overlays.py +0 -0
  106. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/map_parser.py +0 -0
  107. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/proto/__init__.py +0 -0
  108. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/proto/b01_scmap.proto +0 -0
  109. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  110. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/mqtt/__init__.py +0 -0
  111. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/mqtt/health_manager.py +0 -0
  112. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/mqtt/roborock_session.py +0 -0
  113. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/mqtt/session.py +0 -0
  114. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocol.py +0 -0
  115. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocols/__init__.py +0 -0
  116. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocols/a01_protocol.py +0 -0
  117. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  118. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  119. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocols/v1_protocol.py +0 -0
  120. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/py.typed +0 -0
  121. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/roborock_message.py +0 -0
  122. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/roborock_typing.py +0 -0
  123. {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/util.py +0 -0
  124. {python_roborock-5.26.0 → python_roborock-5.27.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.26.0
3
+ Version: 5.27.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.26.0"
3
+ version = "5.27.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"
@@ -0,0 +1,101 @@
1
+ """Testing fakes and simulators for python-roborock.
2
+
3
+ This package provides stateful firmware simulators (e.g. `V1VacuumSimulator`),
4
+ fake transport channels (`FakeChannel`), and cloud orchestration simulators (`FakeRoborockCloud`)
5
+ to allow downstream consumers (such as Home Assistant integrations) to write high-fidelity
6
+ integration tests using the real client library classes instead of fragile top-level mocks.
7
+
8
+ Testing Architecture & Boundaries
9
+ ---------------------------------
10
+ We fake communication at two boundaries:
11
+ 1. **Network HTTP API Interception**: `FakeRoborockCloud.patch_device_manager()` routes
12
+ HTTP requests (such as discovery, login, home details) to custom mock endpoints using
13
+ `aioresponses` under the hood. No Python client methods are mocked; the real EAPI client
14
+ executes fully.
15
+ 2. **Plaintext RPC Message Interception**: Device communication is intercepted at the
16
+ plaintext JSON RPC level (Layer 2). The real client classes (`V1Channel`, `MqttChannel`)
17
+ run under test, but their transport calls are intercepted by our stateful simulators.
18
+
19
+ ┌────────────────────────────────────────────────────────┐
20
+ │ TESTED CLIENT (REAL CODE) │
21
+ │ │
22
+ │ RoborockDevice / Traits / V1RpcChannel / V1Channel │
23
+ └──────────────────────────┬─────────────────────────────┘
24
+
25
+ ROBOROCKMESSAGE PAYLOADS
26
+ (Plaintext JSON commands)
27
+
28
+ ┌──────────────────────────▼─────────────────────────────┐
29
+ │ SIMULATOR (TEST FAKE) │
30
+ │ │
31
+ │ FakeChannel (Intercepts publish/subscribe) │
32
+ │ RoborockDeviceSimulator (Stateful firmware simulator) │
33
+ └────────────────────────────────────────────────────────┘
34
+
35
+ Integration Usage Example
36
+ -------------------------
37
+ ```python
38
+ from roborock.testing import FakeRoborockCloud, V1VacuumSimulator
39
+
40
+ async def test_start_vacuum_service():
41
+ # Setup cloud state and add a simulated vacuum device
42
+ cloud = FakeRoborockCloud()
43
+ fake_device = V1VacuumSimulator(duid="living_room_s7", battery=100, state=RoborockStateCode.charging)
44
+ cloud.add_device(fake_device)
45
+
46
+ # Patch channels and API calls using our cloud context manager
47
+ with cloud.patch_device_manager():
48
+ # Create the real client manager (logins and discovers natively via mock HTTP)
49
+ manager = await create_device_manager(
50
+ user_params=UserParams(username="test_user", user_data=USER_DATA),
51
+ cache=InMemoryCache(),
52
+ )
53
+
54
+ # Fetch the discovered device client
55
+ devices = await manager.get_devices()
56
+ device = devices[0]
57
+
58
+ # Trigger client start command
59
+ await device.v1_properties.command.send("app_start")
60
+
61
+ # Assert against the simulated vacuum state
62
+ assert fake_device.state == RoborockStateCode.cleaning
63
+ ```
64
+ """
65
+
66
+ from roborock.testing.channel import FakeChannel
67
+ from roborock.testing.cloud import FakeRoborockCloud, FakeWebApiClient
68
+ from roborock.testing.simulator import (
69
+ DEFAULT_KEY_T,
70
+ DEFAULT_LOCAL_KEY,
71
+ DEFAULT_PRODUCT_ID,
72
+ RoborockDeviceSimulator,
73
+ )
74
+ from roborock.testing.v1_simulator import (
75
+ DEFAULT_APP_INIT,
76
+ DEFAULT_CLEAN_SUMMARY,
77
+ DEFAULT_CONSUMABLE,
78
+ DEFAULT_DND_TIMER,
79
+ DEFAULT_LAST_CLEAN_RECORD,
80
+ DEFAULT_NETWORK_INFO,
81
+ DEFAULT_STATUS,
82
+ V1VacuumSimulator,
83
+ )
84
+
85
+ __all__ = [
86
+ "DEFAULT_APP_INIT",
87
+ "DEFAULT_CLEAN_SUMMARY",
88
+ "DEFAULT_CONSUMABLE",
89
+ "DEFAULT_DND_TIMER",
90
+ "DEFAULT_KEY_T",
91
+ "DEFAULT_LAST_CLEAN_RECORD",
92
+ "DEFAULT_LOCAL_KEY",
93
+ "DEFAULT_NETWORK_INFO",
94
+ "DEFAULT_PRODUCT_ID",
95
+ "DEFAULT_STATUS",
96
+ "FakeChannel",
97
+ "FakeRoborockCloud",
98
+ "FakeWebApiClient",
99
+ "RoborockDeviceSimulator",
100
+ "V1VacuumSimulator",
101
+ ]
@@ -0,0 +1,126 @@
1
+ """Fake channel transport implementation for python-roborock.
2
+
3
+ This module defines `FakeChannel`, which simulates low-level connection,
4
+ subscription, and publishing logic at the message boundary. It acts as an
5
+ in-memory replacement for `MqttChannel` and `LocalChannel` during testing.
6
+ """
7
+
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+ from unittest.mock import AsyncMock, MagicMock
11
+
12
+ from roborock.devices.transport.channel import Channel
13
+ from roborock.mqtt.health_manager import HealthManager
14
+ from roborock.protocols.v1_protocol import LocalProtocolVersion
15
+ from roborock.roborock_message import RoborockMessage
16
+
17
+
18
+ class FakeChannel(Channel):
19
+ """A stateful, in-memory transport simulator implementing the Channel protocol.
20
+
21
+ It captures all published messages in `published_messages`, maintains a registry
22
+ of active callbacks in `subscribers`, and enables tests or stateful simulators to
23
+ unconditionally push unsolicited messages using `notify_subscribers`.
24
+
25
+ Caller API
26
+ ----------
27
+ The public interface consists of `AsyncMock` / `MagicMock` attributes that
28
+ wrap internal implementations. Because they are mocks, callers can:
29
+
30
+ - **Inspect calls**: ``channel.publish.assert_called_once()``
31
+ - **Inject failures**: ``channel.publish.side_effect = RoborockException(...)``
32
+ to simulate transport errors on the next publish.
33
+ - **Replace behavior**: ``channel.connect.side_effect = my_custom_connect``
34
+ to substitute entirely custom logic.
35
+ - **Queue canned responses**: Append to ``channel.response_queue`` to have
36
+ the channel automatically deliver a response to subscribers on the next
37
+ publish (useful for low-level RPC request/response testing).
38
+ - **Push unsolicited messages**: Call ``channel.notify_subscribers(msg)``
39
+ to simulate the device broadcasting a state change.
40
+ """
41
+
42
+ subscribe: Any
43
+
44
+ def __init__(self, is_local: bool = False):
45
+ """Initialize the fake channel."""
46
+ self.subscribers: list[Callable[[RoborockMessage], None]] = []
47
+ self.published_messages: list[RoborockMessage] = []
48
+ self.response_queue: list[RoborockMessage] = []
49
+ self._is_connected = False
50
+ self._is_local = is_local
51
+
52
+ # Set this to an exception instance to make the next publish raise it.
53
+ # This is a convenience shortcut; callers can also replace
54
+ # ``publish.side_effect`` directly for more control.
55
+ self.publish_side_effect: Exception | None = None
56
+
57
+ # AsyncMock wrapping _publish. Callers can replace side_effect to
58
+ # inject transport errors, e.g.:
59
+ # channel.publish.side_effect = RoborockException("timeout")
60
+ self.publish = AsyncMock(side_effect=self._publish)
61
+
62
+ # AsyncMock wrapping _subscribe. Callers can replace side_effect to
63
+ # simulate subscription failures, e.g.:
64
+ # channel.subscribe.side_effect = RoborockException("sub failed")
65
+ self.subscribe = AsyncMock(side_effect=self._subscribe) # type: ignore[assignment]
66
+
67
+ # AsyncMock wrapping _connect. Callers can replace side_effect to
68
+ # simulate connection failures, e.g.:
69
+ # channel.connect.side_effect = RoborockException("refused")
70
+ self.connect = AsyncMock(side_effect=self._connect)
71
+
72
+ # MagicMock wrapping _close. Callers can assert close was called
73
+ # or inject errors on teardown.
74
+ self.close = MagicMock(side_effect=self._close)
75
+
76
+ self.protocol_version = LocalProtocolVersion.V1
77
+ self.restart = AsyncMock()
78
+ self.health_manager = HealthManager(self.restart)
79
+
80
+ async def _connect(self) -> None:
81
+ self._is_connected = True
82
+
83
+ def _close(self) -> None:
84
+ self._is_connected = False
85
+
86
+ @property
87
+ def is_connected(self) -> bool:
88
+ """Return true if connected."""
89
+ return self._is_connected
90
+
91
+ @property
92
+ def is_local_connected(self) -> bool:
93
+ """Return true if locally connected."""
94
+ return self._is_connected and self._is_local
95
+
96
+ async def _publish(self, message: RoborockMessage) -> None:
97
+ """Default publish implementation.
98
+
99
+ Records the message in ``published_messages`` and, if
100
+ ``response_queue`` is non-empty, pops the first response and
101
+ delivers it to all current subscribers (simulating a
102
+ request/response round-trip).
103
+ """
104
+ self.published_messages.append(message)
105
+ if self.publish_side_effect:
106
+ raise self.publish_side_effect
107
+ if self.response_queue:
108
+ response = self.response_queue.pop(0)
109
+ self.notify_subscribers(response)
110
+
111
+ async def _subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
112
+ """Default subscribe implementation.
113
+
114
+ Registers the callback and returns an unsubscribe function.
115
+ """
116
+ self.subscribers.append(callback)
117
+ return lambda: self.subscribers.remove(callback)
118
+
119
+ def notify_subscribers(self, message: RoborockMessage) -> None:
120
+ """Deliver a message to all current subscribers.
121
+
122
+ Use this to simulate the channel receiving an unsolicited message
123
+ from the device (e.g. a state change broadcast).
124
+ """
125
+ for subscriber in list(self.subscribers):
126
+ subscriber(message)
@@ -0,0 +1,247 @@
1
+ """Cloud environment simulator for python-roborock testing.
2
+
3
+ This module provides `FakeRoborockCloud` which acts as a central registry
4
+ for all simulated devices, dynamically faking HTTP endpoints via aioresponses
5
+ to simulate physical devices connected to the Roborock Cloud.
6
+ """
7
+
8
+ import contextlib
9
+ import re
10
+ from typing import Any
11
+ from unittest.mock import AsyncMock, patch
12
+
13
+ from aioresponses import CallbackResult, aioresponses
14
+
15
+ from roborock.data import HomeData, Reference, RRiot, UserData
16
+ from roborock.devices.rpc.v1_channel import create_v1_channel as original_create_v1_channel
17
+ from roborock.devices.transport.mqtt_channel import create_mqtt_channel as original_create_mqtt_channel
18
+ from roborock.testing.simulator import RoborockDeviceSimulator
19
+ from roborock.testing.v1_simulator import V1VacuumSimulator
20
+
21
+ # EAPI Base URL pattern constants
22
+ IOT_API_BASE_URL = r"https://.*iot\.roborock\.com/api/v1"
23
+ REST_API_BASE_URL = r"https://api-.*\.roborock\.com"
24
+
25
+ DEFAULT_USER_DATA = UserData(
26
+ uid=123456,
27
+ tokentype="token_type",
28
+ token="abc123",
29
+ rruid="abc123",
30
+ region="us",
31
+ countrycode="1",
32
+ country="US",
33
+ nickname="user_nickname",
34
+ rriot=RRiot(
35
+ u="user123",
36
+ s="pass123",
37
+ h="unknown123",
38
+ k="qiCNieZa",
39
+ r=Reference(
40
+ r="US",
41
+ a="https://api-us.roborock.com",
42
+ l="https://wood-us.roborock.com",
43
+ m="tcp://mqtt-us.roborock.com:8883",
44
+ ),
45
+ ),
46
+ )
47
+
48
+
49
+ class FakeWebApiClient:
50
+ """Fakes the EAPI at the HTTP network boundary using aioresponses.
51
+
52
+ Exposes attributes that allow test suites (like Home Assistant) to easily
53
+ override response payloads, status codes, and simulate API errors.
54
+ """
55
+
56
+ def __init__(self, cloud: "FakeRoborockCloud"):
57
+ self.cloud = cloud
58
+ self.url_by_email_status = 200
59
+ self.url_by_email_payload: dict[str, Any] | None = None # Synthesized if None
60
+ self.login_status = 200
61
+ self.login_payload: dict[str, Any] | None = None # Synthesized if None
62
+ self.home_detail_status = 200
63
+ self.home_detail_payload: dict[str, Any] | None = None # Synthesized if None
64
+ self.homes_status = 200
65
+ self.homes_payload_override: dict[str, Any] | None = None
66
+
67
+ def get_url_by_email_payload(self) -> dict[str, Any]:
68
+ """Synthesize getUrlByEmail payload."""
69
+ if self.url_by_email_payload is not None:
70
+ return self.url_by_email_payload
71
+ return {
72
+ "code": 200,
73
+ "data": {
74
+ "country": self.cloud.user_data.country,
75
+ "countrycode": self.cloud.user_data.countrycode,
76
+ "url": f"https://{self.cloud.user_data.region}iot.roborock.com",
77
+ },
78
+ "msg": "success",
79
+ }
80
+
81
+ def get_login_payload(self) -> dict[str, Any]:
82
+ """Synthesize login payload using the cloud user profile state."""
83
+ if self.login_payload is not None:
84
+ return self.login_payload
85
+ return {
86
+ "code": 200,
87
+ "data": self.cloud.user_data.as_dict(),
88
+ "msg": "success",
89
+ }
90
+
91
+ def get_home_detail_payload(self) -> dict[str, Any]:
92
+ """Synthesize getHomeDetail payload using the cloud home state."""
93
+ if self.home_detail_payload is not None:
94
+ return self.home_detail_payload
95
+ return {
96
+ "code": 200,
97
+ "data": {
98
+ "deviceListOrder": None,
99
+ "id": self.cloud.home_id,
100
+ "name": self.cloud.home_name,
101
+ "rrHomeId": self.cloud.home_id,
102
+ "tuyaHomeId": 0,
103
+ },
104
+ "msg": "success",
105
+ }
106
+
107
+ def mock_requests(self, mocked: aioresponses) -> None:
108
+ """Register EAPI endpoint mocks with aioresponses."""
109
+ # getUrlByEmail Endpoint Mocking
110
+ mocked.post(
111
+ re.compile(rf"{IOT_API_BASE_URL}/getUrlByEmail.*"),
112
+ status=self.url_by_email_status,
113
+ payload=self.get_url_by_email_payload(),
114
+ )
115
+
116
+ # User Logins Endpoint Mocking
117
+ mocked.post(
118
+ re.compile(rf"{IOT_API_BASE_URL}/login.*"),
119
+ status=self.login_status,
120
+ payload=self.get_login_payload(),
121
+ )
122
+ mocked.post(
123
+ re.compile(rf"{IOT_API_BASE_URL}/loginWithCode.*"),
124
+ status=self.login_status,
125
+ payload=self.get_login_payload(),
126
+ )
127
+
128
+ # getHomeDetail Endpoint Mocking
129
+ mocked.get(
130
+ re.compile(rf"{IOT_API_BASE_URL}/getHomeDetail.*"),
131
+ status=self.home_detail_status,
132
+ payload=self.get_home_detail_payload(),
133
+ )
134
+
135
+ # Dynamic homes response callback wrapper
136
+ def get_homes_callback(url, **kwargs):
137
+ if self.homes_status != 200 or self.homes_payload_override is not None:
138
+ return CallbackResult(
139
+ status=self.homes_status,
140
+ payload=self.homes_payload_override,
141
+ )
142
+
143
+ devices = []
144
+ products = []
145
+ for server in self.cloud.simulated_devices.values():
146
+ devices.append(server.device_info)
147
+ products.append(server.product)
148
+
149
+ home_data = HomeData(
150
+ id=self.cloud.home_id,
151
+ name=self.cloud.home_name,
152
+ devices=devices,
153
+ products=products,
154
+ )
155
+ return CallbackResult(
156
+ status=200,
157
+ payload={
158
+ "api": None,
159
+ "code": 200,
160
+ "result": home_data.as_dict(),
161
+ "status": "ok",
162
+ "success": True,
163
+ },
164
+ )
165
+
166
+ # getHomeDetail v2 & v3 callbacks routing
167
+ mocked.get(
168
+ re.compile(rf"{REST_API_BASE_URL}/v2/user/homes/{self.cloud.home_id}"),
169
+ callback=get_homes_callback,
170
+ )
171
+ mocked.get(
172
+ re.compile(rf"{REST_API_BASE_URL}/v3/user/homes/{self.cloud.home_id}"),
173
+ callback=get_homes_callback,
174
+ )
175
+
176
+
177
+ class FakeRoborockCloud:
178
+ """A central state object representing the Roborock Cloud environment under test."""
179
+
180
+ def __init__(
181
+ self,
182
+ user_data: UserData | None = None,
183
+ home_id: int = 123456,
184
+ home_name: str = "Fake Home",
185
+ ) -> None:
186
+ self.simulated_devices: dict[str, RoborockDeviceSimulator] = {}
187
+ self.user_data = user_data or DEFAULT_USER_DATA
188
+ self.home_id = home_id
189
+ self.home_name = home_name
190
+ self.web_api = FakeWebApiClient(self)
191
+
192
+ def add_device(self, server: RoborockDeviceSimulator) -> None:
193
+ """Register a stateful device simulator in the cloud registry."""
194
+ self.simulated_devices[server.duid] = server
195
+
196
+ @contextlib.contextmanager
197
+ def patch_device_manager(self):
198
+ """Context manager to patch create_v1_channel and create_mqtt_channel.
199
+
200
+ This automatically routes communications to the registered device simulators
201
+ and intercepts HTTP calls at the network boundary using aioresponses.
202
+ """
203
+
204
+ # Wrapper function for create_v1_channel
205
+ def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache):
206
+ if device.pv in ("A01", "B01"):
207
+ raise NotImplementedError(
208
+ f"Simulating protocol {device.pv} is not yet supported. "
209
+ "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices."
210
+ )
211
+ server = self.simulated_devices.get(device.duid)
212
+ if server is not None:
213
+ if not isinstance(server, V1VacuumSimulator):
214
+ raise TypeError(
215
+ f"Device '{device.duid}' is registered with a {type(server).__name__} "
216
+ f"simulator, but create_v1_channel requires a V1VacuumSimulator."
217
+ )
218
+ return server.v1_channel
219
+ return original_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache)
220
+
221
+ # Wrapper function for create_mqtt_channel
222
+ def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device):
223
+ if device.pv in ("A01", "B01"):
224
+ raise NotImplementedError(
225
+ f"Simulating protocol {device.pv} is not yet supported. "
226
+ "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices."
227
+ )
228
+ server = self.simulated_devices.get(device.duid)
229
+ if server:
230
+ return server.mqtt_channel
231
+ return original_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
232
+
233
+ # Route Web requests using the dynamic FakeWebApiClient
234
+ with aioresponses() as mocked:
235
+ self.web_api.mock_requests(mocked)
236
+
237
+ # Patch Channel factories and rate limiters
238
+ with (
239
+ patch(
240
+ "roborock.web_api.RoborockApiClient._login_limiter.try_acquire_async",
241
+ new=AsyncMock(return_value=True),
242
+ ),
243
+ patch("roborock.web_api.RoborockApiClient._home_data_limiter.try_acquire", return_value=True),
244
+ patch("roborock.devices.device_manager.create_v1_channel", side_effect=mock_create_v1_channel),
245
+ patch("roborock.devices.device_manager.create_mqtt_channel", side_effect=mock_create_mqtt_channel),
246
+ ):
247
+ yield
@@ -0,0 +1,110 @@
1
+ """Base stateful device firmware simulator for python-roborock testing.
2
+
3
+ This module defines `RoborockDeviceSimulator` which intercepts plaintext JSON RPC messages
4
+ sent over simulated channels, process them through a local state engine, update internal
5
+ variables, and write responses back to client subscribers.
6
+ """
7
+
8
+ import logging
9
+
10
+ from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory
11
+ from roborock.roborock_message import RoborockMessage
12
+ from roborock.testing.channel import FakeChannel
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+ # Shared authentication key constants
17
+ DEFAULT_LOCAL_KEY = "fake_localkey_16bytes"
18
+ DEFAULT_KEY_T = "qiCNieZa"
19
+ DEFAULT_PRODUCT_ID = "product-id-123"
20
+
21
+
22
+ class RoborockDeviceSimulator:
23
+ """Base class for stateful device firmware simulators.
24
+
25
+ It sets up an MQTT fake transport channel (and optionally a local channel),
26
+ intercepts published requests, and routes them to `_handle_publish` to
27
+ simulate real device response.
28
+
29
+ Not all protocols support local connections. V1 devices use both MQTT and
30
+ local channels, while A01/B01 devices use MQTT only. Subclasses that need
31
+ a local channel should set ``has_local_channel=True`` (the default for
32
+ backward compatibility with V1 simulators).
33
+
34
+ Caller API
35
+ ----------
36
+ Subclasses (like ``RoborockVacuumSimulator``) provide the high-level
37
+ interface (state attributes, ``trigger_push_update()``, etc.), but callers
38
+ can also reach into the underlying channels for low-level inspection:
39
+
40
+ - **Inspect published messages**: ``simulator.mqtt_channel.published_messages``
41
+ (and ``simulator.local_channel.published_messages`` for V1) contain every
42
+ ``RoborockMessage`` that the client sent through each transport.
43
+ - **Inject transport failures**: Set
44
+ ``simulator.mqtt_channel.publish_side_effect = RoborockException(...)``
45
+ to make the next publish raise, simulating a network error.
46
+ - **Modify device identity**: Override ``simulator.device_info`` or
47
+ ``simulator.product`` before registering with ``FakeRoborockCloud`` to
48
+ control the device metadata returned during discovery.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ duid: str = "fake_duid",
54
+ device_info: HomeDataDevice | None = None,
55
+ product: HomeDataProduct | None = None,
56
+ has_local_channel: bool = True,
57
+ ):
58
+ self.duid = duid
59
+ self.product = product or HomeDataProduct(
60
+ id=DEFAULT_PRODUCT_ID,
61
+ name="Roborock Vacuum",
62
+ model="roborock.vacuum.s7",
63
+ category=RoborockCategory.VACUUM,
64
+ )
65
+ self.device_info = device_info or HomeDataDevice(
66
+ duid=self.duid,
67
+ name=f"Vacuum {self.duid}",
68
+ local_key=DEFAULT_LOCAL_KEY,
69
+ product_id=self.product.id,
70
+ sn="fake_serial_number",
71
+ pv="1.0",
72
+ )
73
+
74
+ # MQTT channel is always present — all protocols use it.
75
+ self.mqtt_channel = FakeChannel(is_local=False)
76
+ self.mqtt_channel.publish.side_effect = self._handle_mqtt_publish
77
+
78
+ # Local channel is only used by V1 devices. A01/B01 (MQTT-only)
79
+ # simulators should pass has_local_channel=False.
80
+ self.local_channel: FakeChannel | None = None
81
+ if has_local_channel:
82
+ self.local_channel = FakeChannel(is_local=True)
83
+ self.local_channel.publish.side_effect = self._handle_local_publish
84
+
85
+ async def _handle_local_publish(self, message: RoborockMessage) -> None:
86
+ assert self.local_channel is not None
87
+ self.local_channel.published_messages.append(message)
88
+ if self.local_channel.publish_side_effect:
89
+ raise self.local_channel.publish_side_effect
90
+ await self._handle_publish(message, self.local_channel)
91
+
92
+ async def _handle_mqtt_publish(self, message: RoborockMessage) -> None:
93
+ self.mqtt_channel.published_messages.append(message)
94
+ if self.mqtt_channel.publish_side_effect:
95
+ raise self.mqtt_channel.publish_side_effect
96
+ await self._handle_publish(message, self.mqtt_channel)
97
+
98
+ async def _handle_publish(self, message: RoborockMessage, channel: FakeChannel) -> None:
99
+ """To be overridden by subclasses to route commands."""
100
+ raise NotImplementedError("Subclasses must implement _handle_publish")
101
+
102
+ def connect(self) -> None:
103
+ if self.local_channel is not None:
104
+ self.local_channel._is_connected = True
105
+ self.mqtt_channel._is_connected = True
106
+
107
+ def close(self) -> None:
108
+ if self.local_channel is not None:
109
+ self.local_channel._is_connected = False
110
+ self.mqtt_channel._is_connected = False