python-roborock 2.9.0rc1__tar.gz → 2.9.2__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-2.9.0rc1 → python_roborock-2.9.2}/PKG-INFO +46 -4
- python_roborock-2.9.2/README.md +76 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/pyproject.toml +7 -13
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/api.py +36 -36
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/cloud_api.py +78 -74
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/code_mappings.py +42 -2
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/const.py +1 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/containers.py +39 -23
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/local_api.py +30 -15
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/roborock_future.py +10 -3
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/roborock_typing.py +6 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/version_1_apis/roborock_client_v1.py +30 -16
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/version_1_apis/roborock_local_client_v1.py +22 -8
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/version_1_apis/roborock_mqtt_client_v1.py +22 -15
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/version_a01_apis/roborock_client_a01.py +12 -5
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +12 -6
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/web_api.py +31 -11
- python_roborock-2.9.0rc1/README.md +0 -32
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/LICENSE +0 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/__init__.py +0 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/cli.py +0 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/command_cache.py +0 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/exceptions.py +0 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/protocol.py +0 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/py.typed +0 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/roborock_message.py +0 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/util.py +0 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/version_1_apis/__init__.py +0 -0
- {python_roborock-2.9.0rc1 → python_roborock-2.9.2}/roborock/version_a01_apis/__init__.py +0 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: python-roborock
|
|
3
|
-
Version: 2.9.
|
|
3
|
+
Version: 2.9.2
|
|
4
4
|
Summary: A package to control Roborock vacuums.
|
|
5
|
-
Home-page: https://github.com/humbertogontijo/python-roborock
|
|
6
5
|
License: GPL-3.0-only
|
|
7
6
|
Keywords: roborock,vacuum,homeassistant
|
|
8
7
|
Author: humbertogontijo
|
|
@@ -22,7 +21,6 @@ Requires-Dist: aiohttp (>=3.8.2,<4.0.0)
|
|
|
22
21
|
Requires-Dist: async-timeout
|
|
23
22
|
Requires-Dist: click (>=8)
|
|
24
23
|
Requires-Dist: construct (>=2.10.57,<3.0.0)
|
|
25
|
-
Requires-Dist: dacite (>=1.8.0,<2.0.0)
|
|
26
24
|
Requires-Dist: paho-mqtt (>=1.6.1,<2.0.0)
|
|
27
25
|
Requires-Dist: pycryptodome (>=3.18,<4.0)
|
|
28
26
|
Requires-Dist: pycryptodomex (>=3.18,<4.0) ; sys_platform == "darwin"
|
|
@@ -53,6 +51,50 @@ Install this via pip (or your favourite package manager):
|
|
|
53
51
|
|
|
54
52
|
You can see all of the commands supported [here]("https://python-roborock.readthedocs.io/en/latest/api_commands.html")
|
|
55
53
|
|
|
54
|
+
## Sending Commands
|
|
55
|
+
|
|
56
|
+
Here is an example that requires no manual intervention and can be done all automatically. You can skip some steps by
|
|
57
|
+
caching values or looking at them and grabbing them manually.
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
|
|
61
|
+
from roborock import HomeDataProduct, DeviceData, RoborockCommand
|
|
62
|
+
from roborock.version_1_apis import RoborockMqttClientV1, RoborockLocalClientV1
|
|
63
|
+
from roborock.web_api import RoborockApiClient
|
|
64
|
+
|
|
65
|
+
async def main():
|
|
66
|
+
web_api = RoborockApiClient(username="youremailhere")
|
|
67
|
+
# Login via your password
|
|
68
|
+
user_data = await web_api.pass_login(password="pass_here")
|
|
69
|
+
# Or login via a code
|
|
70
|
+
await web_api.request_code()
|
|
71
|
+
code = input("What is the code?")
|
|
72
|
+
user_data = await web_api.code_login(code)
|
|
73
|
+
|
|
74
|
+
# Get home data
|
|
75
|
+
home_data = await web_api.get_home_data_v2(user_data)
|
|
76
|
+
|
|
77
|
+
# Get the device you want
|
|
78
|
+
device = home_data.devices[0]
|
|
79
|
+
|
|
80
|
+
# Get product ids:
|
|
81
|
+
product_info: dict[str, HomeDataProduct] = {
|
|
82
|
+
product.id: product for product in home_data.products
|
|
83
|
+
}
|
|
84
|
+
# Create the Mqtt(aka cloud required) Client
|
|
85
|
+
device_data = DeviceData(device, product_info[device.product_id].model)
|
|
86
|
+
mqtt_client = RoborockMqttClientV1(user_data, device_data)
|
|
87
|
+
networking = await mqtt_client.get_networking()
|
|
88
|
+
local_device_data = DeviceData(device, product_info[device.product_id].model, networking.ip)
|
|
89
|
+
local_client = RoborockLocalClientV1(local_device_data)
|
|
90
|
+
# You can use the send_command to send any command to the device
|
|
91
|
+
status = await local_client.send_command(RoborockCommand.GET_STATUS)
|
|
92
|
+
# Or use existing functions that will give you data classes
|
|
93
|
+
status = await local_client.get_status()
|
|
94
|
+
|
|
95
|
+
asyncio.run(main())
|
|
96
|
+
```
|
|
97
|
+
|
|
56
98
|
## Supported devices
|
|
57
99
|
|
|
58
100
|
You can find what devices are supported
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Roborock
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://pypi.org/project/python-roborock/">
|
|
5
|
+
<img src="https://img.shields.io/pypi/v/python-roborock.svg?logo=python&logoColor=fff&style=flat-square" alt="PyPI Version">
|
|
6
|
+
</a>
|
|
7
|
+
<img src="https://img.shields.io/pypi/pyversions/python-roborock.svg?style=flat-square&logo=python&logoColor=fff" alt="Supported Python versions">
|
|
8
|
+
<img src="https://img.shields.io/pypi/l/python-roborock.svg?style=flat-square" alt="License">
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
Roborock library for online and offline control of your vacuums.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Install this via pip (or your favourite package manager):
|
|
16
|
+
|
|
17
|
+
`pip install python-roborock`
|
|
18
|
+
|
|
19
|
+
## Functionality
|
|
20
|
+
|
|
21
|
+
You can see all of the commands supported [here]("https://python-roborock.readthedocs.io/en/latest/api_commands.html")
|
|
22
|
+
|
|
23
|
+
## Sending Commands
|
|
24
|
+
|
|
25
|
+
Here is an example that requires no manual intervention and can be done all automatically. You can skip some steps by
|
|
26
|
+
caching values or looking at them and grabbing them manually.
|
|
27
|
+
```python
|
|
28
|
+
import asyncio
|
|
29
|
+
|
|
30
|
+
from roborock import HomeDataProduct, DeviceData, RoborockCommand
|
|
31
|
+
from roborock.version_1_apis import RoborockMqttClientV1, RoborockLocalClientV1
|
|
32
|
+
from roborock.web_api import RoborockApiClient
|
|
33
|
+
|
|
34
|
+
async def main():
|
|
35
|
+
web_api = RoborockApiClient(username="youremailhere")
|
|
36
|
+
# Login via your password
|
|
37
|
+
user_data = await web_api.pass_login(password="pass_here")
|
|
38
|
+
# Or login via a code
|
|
39
|
+
await web_api.request_code()
|
|
40
|
+
code = input("What is the code?")
|
|
41
|
+
user_data = await web_api.code_login(code)
|
|
42
|
+
|
|
43
|
+
# Get home data
|
|
44
|
+
home_data = await web_api.get_home_data_v2(user_data)
|
|
45
|
+
|
|
46
|
+
# Get the device you want
|
|
47
|
+
device = home_data.devices[0]
|
|
48
|
+
|
|
49
|
+
# Get product ids:
|
|
50
|
+
product_info: dict[str, HomeDataProduct] = {
|
|
51
|
+
product.id: product for product in home_data.products
|
|
52
|
+
}
|
|
53
|
+
# Create the Mqtt(aka cloud required) Client
|
|
54
|
+
device_data = DeviceData(device, product_info[device.product_id].model)
|
|
55
|
+
mqtt_client = RoborockMqttClientV1(user_data, device_data)
|
|
56
|
+
networking = await mqtt_client.get_networking()
|
|
57
|
+
local_device_data = DeviceData(device, product_info[device.product_id].model, networking.ip)
|
|
58
|
+
local_client = RoborockLocalClientV1(local_device_data)
|
|
59
|
+
# You can use the send_command to send any command to the device
|
|
60
|
+
status = await local_client.send_command(RoborockCommand.GET_STATUS)
|
|
61
|
+
# Or use existing functions that will give you data classes
|
|
62
|
+
status = await local_client.get_status()
|
|
63
|
+
|
|
64
|
+
asyncio.run(main())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Supported devices
|
|
68
|
+
|
|
69
|
+
You can find what devices are supported
|
|
70
|
+
[here]("https://python-roborock.readthedocs.io/en/latest/supported_devices.html").
|
|
71
|
+
Please note this may not immediately contain the latest devices.
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
## Credits
|
|
75
|
+
|
|
76
|
+
Thanks @rovo89 for https://gist.github.com/rovo89/dff47ed19fca0dfdda77503e66c2b7c7 And thanks @PiotrMachowski for https://github.com/PiotrMachowski/Home-Assistant-custom-components-Xiaomi-Cloud-Map-Extractor
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-roborock"
|
|
3
|
-
version = "2.9.
|
|
3
|
+
version = "2.9.2"
|
|
4
4
|
description = "A package to control Roborock vacuums."
|
|
5
5
|
authors = ["humbertogontijo <humbertogontijo@users.noreply.github.com>"]
|
|
6
6
|
license = "GPL-3.0-only"
|
|
@@ -28,7 +28,6 @@ async-timeout = "*"
|
|
|
28
28
|
pycryptodome = "^3.18"
|
|
29
29
|
pycryptodomex = {version = "^3.18", markers = "sys_platform == 'darwin'"}
|
|
30
30
|
paho-mqtt = "^1.6.1"
|
|
31
|
-
dacite = "^1.8.0"
|
|
32
31
|
construct = "^2.10.57"
|
|
33
32
|
vacuum-map-parser-roborock = "*"
|
|
34
33
|
|
|
@@ -40,25 +39,17 @@ build-backend = "poetry.core.masonry.api"
|
|
|
40
39
|
[tool.poetry.group.dev.dependencies]
|
|
41
40
|
pytest-asyncio = "*"
|
|
42
41
|
pytest = "*"
|
|
43
|
-
pre-commit = "
|
|
42
|
+
pre-commit = ">=3.5,<5.0"
|
|
44
43
|
mypy = "*"
|
|
45
44
|
ruff = "*"
|
|
46
45
|
codespell = "*"
|
|
47
46
|
pyshark = "^0.6"
|
|
47
|
+
aioresponses = "^0.7.7"
|
|
48
48
|
|
|
49
49
|
[tool.semantic_release]
|
|
50
|
+
branch = "main"
|
|
50
51
|
version_toml = ["pyproject.toml:tool.poetry.version"]
|
|
51
52
|
build_command = "pip install poetry && poetry build"
|
|
52
|
-
|
|
53
|
-
[semantic_release.branches.main]
|
|
54
|
-
match = "release"
|
|
55
|
-
prerelease = false
|
|
56
|
-
|
|
57
|
-
[tool.semantic_release.branches.other]
|
|
58
|
-
match = ".*"
|
|
59
|
-
prerelease_token = "rc"
|
|
60
|
-
prerelease = true
|
|
61
|
-
|
|
62
53
|
[tool.semantic_release.commit_parser_options]
|
|
63
54
|
allowed_tags = [
|
|
64
55
|
"chore",
|
|
@@ -76,3 +67,6 @@ select=["E", "F", "UP", "I"]
|
|
|
76
67
|
|
|
77
68
|
[tool.ruff.lint.per-file-ignores]
|
|
78
69
|
"*/__init__.py" = ["F401"]
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
asyncio_mode = "auto"
|
|
@@ -7,7 +7,7 @@ import base64
|
|
|
7
7
|
import logging
|
|
8
8
|
import secrets
|
|
9
9
|
import time
|
|
10
|
-
from
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
from .containers import (
|
|
@@ -16,33 +16,35 @@ from .containers import (
|
|
|
16
16
|
from .exceptions import (
|
|
17
17
|
RoborockTimeout,
|
|
18
18
|
UnknownMethodError,
|
|
19
|
-
VacuumError,
|
|
20
19
|
)
|
|
21
20
|
from .roborock_future import RoborockFuture
|
|
22
21
|
from .roborock_message import (
|
|
23
22
|
RoborockMessage,
|
|
24
23
|
)
|
|
25
24
|
from .roborock_typing import RoborockCommand
|
|
26
|
-
from .util import
|
|
25
|
+
from .util import get_next_int, get_running_loop_or_create_one
|
|
27
26
|
|
|
28
27
|
_LOGGER = logging.getLogger(__name__)
|
|
29
28
|
KEEPALIVE = 60
|
|
30
29
|
|
|
31
30
|
|
|
32
|
-
class RoborockClient:
|
|
33
|
-
|
|
31
|
+
class RoborockClient(ABC):
|
|
32
|
+
"""Roborock client base class."""
|
|
33
|
+
|
|
34
|
+
_logger: logging.LoggerAdapter
|
|
35
|
+
|
|
36
|
+
def __init__(self, device_info: DeviceData, queue_timeout: int = 4) -> None:
|
|
37
|
+
"""Initialize RoborockClient."""
|
|
34
38
|
self.event_loop = get_running_loop_or_create_one()
|
|
35
39
|
self.device_info = device_info
|
|
36
|
-
self._endpoint = endpoint
|
|
37
40
|
self._nonce = secrets.token_bytes(16)
|
|
38
41
|
self._waiting_queue: dict[int, RoborockFuture] = {}
|
|
39
|
-
self._last_device_msg_in =
|
|
40
|
-
self._last_disconnection =
|
|
42
|
+
self._last_device_msg_in = time.monotonic()
|
|
43
|
+
self._last_disconnection = time.monotonic()
|
|
41
44
|
self.keep_alive = KEEPALIVE
|
|
42
45
|
self._diagnostic_data: dict[str, dict[str, Any]] = {
|
|
43
46
|
"misc_info": {"Nonce": base64.b64encode(self._nonce).decode("utf-8")}
|
|
44
47
|
}
|
|
45
|
-
self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER)
|
|
46
48
|
self.is_available: bool = True
|
|
47
49
|
self.queue_timeout = queue_timeout
|
|
48
50
|
|
|
@@ -59,35 +61,30 @@ class RoborockClient:
|
|
|
59
61
|
def diagnostic_data(self) -> dict:
|
|
60
62
|
return self._diagnostic_data
|
|
61
63
|
|
|
62
|
-
@
|
|
63
|
-
def time_func(self) -> Callable[[], float]:
|
|
64
|
-
try:
|
|
65
|
-
# Use monotonic clock if available
|
|
66
|
-
time_func = time.monotonic
|
|
67
|
-
except AttributeError:
|
|
68
|
-
time_func = time.time
|
|
69
|
-
return time_func
|
|
70
|
-
|
|
64
|
+
@abstractmethod
|
|
71
65
|
async def async_connect(self):
|
|
72
|
-
|
|
66
|
+
"""Connect to the Roborock device."""
|
|
73
67
|
|
|
68
|
+
@abstractmethod
|
|
74
69
|
def sync_disconnect(self) -> Any:
|
|
75
|
-
|
|
70
|
+
"""Disconnect from the Roborock device."""
|
|
76
71
|
|
|
72
|
+
@abstractmethod
|
|
77
73
|
async def async_disconnect(self) -> Any:
|
|
78
|
-
|
|
74
|
+
"""Disconnect from the Roborock device."""
|
|
79
75
|
|
|
76
|
+
@abstractmethod
|
|
80
77
|
def on_message_received(self, messages: list[RoborockMessage]) -> None:
|
|
81
|
-
|
|
78
|
+
"""Handle received incoming messages from the device."""
|
|
82
79
|
|
|
83
80
|
def on_connection_lost(self, exc: Exception | None) -> None:
|
|
84
|
-
self._last_disconnection =
|
|
81
|
+
self._last_disconnection = time.monotonic()
|
|
85
82
|
self._logger.info("Roborock client disconnected")
|
|
86
83
|
if exc is not None:
|
|
87
84
|
self._logger.warning(exc)
|
|
88
85
|
|
|
89
86
|
def should_keepalive(self) -> bool:
|
|
90
|
-
now =
|
|
87
|
+
now = time.monotonic()
|
|
91
88
|
# noinspection PyUnresolvedReferences
|
|
92
89
|
if now - self._last_disconnection > self.keep_alive**2 and now - self._last_device_msg_in > self.keep_alive:
|
|
93
90
|
return False
|
|
@@ -98,37 +95,40 @@ class RoborockClient:
|
|
|
98
95
|
await self.async_disconnect()
|
|
99
96
|
await self.async_connect()
|
|
100
97
|
|
|
101
|
-
async def _wait_response(self, request_id: int, queue: RoborockFuture) ->
|
|
98
|
+
async def _wait_response(self, request_id: int, queue: RoborockFuture) -> Any:
|
|
102
99
|
try:
|
|
103
|
-
|
|
100
|
+
response = await queue.async_get(self.queue_timeout)
|
|
104
101
|
if response == "unknown_method":
|
|
105
102
|
raise UnknownMethodError("Unknown method")
|
|
106
|
-
return response
|
|
103
|
+
return response
|
|
107
104
|
except (asyncio.TimeoutError, asyncio.CancelledError):
|
|
108
105
|
raise RoborockTimeout(f"id={request_id} Timeout after {self.queue_timeout} seconds") from None
|
|
109
106
|
finally:
|
|
110
107
|
self._waiting_queue.pop(request_id, None)
|
|
111
108
|
|
|
112
|
-
def _async_response(
|
|
113
|
-
self, request_id: int, protocol_id: int = 0
|
|
114
|
-
) -> Coroutine[Any, Any, tuple[Any, VacuumError | None]]:
|
|
109
|
+
def _async_response(self, request_id: int, protocol_id: int = 0) -> Any:
|
|
115
110
|
queue = RoborockFuture(protocol_id)
|
|
116
111
|
if request_id in self._waiting_queue:
|
|
117
112
|
new_id = get_next_int(10000, 32767)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
113
|
+
self._logger.warning(
|
|
114
|
+
"Attempting to create a future with an existing id %s (%s)... New id is %s. "
|
|
115
|
+
"Code may not function properly.",
|
|
116
|
+
request_id,
|
|
117
|
+
protocol_id,
|
|
118
|
+
new_id,
|
|
121
119
|
)
|
|
122
120
|
request_id = new_id
|
|
123
121
|
self._waiting_queue[request_id] = queue
|
|
124
|
-
return self._wait_response(request_id, queue)
|
|
122
|
+
return asyncio.ensure_future(self._wait_response(request_id, queue))
|
|
125
123
|
|
|
124
|
+
@abstractmethod
|
|
126
125
|
async def send_message(self, roborock_message: RoborockMessage):
|
|
127
|
-
|
|
126
|
+
"""Send a message to the Roborock device."""
|
|
128
127
|
|
|
128
|
+
@abstractmethod
|
|
129
129
|
async def _send_command(
|
|
130
130
|
self,
|
|
131
131
|
method: RoborockCommand | str,
|
|
132
132
|
params: list | dict | int | None = None,
|
|
133
133
|
):
|
|
134
|
-
|
|
134
|
+
"""Send a command to the Roborock device."""
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import asyncio
|
|
4
|
-
import base64
|
|
5
3
|
import logging
|
|
6
4
|
import threading
|
|
7
|
-
import typing
|
|
8
5
|
import uuid
|
|
9
|
-
from
|
|
6
|
+
from abc import ABC
|
|
7
|
+
from asyncio import Lock
|
|
10
8
|
from typing import Any
|
|
11
9
|
from urllib.parse import urlparse
|
|
12
10
|
|
|
@@ -15,31 +13,52 @@ import paho.mqtt.client as mqtt
|
|
|
15
13
|
from .api import KEEPALIVE, RoborockClient
|
|
16
14
|
from .containers import DeviceData, UserData
|
|
17
15
|
from .exceptions import RoborockException, VacuumError
|
|
18
|
-
from .protocol import MessageParser,
|
|
16
|
+
from .protocol import MessageParser, md5hex
|
|
19
17
|
from .roborock_future import RoborockFuture
|
|
20
|
-
from .roborock_message import RoborockMessage
|
|
21
|
-
from .roborock_typing import RoborockCommand
|
|
22
|
-
from .util import RoborockLoggerAdapter
|
|
23
18
|
|
|
24
|
-
if typing.TYPE_CHECKING:
|
|
25
|
-
pass
|
|
26
19
|
_LOGGER = logging.getLogger(__name__)
|
|
27
20
|
CONNECT_REQUEST_ID = 0
|
|
28
21
|
DISCONNECT_REQUEST_ID = 1
|
|
29
22
|
|
|
30
23
|
|
|
31
|
-
class
|
|
24
|
+
class _Mqtt(mqtt.Client):
|
|
25
|
+
"""Internal MQTT client.
|
|
26
|
+
|
|
27
|
+
This is a subclass of the Paho MQTT client that adds some additional functionality
|
|
28
|
+
for error cases where things get stuck.
|
|
29
|
+
"""
|
|
30
|
+
|
|
32
31
|
_thread: threading.Thread
|
|
33
32
|
_client_id: str
|
|
34
33
|
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
"""Initialize the MQTT client."""
|
|
36
|
+
super().__init__(protocol=mqtt.MQTTv5)
|
|
37
|
+
self.reset_client_id()
|
|
38
|
+
|
|
39
|
+
def reset_client_id(self):
|
|
40
|
+
"""Generate a new client id to make a new session when reconnecting."""
|
|
41
|
+
self._client_id = mqtt.base62(uuid.uuid4().int, padding=22)
|
|
42
|
+
|
|
43
|
+
def maybe_restart_loop(self) -> None:
|
|
44
|
+
"""Ensure that the MQTT loop is running in case it previously exited."""
|
|
45
|
+
if not self._thread or not self._thread.is_alive():
|
|
46
|
+
if self._thread:
|
|
47
|
+
_LOGGER.info("Stopping mqtt loop")
|
|
48
|
+
super().loop_stop()
|
|
49
|
+
_LOGGER.info("Starting mqtt loop")
|
|
50
|
+
super().loop_start()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RoborockMqttClient(RoborockClient, ABC):
|
|
54
|
+
"""Roborock MQTT client base class."""
|
|
55
|
+
|
|
35
56
|
def __init__(self, user_data: UserData, device_info: DeviceData, queue_timeout: int = 10) -> None:
|
|
57
|
+
"""Initialize the Roborock MQTT client."""
|
|
36
58
|
rriot = user_data.rriot
|
|
37
59
|
if rriot is None:
|
|
38
60
|
raise RoborockException("Got no rriot data from user_data")
|
|
39
|
-
|
|
40
|
-
RoborockClient.__init__(self, endpoint, device_info, queue_timeout)
|
|
41
|
-
mqtt.Client.__init__(self, protocol=mqtt.MQTTv5)
|
|
42
|
-
self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER)
|
|
61
|
+
RoborockClient.__init__(self, device_info, queue_timeout)
|
|
43
62
|
self._mqtt_user = rriot.u
|
|
44
63
|
self._hashed_user = md5hex(self._mqtt_user + ":" + rriot.k)[2:10]
|
|
45
64
|
url = urlparse(rriot.r.m)
|
|
@@ -48,39 +67,43 @@ class RoborockMqttClient(RoborockClient, mqtt.Client):
|
|
|
48
67
|
self._mqtt_host = str(url.hostname)
|
|
49
68
|
self._mqtt_port = url.port
|
|
50
69
|
self._mqtt_ssl = url.scheme == "ssl"
|
|
70
|
+
|
|
71
|
+
self._mqtt_client = _Mqtt()
|
|
72
|
+
self._mqtt_client.on_connect = self._mqtt_on_connect
|
|
73
|
+
self._mqtt_client.on_message = self._mqtt_on_message
|
|
74
|
+
self._mqtt_client.on_disconnect = self._mqtt_on_disconnect
|
|
51
75
|
if self._mqtt_ssl:
|
|
52
|
-
|
|
76
|
+
self._mqtt_client.tls_set()
|
|
77
|
+
|
|
53
78
|
self._mqtt_password = rriot.s
|
|
54
79
|
self._hashed_password = md5hex(self._mqtt_password + ":" + rriot.k)[16:]
|
|
55
|
-
|
|
56
|
-
self._endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode()
|
|
80
|
+
self._mqtt_client.username_pw_set(self._hashed_user, self._hashed_password)
|
|
57
81
|
self._waiting_queue: dict[int, RoborockFuture] = {}
|
|
58
82
|
self._mutex = Lock()
|
|
59
|
-
self.update_client_id()
|
|
60
83
|
|
|
61
|
-
def
|
|
84
|
+
def _mqtt_on_connect(self, *args, **kwargs):
|
|
62
85
|
_, __, ___, rc, ____ = args
|
|
63
86
|
connection_queue = self._waiting_queue.get(CONNECT_REQUEST_ID)
|
|
64
87
|
if rc != mqtt.MQTT_ERR_SUCCESS:
|
|
65
88
|
message = f"Failed to connect ({mqtt.error_string(rc)})"
|
|
66
89
|
self._logger.error(message)
|
|
67
90
|
if connection_queue:
|
|
68
|
-
connection_queue.
|
|
91
|
+
connection_queue.set_exception(VacuumError(message))
|
|
69
92
|
return
|
|
70
93
|
self._logger.info(f"Connected to mqtt {self._mqtt_host}:{self._mqtt_port}")
|
|
71
94
|
topic = f"rr/m/o/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}"
|
|
72
|
-
(result, mid) = self.subscribe(topic)
|
|
95
|
+
(result, mid) = self._mqtt_client.subscribe(topic)
|
|
73
96
|
if result != 0:
|
|
74
97
|
message = f"Failed to subscribe ({mqtt.error_string(rc)})"
|
|
75
98
|
self._logger.error(message)
|
|
76
99
|
if connection_queue:
|
|
77
|
-
connection_queue.
|
|
100
|
+
connection_queue.set_exception(VacuumError(message))
|
|
78
101
|
return
|
|
79
102
|
self._logger.info(f"Subscribed to topic {topic}")
|
|
80
103
|
if connection_queue:
|
|
81
|
-
connection_queue.
|
|
104
|
+
connection_queue.set_result(True)
|
|
82
105
|
|
|
83
|
-
def
|
|
106
|
+
def _mqtt_on_message(self, *args, **kwargs):
|
|
84
107
|
client, __, msg = args
|
|
85
108
|
try:
|
|
86
109
|
messages, _ = MessageParser.parse(msg.payload, local_key=self.device_info.device.local_key)
|
|
@@ -88,93 +111,74 @@ class RoborockMqttClient(RoborockClient, mqtt.Client):
|
|
|
88
111
|
except Exception as ex:
|
|
89
112
|
self._logger.exception(ex)
|
|
90
113
|
|
|
91
|
-
def
|
|
114
|
+
def _mqtt_on_disconnect(self, *args, **kwargs):
|
|
92
115
|
_, __, rc, ___ = args
|
|
93
116
|
try:
|
|
94
117
|
exc = RoborockException(mqtt.error_string(rc)) if rc != mqtt.MQTT_ERR_SUCCESS else None
|
|
95
118
|
super().on_connection_lost(exc)
|
|
96
119
|
if rc == mqtt.MQTT_ERR_PROTOCOL:
|
|
97
|
-
self.
|
|
120
|
+
self._mqtt_client.reset_client_id()
|
|
98
121
|
connection_queue = self._waiting_queue.get(DISCONNECT_REQUEST_ID)
|
|
99
122
|
if connection_queue:
|
|
100
|
-
connection_queue.
|
|
123
|
+
connection_queue.set_result(True)
|
|
101
124
|
except Exception as ex:
|
|
102
125
|
self._logger.exception(ex)
|
|
103
126
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def sync_stop_loop(self) -> None:
|
|
108
|
-
if self._thread:
|
|
109
|
-
self._logger.info("Stopping mqtt loop")
|
|
110
|
-
super().loop_stop()
|
|
111
|
-
|
|
112
|
-
def sync_start_loop(self) -> None:
|
|
113
|
-
if not self._thread or not self._thread.is_alive():
|
|
114
|
-
self.sync_stop_loop()
|
|
115
|
-
self._logger.info("Starting mqtt loop")
|
|
116
|
-
super().loop_start()
|
|
127
|
+
def is_connected(self) -> bool:
|
|
128
|
+
"""Check if the mqtt client is connected."""
|
|
129
|
+
return self._mqtt_client.is_connected()
|
|
117
130
|
|
|
118
|
-
def sync_disconnect(self) ->
|
|
131
|
+
def sync_disconnect(self) -> Any:
|
|
119
132
|
if not self.is_connected():
|
|
120
|
-
return
|
|
133
|
+
return None
|
|
121
134
|
|
|
122
135
|
self._logger.info("Disconnecting from mqtt")
|
|
123
|
-
disconnected_future =
|
|
124
|
-
rc =
|
|
136
|
+
disconnected_future = self._async_response(DISCONNECT_REQUEST_ID)
|
|
137
|
+
rc = self._mqtt_client.disconnect()
|
|
125
138
|
|
|
126
139
|
if rc == mqtt.MQTT_ERR_NO_CONN:
|
|
127
140
|
disconnected_future.cancel()
|
|
128
|
-
return
|
|
141
|
+
return None
|
|
129
142
|
|
|
130
143
|
if rc != mqtt.MQTT_ERR_SUCCESS:
|
|
131
144
|
disconnected_future.cancel()
|
|
132
145
|
raise RoborockException(f"Failed to disconnect ({mqtt.error_string(rc)})")
|
|
133
146
|
|
|
134
|
-
return
|
|
147
|
+
return disconnected_future
|
|
135
148
|
|
|
136
|
-
def sync_connect(self) ->
|
|
149
|
+
def sync_connect(self) -> Any:
|
|
137
150
|
if self.is_connected():
|
|
138
|
-
self.
|
|
139
|
-
return
|
|
151
|
+
self._mqtt_client.maybe_restart_loop()
|
|
152
|
+
return None
|
|
140
153
|
|
|
141
154
|
if self._mqtt_port is None or self._mqtt_host is None:
|
|
142
155
|
raise RoborockException("Mqtt information was not entered. Cannot connect.")
|
|
143
156
|
|
|
144
157
|
self._logger.debug("Connecting to mqtt")
|
|
145
|
-
connected_future =
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return True, connected_future
|
|
158
|
+
connected_future = self._async_response(CONNECT_REQUEST_ID)
|
|
159
|
+
self._mqtt_client.connect(host=self._mqtt_host, port=self._mqtt_port, keepalive=KEEPALIVE)
|
|
160
|
+
self._mqtt_client.maybe_restart_loop()
|
|
161
|
+
return connected_future
|
|
150
162
|
|
|
151
163
|
async def async_disconnect(self) -> None:
|
|
152
164
|
async with self._mutex:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
165
|
+
if disconnected_future := self.sync_disconnect():
|
|
166
|
+
try:
|
|
167
|
+
await disconnected_future
|
|
168
|
+
except VacuumError as err:
|
|
157
169
|
raise RoborockException(err) from err
|
|
158
170
|
|
|
159
171
|
async def async_connect(self) -> None:
|
|
160
172
|
async with self._mutex:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
173
|
+
if connected_future := self.sync_connect():
|
|
174
|
+
try:
|
|
175
|
+
await connected_future
|
|
176
|
+
except VacuumError as err:
|
|
165
177
|
raise RoborockException(err) from err
|
|
166
178
|
|
|
167
179
|
def _send_msg_raw(self, msg: bytes) -> None:
|
|
168
|
-
info = self.publish(
|
|
180
|
+
info = self._mqtt_client.publish(
|
|
181
|
+
f"rr/m/i/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}", msg
|
|
182
|
+
)
|
|
169
183
|
if info.rc != mqtt.MQTT_ERR_SUCCESS:
|
|
170
184
|
raise RoborockException(f"Failed to publish ({mqtt.error_string(info.rc)})")
|
|
171
|
-
|
|
172
|
-
async def send_message(self, roborock_message: RoborockMessage):
|
|
173
|
-
raise NotImplementedError
|
|
174
|
-
|
|
175
|
-
async def _send_command(
|
|
176
|
-
self,
|
|
177
|
-
method: RoborockCommand | str,
|
|
178
|
-
params: list | dict | int | None = None,
|
|
179
|
-
):
|
|
180
|
-
raise NotImplementedError
|