Skip to content

Commit a57122d

Browse files
authored
Merge branch 'main' into mqtt_error_handling
2 parents 8360b5c + 420e4ae commit a57122d

35 files changed

+1648
-406
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
runs-on: ubuntu-latest
2626
steps:
2727
- uses: actions/checkout@v5
28-
- uses: actions/setup-python@v5
28+
- uses: actions/setup-python@v6
2929
with:
3030
python-version: "3.11"
3131
- uses: pre-commit/action@v3.0.1
@@ -42,7 +42,7 @@ jobs:
4242
steps:
4343
- uses: actions/checkout@v5
4444
- name: Set up Python
45-
uses: actions/setup-python@v5
45+
uses: actions/setup-python@v6
4646
with:
4747
python-version: ${{ matrix.python-version }}
4848
- uses: snok/install-poetry@v1.4.1
@@ -75,12 +75,12 @@ jobs:
7575
persist-credentials: false
7676
- name: Python Semantic Release
7777
id: release
78-
uses: python-semantic-release/python-semantic-release@v10.3.0
78+
uses: python-semantic-release/python-semantic-release@v10.4.1
7979
with:
8080
github_token: ${{ secrets.GH_TOKEN }}
8181

8282
- name: Publish package distributions to PyPI
83-
uses: pypa/gh-action-pypi-publish@v1.12.4
83+
uses: pypa/gh-action-pypi-publish@v1.13.0
8484

8585
# NOTE: DO NOT wrap the conditional in ${{ }} as it will always evaluate to true.
8686
# See https://github.com/actions/runner/issues/1173

poetry.lock

Lines changed: 88 additions & 68 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "python-roborock"
3-
version = "2.39.0"
3+
version = "2.45.0"
44
description = "A package to control Roborock vacuums."
55
authors = ["humbertogontijo <humbertogontijo@users.noreply.github.com>"]
66
license = "GPL-3.0-only"
@@ -32,6 +32,7 @@ construct = "^2.10.57"
3232
vacuum-map-parser-roborock = "*"
3333
pyrate-limiter = "^3.7.0"
3434
aiomqtt = "^2.3.2"
35+
click-shell = "^2.1"
3536

3637

3738
[build-system]

roborock/broadcast_protocol.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import hashlib
5+
import json
6+
import logging
7+
from asyncio import BaseTransport, Lock
8+
9+
from construct import ( # type: ignore
10+
Bytes,
11+
Checksum,
12+
GreedyBytes,
13+
Int16ub,
14+
Int32ub,
15+
Prefixed,
16+
RawCopy,
17+
Struct,
18+
)
19+
from Crypto.Cipher import AES
20+
21+
from roborock import RoborockException
22+
from roborock.containers import BroadcastMessage
23+
from roborock.protocol import EncryptionAdapter, Utils, _Parser
24+
25+
_LOGGER = logging.getLogger(__name__)
26+
27+
BROADCAST_TOKEN = b"qWKYcdQWrbm9hPqe"
28+
29+
30+
class RoborockProtocol(asyncio.DatagramProtocol):
31+
def __init__(self, timeout: int = 5):
32+
self.timeout = timeout
33+
self.transport: BaseTransport | None = None
34+
self.devices_found: list[BroadcastMessage] = []
35+
self._mutex = Lock()
36+
37+
def datagram_received(self, data: bytes, _):
38+
"""Handle incoming broadcast datagrams."""
39+
try:
40+
version = data[:3]
41+
if version == b"L01":
42+
[parsed_msg], _ = L01Parser.parse(data)
43+
encrypted_payload = parsed_msg.payload
44+
if encrypted_payload is None:
45+
raise RoborockException("No encrypted payload found in broadcast message")
46+
ciphertext = encrypted_payload[:-16]
47+
tag = encrypted_payload[-16:]
48+
49+
key = hashlib.sha256(BROADCAST_TOKEN).digest()
50+
iv_digest_input = data[:9]
51+
digest = hashlib.sha256(iv_digest_input).digest()
52+
iv = digest[:12]
53+
54+
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
55+
decrypted_payload_bytes = cipher.decrypt_and_verify(ciphertext, tag)
56+
json_payload = json.loads(decrypted_payload_bytes)
57+
parsed_message = BroadcastMessage(duid=json_payload["duid"], ip=json_payload["ip"], version=version)
58+
_LOGGER.debug(f"Received L01 broadcast: {parsed_message}")
59+
self.devices_found.append(parsed_message)
60+
else:
61+
# Fallback to the original protocol parser for other versions
62+
[broadcast_message], _ = BroadcastParser.parse(data)
63+
if broadcast_message.payload:
64+
json_payload = json.loads(broadcast_message.payload)
65+
parsed_message = BroadcastMessage(duid=json_payload["duid"], ip=json_payload["ip"], version=version)
66+
_LOGGER.debug(f"Received broadcast: {parsed_message}")
67+
self.devices_found.append(parsed_message)
68+
except Exception as e:
69+
_LOGGER.warning(f"Failed to decode message: {data!r}. Error: {e}")
70+
71+
async def discover(self) -> list[BroadcastMessage]:
72+
async with self._mutex:
73+
try:
74+
loop = asyncio.get_event_loop()
75+
self.transport, _ = await loop.create_datagram_endpoint(lambda: self, local_addr=("0.0.0.0", 58866))
76+
await asyncio.sleep(self.timeout)
77+
return self.devices_found
78+
finally:
79+
self.close()
80+
self.devices_found = []
81+
82+
def close(self):
83+
self.transport.close() if self.transport else None
84+
85+
86+
_BroadcastMessage = Struct(
87+
"message"
88+
/ RawCopy(
89+
Struct(
90+
"version" / Bytes(3),
91+
"seq" / Int32ub,
92+
"protocol" / Int16ub,
93+
"payload" / EncryptionAdapter(lambda ctx: BROADCAST_TOKEN),
94+
)
95+
),
96+
"checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data),
97+
)
98+
99+
_L01BroadcastMessage = Struct(
100+
"message"
101+
/ RawCopy(
102+
Struct(
103+
"version" / Bytes(3),
104+
"field1" / Bytes(4), # Unknown field
105+
"field2" / Bytes(2), # Unknown field
106+
"payload" / Prefixed(Int16ub, GreedyBytes), # Encrypted payload with length prefix
107+
)
108+
),
109+
"checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data),
110+
)
111+
112+
113+
BroadcastParser: _Parser = _Parser(_BroadcastMessage, False)
114+
L01Parser: _Parser = _Parser(_L01BroadcastMessage, False)

0 commit comments

Comments
 (0)