Skip to content

Commit 99dd479

Browse files
authored
feat: add l01 discovery (#462)
* feat: add l01 discovery * fix: wrong package * fix: missing code
1 parent 887e914 commit 99dd479

File tree

4 files changed

+83
-9
lines changed

4 files changed

+83
-9
lines changed

roborock/broadcast_protocol.py

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import hashlib
45
import json
56
import logging
67
from asyncio import BaseTransport, Lock
78

89
from construct import ( # type: ignore
910
Bytes,
1011
Checksum,
12+
GreedyBytes,
1113
Int16ub,
1214
Int32ub,
15+
Prefixed,
1316
RawCopy,
1417
Struct,
1518
)
19+
from Crypto.Cipher import AES
1620

21+
from roborock import RoborockException
1722
from roborock.containers import BroadcastMessage
1823
from roborock.protocol import EncryptionAdapter, Utils, _Parser
1924

@@ -29,14 +34,41 @@ def __init__(self, timeout: int = 5):
2934
self.devices_found: list[BroadcastMessage] = []
3035
self._mutex = Lock()
3136

32-
def datagram_received(self, data, _):
33-
[broadcast_message], _ = BroadcastParser.parse(data)
34-
if broadcast_message.payload:
35-
parsed_message = BroadcastMessage.from_dict(json.loads(broadcast_message.payload))
36-
_LOGGER.debug(f"Received broadcast: {parsed_message}")
37-
self.devices_found.append(parsed_message)
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:]
3848

39-
async def discover(self):
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]:
4072
async with self._mutex:
4173
try:
4274
loop = asyncio.get_event_loop()
@@ -64,5 +96,19 @@ def close(self):
6496
"checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data),
6597
)
6698

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+
67112

68113
BroadcastParser: _Parser = _Parser(_BroadcastMessage, False)
114+
L01Parser: _Parser = _Parser(_L01BroadcastMessage, False)

roborock/containers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,7 @@ class FlowLedStatus(RoborockBase):
783783
class BroadcastMessage(RoborockBase):
784784
duid: str
785785
ip: str
786+
version: bytes
786787

787788

788789
class ServerTimer(NamedTuple):

roborock/protocol.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,10 @@ def parse(self, data: bytes, local_key: str | None = None) -> tuple[list[Roboroc
304304
messages.append(
305305
RoborockMessage(
306306
version=message.message.value.version,
307-
seq=message.message.value.seq,
307+
seq=message.message.value.get("seq"),
308308
random=message.message.value.get("random"),
309309
timestamp=message.message.value.get("timestamp"),
310-
protocol=message.message.value.protocol,
310+
protocol=message.message.value.get("protocol"),
311311
payload=message.message.value.payload,
312312
)
313313
)

tests/test_broadcast_protocol.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from roborock.broadcast_protocol import RoborockProtocol
2+
3+
4+
def test_l01_data():
5+
data = bytes.fromhex(
6+
"4c30310000000000000043841496d5a31e34b5b02c1867c445509ba5a21aec1fa4b307bddeb27a75d9b366193e8a97d0534dc39851c"
7+
"980609f2670cdcaee04594ec5c93e3c5ae609b0c9a203139ac8e40c8c"
8+
)
9+
prot = RoborockProtocol()
10+
prot.datagram_received(data, None)
11+
device = prot.devices_found[0]
12+
assert device.duid == "ZrQn1jfZtJQLoPOL7620e"
13+
assert device.ip == "192.168.1.4"
14+
assert device.version == b"L01"
15+
16+
17+
def test_v1_data():
18+
data = bytes.fromhex(
19+
"312e30000003e003e80040b87035058b439f36af42f249605f8661897173f111bb849a6231831f5874a0cf220a25872ea412d796b4902ee"
20+
"57fdc120074b901b482acb1fe6d06317e3a72ddac654fe0"
21+
)
22+
prot = RoborockProtocol()
23+
prot.datagram_received(data, None)
24+
device = prot.devices_found[0]
25+
assert device.duid == "h96rOV3e8DTPMAOLiypREl"
26+
assert device.ip == "192.168.20.250"
27+
assert device.version == b"1.0"

0 commit comments

Comments
 (0)