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.
- {python_roborock-5.26.0 → python_roborock-5.27.0}/PKG-INFO +1 -1
- {python_roborock-5.26.0 → python_roborock-5.27.0}/pyproject.toml +1 -1
- python_roborock-5.27.0/roborock/testing/__init__.py +101 -0
- python_roborock-5.27.0/roborock/testing/channel.py +126 -0
- python_roborock-5.27.0/roborock/testing/cloud.py +247 -0
- python_roborock-5.27.0/roborock/testing/simulator.py +110 -0
- python_roborock-5.27.0/roborock/testing/v1_simulator.py +395 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/.gitignore +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/LICENSE +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/README.md +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/broadcast_protocol.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/callbacks.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/cli.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/const.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q10/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q7/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/code_mappings.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/containers.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/dyad/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/dyad/dyad_containers.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/mower/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/mower/mower_containers.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/v1/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/v1/v1_clean_modes.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/v1/v1_code_mappings.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/v1/v1_containers.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/zeo/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/data/zeo/zeo_containers.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/device_features.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/README.md +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/cache.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/device.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/device_manager.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/file_cache.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/rpc/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/rpc/a01_channel.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/rpc/v1_channel.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/a01/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/button_light.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/command.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/common.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/consumable.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/map.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/network_info.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/status.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q10/volume.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q7/map.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/common.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/traits_mixin.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/child_lock.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/command.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/common.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/consumeable.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/device_features.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/home.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/led_status.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/map_content.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/maps.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/network_info.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/rooms.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/routines.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/status.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/volume.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/transport/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/transport/channel.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/transport/local_channel.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/devices/transport/mqtt_channel.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/diagnostics.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/exceptions.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/b01_grid_layers.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/b01_map_parser.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/b01_q10_map_parser.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/b01_q10_overlays.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/map_parser.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/proto/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/proto/b01_scmap.proto +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/mqtt/health_manager.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocol.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocols/__init__.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocols/b01_q10_protocol.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocols/b01_q7_protocol.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/protocols/v1_protocol.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/py.typed +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/roborock_message.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-5.26.0 → python_roborock-5.27.0}/roborock/util.py +0 -0
- {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.
|
|
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.
|
|
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
|