python-roborock 5.26.0__py3-none-any.whl → 5.27.0__py3-none-any.whl
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.
- {python_roborock-5.26.0.dist-info → python_roborock-5.27.0.dist-info}/METADATA +1 -1
- {python_roborock-5.26.0.dist-info → python_roborock-5.27.0.dist-info}/RECORD +10 -5
- roborock/testing/__init__.py +101 -0
- roborock/testing/channel.py +126 -0
- roborock/testing/cloud.py +247 -0
- roborock/testing/simulator.py +110 -0
- roborock/testing/v1_simulator.py +395 -0
- {python_roborock-5.26.0.dist-info → python_roborock-5.27.0.dist-info}/WHEEL +0 -0
- {python_roborock-5.26.0.dist-info → python_roborock-5.27.0.dist-info}/entry_points.txt +0 -0
- {python_roborock-5.26.0.dist-info → python_roborock-5.27.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-roborock
|
|
3
|
-
Version: 5.
|
|
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/
|
|
@@ -112,8 +112,13 @@ roborock/protocols/a01_protocol.py,sha256=JCIhUuVNamcENu0gtZR2x8rBz6dpAZLcbA-NQg
|
|
|
112
112
|
roborock/protocols/b01_q10_protocol.py,sha256=Ac3_VHJ2DAwBiXFMPYOHExEV0prxS9Ju0djiC8nKtiI,4935
|
|
113
113
|
roborock/protocols/b01_q7_protocol.py,sha256=o8repRrxhxknzspsg1K27ds643rs87ilWXbj8RJtOCs,4619
|
|
114
114
|
roborock/protocols/v1_protocol.py,sha256=uA-EMXd1AUBAXjkjAm0VxZlPLre0v2X9Kei5UqyPzaY,11141
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
roborock/testing/__init__.py,sha256=hux3YzYVGWv_1ycpY1gmVdYWj0YXCam1s7UeAdsLSxU,4430
|
|
116
|
+
roborock/testing/channel.py,sha256=mHk4TZtndr1Z-wAYr-PQYWphwU1T_zhLmGYqlgGcyPQ,5318
|
|
117
|
+
roborock/testing/cloud.py,sha256=el6Cg8P3gII4ie6Aih6Ic0rrsbt2Eau9IdLAbih88kQ,9538
|
|
118
|
+
roborock/testing/simulator.py,sha256=UhEt88RduzYTe7J-PbzvPMXlXcvA3VeBwMBsspwrls0,4692
|
|
119
|
+
roborock/testing/v1_simulator.py,sha256=ITc-z4IIh10hnZI-bjQmb4rOT8rIYZZWnMcjnDDKmGI,14407
|
|
120
|
+
python_roborock-5.27.0.dist-info/METADATA,sha256=zZjSGkJHhX5C0x0P2WvUjro-MihNKWOq6fwi_T91MTQ,5281
|
|
121
|
+
python_roborock-5.27.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
122
|
+
python_roborock-5.27.0.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
|
|
123
|
+
python_roborock-5.27.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
124
|
+
python_roborock-5.27.0.dist-info/RECORD,,
|
|
@@ -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
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Stateful V1/L01 vacuum device firmware simulator.
|
|
2
|
+
|
|
3
|
+
This module provides `V1VacuumSimulator` which simulates the firmware state
|
|
4
|
+
machine and JSON RPC commands for V1 vacuum cleaners.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import asdict, replace
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
from unittest.mock import Mock
|
|
15
|
+
|
|
16
|
+
from roborock.data import HomeDataDevice, HomeDataProduct
|
|
17
|
+
from roborock.data.v1 import RoborockStateCode
|
|
18
|
+
from roborock.data.v1.v1_code_mappings import (
|
|
19
|
+
RoborockChargeStatus,
|
|
20
|
+
RoborockCleanType,
|
|
21
|
+
RoborockDockErrorCode,
|
|
22
|
+
RoborockDockTypeCode,
|
|
23
|
+
RoborockErrorCode,
|
|
24
|
+
RoborockFinishReason,
|
|
25
|
+
RoborockInCleaning,
|
|
26
|
+
RoborockStartType,
|
|
27
|
+
)
|
|
28
|
+
from roborock.data.v1.v1_containers import (
|
|
29
|
+
AppInitStatus,
|
|
30
|
+
AppInitStatusLocalInfo,
|
|
31
|
+
CleanRecord,
|
|
32
|
+
CleanSummary,
|
|
33
|
+
Consumable,
|
|
34
|
+
DnDTimer,
|
|
35
|
+
NetworkInfo,
|
|
36
|
+
StatusV2,
|
|
37
|
+
)
|
|
38
|
+
from roborock.devices.cache import DeviceCache, InMemoryCache
|
|
39
|
+
from roborock.devices.rpc.v1_channel import V1Channel
|
|
40
|
+
from roborock.protocols.v1_protocol import SecurityData
|
|
41
|
+
from roborock.roborock_message import RoborockDataProtocol, RoborockMessage, RoborockMessageProtocol
|
|
42
|
+
from roborock.testing.channel import FakeChannel
|
|
43
|
+
from roborock.testing.simulator import RoborockDeviceSimulator
|
|
44
|
+
|
|
45
|
+
_LOGGER = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _serialize_dataclass(obj: Any) -> dict[str, Any]:
|
|
49
|
+
"""Helper to convert dataclass instances to dictionaries with serialized enums and filtered Nones."""
|
|
50
|
+
return {k: (v.value if isinstance(v, Enum) else v) for k, v in asdict(obj).items() if v is not None}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DEFAULT_STATUS = StatusV2(
|
|
54
|
+
msg_ver=2,
|
|
55
|
+
msg_seq=458,
|
|
56
|
+
state=RoborockStateCode.charging,
|
|
57
|
+
battery=100,
|
|
58
|
+
clean_time=1176,
|
|
59
|
+
clean_area=20965000,
|
|
60
|
+
error_code=RoborockErrorCode(0),
|
|
61
|
+
map_present=1,
|
|
62
|
+
in_cleaning=RoborockInCleaning.complete,
|
|
63
|
+
in_returning=0,
|
|
64
|
+
in_fresh_state=1,
|
|
65
|
+
lab_status=1,
|
|
66
|
+
water_box_status=1,
|
|
67
|
+
back_type=-1,
|
|
68
|
+
wash_phase=0,
|
|
69
|
+
wash_ready=0,
|
|
70
|
+
fan_power=102,
|
|
71
|
+
dnd_enabled=0,
|
|
72
|
+
map_status=3,
|
|
73
|
+
is_locating=0,
|
|
74
|
+
lock_status=0,
|
|
75
|
+
water_box_mode=200,
|
|
76
|
+
water_box_carriage_status=1,
|
|
77
|
+
mop_forbidden_enable=1,
|
|
78
|
+
camera_status=3457,
|
|
79
|
+
is_exploring=0,
|
|
80
|
+
home_sec_status=0,
|
|
81
|
+
home_sec_enable_password=0,
|
|
82
|
+
adbumper_status=[0, 0, 0],
|
|
83
|
+
water_shortage_status=0,
|
|
84
|
+
dock_type=RoborockDockTypeCode.s8_dock,
|
|
85
|
+
dust_collection_status=0,
|
|
86
|
+
auto_dust_collection=1,
|
|
87
|
+
avoid_count=19,
|
|
88
|
+
mop_mode=300,
|
|
89
|
+
debug_mode=0,
|
|
90
|
+
collision_avoid_status=1,
|
|
91
|
+
switch_map_mode=0,
|
|
92
|
+
dock_error_status=RoborockDockErrorCode(0),
|
|
93
|
+
charge_status=RoborockChargeStatus.charge_waiting,
|
|
94
|
+
unsave_map_reason=0,
|
|
95
|
+
unsave_map_flag=0,
|
|
96
|
+
dss=169,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
DEFAULT_APP_INIT = AppInitStatus(
|
|
100
|
+
local_info=AppInitStatusLocalInfo(
|
|
101
|
+
location="us",
|
|
102
|
+
bom="A.03.0069",
|
|
103
|
+
featureset=1,
|
|
104
|
+
language="en",
|
|
105
|
+
logserver="awsusor0.fds.api.xiaomi.com",
|
|
106
|
+
wifiplan="0x39",
|
|
107
|
+
timezone="US/Pacific",
|
|
108
|
+
name="custom_A.03.0069_FCC",
|
|
109
|
+
),
|
|
110
|
+
feature_info=[111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 122, 123, 124, 125],
|
|
111
|
+
new_feature_info=633887780925447,
|
|
112
|
+
new_feature_info_str="0000000000002000",
|
|
113
|
+
new_feature_info_2=8192,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
DEFAULT_NETWORK_INFO = NetworkInfo(
|
|
117
|
+
ip="1.1.1.1",
|
|
118
|
+
ssid="test_wifi",
|
|
119
|
+
mac="aa:bb:cc:dd:ee:ff",
|
|
120
|
+
bssid="aa:bb:cc:dd:ee:ff",
|
|
121
|
+
rssi=-50,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
DEFAULT_CONSUMABLE = Consumable(
|
|
125
|
+
main_brush_work_time=74382,
|
|
126
|
+
side_brush_work_time=74383,
|
|
127
|
+
filter_work_time=74384,
|
|
128
|
+
filter_element_work_time=0,
|
|
129
|
+
sensor_dirty_time=74385,
|
|
130
|
+
strainer_work_times=65,
|
|
131
|
+
dust_collection_work_times=25,
|
|
132
|
+
cleaning_brush_work_times=66,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
DEFAULT_DND_TIMER = DnDTimer(
|
|
136
|
+
start_hour=22,
|
|
137
|
+
start_minute=0,
|
|
138
|
+
end_hour=7,
|
|
139
|
+
end_minute=0,
|
|
140
|
+
enabled=1,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
DEFAULT_CLEAN_SUMMARY = CleanSummary(
|
|
144
|
+
clean_time=74382,
|
|
145
|
+
clean_area=1159182500,
|
|
146
|
+
clean_count=31,
|
|
147
|
+
dust_collection_count=25,
|
|
148
|
+
records=[1672543330, 1672458041],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
DEFAULT_LAST_CLEAN_RECORD = CleanRecord(
|
|
152
|
+
begin=1672543330,
|
|
153
|
+
end=1672544638,
|
|
154
|
+
duration=1176,
|
|
155
|
+
area=20965000,
|
|
156
|
+
error=0,
|
|
157
|
+
complete=1,
|
|
158
|
+
start_type=RoborockStartType.app,
|
|
159
|
+
clean_type=RoborockCleanType.select_zone,
|
|
160
|
+
finish_reason=RoborockFinishReason.finished_cleaning_4,
|
|
161
|
+
dust_collection_status=1,
|
|
162
|
+
avoid_count=19,
|
|
163
|
+
wash_count=2,
|
|
164
|
+
map_flag=0,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class V1VacuumSimulator(RoborockDeviceSimulator):
|
|
169
|
+
"""Firmware simulator for a V1/L01 vacuum device.
|
|
170
|
+
|
|
171
|
+
This class holds the simulated physical hardware state (such as battery levels,
|
|
172
|
+
cleaning state, fan speeds, and consumable wear). When it receives JSON RPC
|
|
173
|
+
commands (like `app_start` or `get_consumable`), it updates these state variables
|
|
174
|
+
and returns a response corresponding to the expected firmware behavior.
|
|
175
|
+
|
|
176
|
+
Default command handlers are mapped in `self.default_handlers` and can be
|
|
177
|
+
overridden during initialization by passing `custom_handlers`.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
duid: str = "fake_duid",
|
|
183
|
+
status: StatusV2 | None = None,
|
|
184
|
+
app_init: AppInitStatus | None = None,
|
|
185
|
+
network_info: NetworkInfo | None = None,
|
|
186
|
+
consumables: Consumable | None = None,
|
|
187
|
+
dnd_timer: DnDTimer | None = None,
|
|
188
|
+
clean_summary: CleanSummary | None = None,
|
|
189
|
+
last_clean_record: CleanRecord | None = None,
|
|
190
|
+
custom_handlers: dict[str, Callable[[list[Any]], Any]] | None = None,
|
|
191
|
+
device_info: HomeDataDevice | None = None,
|
|
192
|
+
product: HomeDataProduct | None = None,
|
|
193
|
+
):
|
|
194
|
+
super().__init__(duid=duid, device_info=device_info, product=product)
|
|
195
|
+
self.status = status or replace(DEFAULT_STATUS)
|
|
196
|
+
self.app_init = app_init or replace(DEFAULT_APP_INIT)
|
|
197
|
+
if app_init is None:
|
|
198
|
+
self.app_init.local_info = replace(DEFAULT_APP_INIT.local_info)
|
|
199
|
+
self.network_info = network_info or replace(DEFAULT_NETWORK_INFO)
|
|
200
|
+
self.consumables = consumables or replace(DEFAULT_CONSUMABLE)
|
|
201
|
+
self.dnd_timer = dnd_timer or replace(DEFAULT_DND_TIMER)
|
|
202
|
+
self.clean_summary = clean_summary or replace(DEFAULT_CLEAN_SUMMARY)
|
|
203
|
+
self.last_clean_record = last_clean_record or replace(DEFAULT_LAST_CLEAN_RECORD)
|
|
204
|
+
self.custom_handlers = custom_handlers or {}
|
|
205
|
+
|
|
206
|
+
# Set up default handlers dictionary
|
|
207
|
+
self.default_handlers: dict[str, Callable[[Any], Any]] = {
|
|
208
|
+
"get_status": lambda params: [self.get_status_dict()],
|
|
209
|
+
"get_consumable": lambda params: [_serialize_dataclass(self.consumables)],
|
|
210
|
+
"get_dnd_timer": lambda params: _serialize_dataclass(self.dnd_timer),
|
|
211
|
+
"get_clean_summary": lambda params: _serialize_dataclass(self.clean_summary),
|
|
212
|
+
"get_clean_record": lambda params: _serialize_dataclass(self.last_clean_record),
|
|
213
|
+
"app_start": self._handle_app_start,
|
|
214
|
+
"app_stop": self._handle_app_stop,
|
|
215
|
+
"app_charge": self._handle_app_charge,
|
|
216
|
+
"set_custom_mode": self._handle_set_custom_mode,
|
|
217
|
+
"set_mop_mode": self._handle_set_mop_mode,
|
|
218
|
+
"set_water_box_custom_mode": self._handle_set_water_box_custom_mode,
|
|
219
|
+
"reset_consumable": self._handle_reset_consumable,
|
|
220
|
+
"app_get_init_status": self._handle_app_get_init_status,
|
|
221
|
+
"get_network_info": self._handle_get_network_info,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
self.device_cache = DeviceCache(self.duid, InMemoryCache())
|
|
225
|
+
self.security_data = SecurityData(endpoint="fake_endpoint", nonce=b"fake_nonce_16bytes")
|
|
226
|
+
local_session = Mock(return_value=self.local_channel)
|
|
227
|
+
|
|
228
|
+
self._v1_channel = V1Channel(
|
|
229
|
+
device_uid=self.duid,
|
|
230
|
+
security_data=self.security_data,
|
|
231
|
+
mqtt_channel=self.mqtt_channel, # type: ignore[arg-type]
|
|
232
|
+
local_session=local_session,
|
|
233
|
+
device_cache=self.device_cache,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def v1_channel(self) -> V1Channel:
|
|
238
|
+
"""Returns the real V1Channel bound to the fake channels."""
|
|
239
|
+
return self._v1_channel
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def in_cleaning(self) -> RoborockInCleaning:
|
|
243
|
+
"""Return global_clean_not_complete if cleaning, else complete."""
|
|
244
|
+
return (
|
|
245
|
+
RoborockInCleaning.global_clean_not_complete
|
|
246
|
+
if self.status.state == RoborockStateCode.cleaning
|
|
247
|
+
else RoborockInCleaning.complete
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def in_returning(self) -> int:
|
|
252
|
+
"""Return 1 if returning, else 0."""
|
|
253
|
+
return 1 if self.status.state == RoborockStateCode.returning_home else 0
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def charge_status(self) -> RoborockChargeStatus:
|
|
257
|
+
"""Return charging if charging, else charge_waiting."""
|
|
258
|
+
return (
|
|
259
|
+
RoborockChargeStatus.charging
|
|
260
|
+
if self.status.state == RoborockStateCode.charging
|
|
261
|
+
else RoborockChargeStatus.charge_waiting
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def get_status_dict(self) -> dict[str, Any]:
|
|
265
|
+
"""Generate status dict using the current simulated state."""
|
|
266
|
+
self.status.in_cleaning = self.in_cleaning
|
|
267
|
+
self.status.in_returning = self.in_returning
|
|
268
|
+
self.status.charge_status = self.charge_status
|
|
269
|
+
return _serialize_dataclass(self.status)
|
|
270
|
+
|
|
271
|
+
def _handle_app_start(self, params: Any) -> str:
|
|
272
|
+
self.status.state = RoborockStateCode.cleaning
|
|
273
|
+
return "ok"
|
|
274
|
+
|
|
275
|
+
def _handle_app_stop(self, params: Any) -> str:
|
|
276
|
+
self.status.state = RoborockStateCode.paused
|
|
277
|
+
return "ok"
|
|
278
|
+
|
|
279
|
+
def _handle_app_charge(self, params: Any) -> str:
|
|
280
|
+
self.status.state = RoborockStateCode.returning_home
|
|
281
|
+
return "ok"
|
|
282
|
+
|
|
283
|
+
def _handle_set_custom_mode(self, params: Any) -> str:
|
|
284
|
+
if isinstance(params, list) and len(params) > 0:
|
|
285
|
+
self.status.fan_power = params[0]
|
|
286
|
+
elif isinstance(params, dict):
|
|
287
|
+
self.status.fan_power = params.get("fan_power", self.status.fan_power)
|
|
288
|
+
return "ok"
|
|
289
|
+
|
|
290
|
+
def _handle_set_mop_mode(self, params: Any) -> str:
|
|
291
|
+
if isinstance(params, list) and len(params) > 0:
|
|
292
|
+
self.status.mop_mode = params[0]
|
|
293
|
+
return "ok"
|
|
294
|
+
|
|
295
|
+
def _handle_set_water_box_custom_mode(self, params: Any) -> str:
|
|
296
|
+
if isinstance(params, list) and len(params) > 0:
|
|
297
|
+
self.status.water_box_mode = params[0]
|
|
298
|
+
return "ok"
|
|
299
|
+
|
|
300
|
+
def _handle_reset_consumable(self, params: Any) -> str:
|
|
301
|
+
if isinstance(params, list) and len(params) > 0:
|
|
302
|
+
consumable_name = params[0]
|
|
303
|
+
if hasattr(self.consumables, consumable_name):
|
|
304
|
+
setattr(self.consumables, consumable_name, 0)
|
|
305
|
+
return "ok"
|
|
306
|
+
|
|
307
|
+
def _handle_app_get_init_status(self, params: Any) -> list[dict[str, Any]]:
|
|
308
|
+
payload = _serialize_dataclass(self.app_init)
|
|
309
|
+
if "new_feature_info_2" in payload:
|
|
310
|
+
payload["new_feature_info2"] = payload.pop("new_feature_info_2")
|
|
311
|
+
|
|
312
|
+
payload["status_info"] = {
|
|
313
|
+
"state": self.status.state.value if self.status.state else 0,
|
|
314
|
+
"battery": self.status.battery,
|
|
315
|
+
"clean_time": self.status.clean_time or 5610,
|
|
316
|
+
"clean_area": self.status.clean_area or 96490000,
|
|
317
|
+
"error_code": self.status.error_code.value if self.status.error_code else 0,
|
|
318
|
+
"in_cleaning": self.in_cleaning.value,
|
|
319
|
+
"in_returning": self.in_returning,
|
|
320
|
+
"in_fresh_state": self.status.in_fresh_state or 1,
|
|
321
|
+
"lab_status": self.status.lab_status or 1,
|
|
322
|
+
"water_box_status": self.status.water_box_status or 0,
|
|
323
|
+
"map_status": self.status.map_status or 3,
|
|
324
|
+
"is_locating": self.status.is_locating or 0,
|
|
325
|
+
"lock_status": self.status.lock_status or 0,
|
|
326
|
+
"water_box_mode": self.status.water_box_mode,
|
|
327
|
+
"distance_off": self.status.distance_off or 0,
|
|
328
|
+
"water_box_carriage_status": self.status.water_box_carriage_status or 0,
|
|
329
|
+
"mop_forbidden_enable": self.status.mop_forbidden_enable or 0,
|
|
330
|
+
}
|
|
331
|
+
return [payload]
|
|
332
|
+
|
|
333
|
+
def _handle_get_network_info(self, params: Any) -> dict[str, Any]:
|
|
334
|
+
return _serialize_dataclass(self.network_info)
|
|
335
|
+
|
|
336
|
+
async def _handle_publish(self, message: RoborockMessage, channel: FakeChannel) -> None:
|
|
337
|
+
if not message.payload:
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
payload = json.loads(message.payload.decode())
|
|
342
|
+
dps = payload.get("dps", {})
|
|
343
|
+
if "101" not in dps:
|
|
344
|
+
return
|
|
345
|
+
inner = json.loads(dps["101"])
|
|
346
|
+
msg_id = inner["id"]
|
|
347
|
+
method = inner["method"]
|
|
348
|
+
params = inner.get("params", [])
|
|
349
|
+
except Exception as e:
|
|
350
|
+
_LOGGER.debug("Failed to parse plaintext JSON RPC payload: %s", e, exc_info=True)
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
result = None
|
|
354
|
+
error = None
|
|
355
|
+
|
|
356
|
+
# Check custom handlers override first, then fall back to default handlers
|
|
357
|
+
handler = self.custom_handlers.get(method) or self.default_handlers.get(method)
|
|
358
|
+
if handler:
|
|
359
|
+
try:
|
|
360
|
+
result = handler(params)
|
|
361
|
+
except Exception as e:
|
|
362
|
+
error = str(e)
|
|
363
|
+
_LOGGER.debug("Error executing command handler for %s: %s", method, e, exc_info=True)
|
|
364
|
+
else:
|
|
365
|
+
result = "ok"
|
|
366
|
+
|
|
367
|
+
response_data = {
|
|
368
|
+
"dps": {"102": json.dumps({"id": msg_id, "result": result, "error": error})},
|
|
369
|
+
"t": int(time.time()),
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
response_msg = RoborockMessage(
|
|
373
|
+
protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=json.dumps(response_data).encode(), seq=msg_id
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
channel.notify_subscribers(response_msg)
|
|
377
|
+
|
|
378
|
+
def trigger_push_update(self) -> None:
|
|
379
|
+
"""Trigger an unsolicited push state update to all subscribers."""
|
|
380
|
+
dps_payload = {
|
|
381
|
+
str(int(RoborockDataProtocol.STATE)): self.status.state.value if self.status.state else 0,
|
|
382
|
+
str(int(RoborockDataProtocol.BATTERY)): self.status.battery,
|
|
383
|
+
str(int(RoborockDataProtocol.FAN_POWER)): self.status.fan_power,
|
|
384
|
+
str(int(RoborockDataProtocol.WATER_BOX_MODE)): self.status.water_box_mode,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
payload = {"dps": dps_payload, "t": int(time.time())}
|
|
388
|
+
|
|
389
|
+
push_msg = RoborockMessage(
|
|
390
|
+
protocol=RoborockMessageProtocol.GENERAL_RESPONSE, payload=json.dumps(payload).encode()
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
self.mqtt_channel.notify_subscribers(push_msg)
|
|
394
|
+
if self.local_channel is not None:
|
|
395
|
+
self.local_channel.notify_subscribers(push_msg)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|