Skip to content

Commit 1b4926e

Browse files
fix: minor fixes to offline integration
1 parent 22680bf commit 1b4926e

File tree

4 files changed

+165
-132
lines changed

4 files changed

+165
-132
lines changed

offline.py

Lines changed: 0 additions & 132 deletions
This file was deleted.

roborock/offline/__init__.py

Whitespace-only changes.

roborock/offline/offline.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import asyncio
2+
import base64
3+
import binascii
4+
import json
5+
import math
6+
import secrets
7+
import struct
8+
import time
9+
10+
from Crypto.Cipher import AES
11+
from Crypto.Util.Padding import unpad, pad
12+
13+
from roborock import RoborockException
14+
from roborock.api import md5bin, encode_timestamp
15+
from roborock.offline.socket_listener import RoborockSocketListener
16+
from roborock.typing import RoborockCommand
17+
18+
local_ip = "<device_ip>"
19+
local_key = "<device_localkey>"
20+
21+
salt = "TXdfu$jyZ#TZHsg4"
22+
seq = 1
23+
random = 4711
24+
request_id = 10000
25+
counter = 1
26+
endpoint = "abc"
27+
nonce = secrets.token_bytes(16).hex().upper()
28+
29+
secured_prefix = 199
30+
get_prefix = 119
31+
app_prefix = 135
32+
set_prefix = 151
33+
34+
35+
async def send_command(
36+
method: RoborockCommand, params: list = None
37+
):
38+
timestamp = math.floor(time.time())
39+
_request_id = request_id + 1
40+
inner = {
41+
"id": _request_id,
42+
"method": method,
43+
"params": params or [],
44+
"security": {
45+
"endpoint": endpoint,
46+
"nonce": nonce,
47+
}
48+
}
49+
payload = json.dumps(
50+
{
51+
"dps": {"101": json.dumps(inner, separators=(",", ":"))},
52+
"t": timestamp,
53+
},
54+
separators=(",", ":"),
55+
).encode()
56+
print(f"id={_request_id} Requesting method {method} with {params}")
57+
_prefix = secured_prefix
58+
return await _send_msg_raw(timestamp, _prefix, payload)
59+
60+
61+
async def _send_msg_raw(timestamp: int, prefix: int, payload: bytes):
62+
protocol = 4
63+
aes_key = md5bin(encode_timestamp(timestamp) + local_key + salt)
64+
cipher = AES.new(aes_key, AES.MODE_ECB)
65+
encrypted = cipher.encrypt(pad(payload, AES.block_size))
66+
encrypted_len = len(encrypted)
67+
msg = struct.pack(
68+
f"!I3sIIIHH{encrypted_len}s",
69+
prefix,
70+
"1.0".encode(),
71+
seq,
72+
random,
73+
timestamp,
74+
protocol,
75+
encrypted_len,
76+
encrypted,
77+
)
78+
crc32 = binascii.crc32(msg[4:])
79+
msg += struct.pack("!I", crc32)
80+
print(f"Requesting with prefix {prefix} and payload {payload}")
81+
# Send the command to the Roborock device
82+
return await listener.send_message(msg)
83+
84+
85+
def _decode_msg(msg):
86+
prefix = int.from_bytes(msg[:4], 'big')
87+
msg = msg[4:]
88+
if msg[0:3] != "1.0".encode():
89+
raise RoborockException("Unknown protocol version")
90+
crc32 = binascii.crc32(msg[: len(msg) - 4])
91+
if len(msg) == 17 or len(msg) == 21:
92+
[version, _seq, _random, timestamp, protocol] = struct.unpack(
93+
"!3sIIIH", msg[0:17]
94+
)
95+
resp1 = {
96+
"version": version,
97+
"timestamp": timestamp,
98+
"protocol": protocol,
99+
}
100+
print(f"Response with prefix {prefix} and payload {resp1}")
101+
return resp1
102+
[version, _seq, _random, timestamp, protocol, payload_len] = struct.unpack(
103+
"!3sIIIHH", msg[0:19]
104+
)
105+
[payload, expected_crc32] = struct.unpack_from(f"!{payload_len}sI", msg, 19)
106+
if crc32 != expected_crc32:
107+
raise RoborockException(f"Wrong CRC32 {crc32}, expected {expected_crc32}")
108+
109+
aes_key = md5bin(encode_timestamp(timestamp) + local_key + salt)
110+
decipher = AES.new(aes_key, AES.MODE_ECB)
111+
decrypted_payload = decipher.decrypt(payload)
112+
if len(decrypted_payload) > 0:
113+
decrypted_payload = unpad(decipher.decrypt(payload), AES.block_size)
114+
resp2 = {
115+
"version": version,
116+
"timestamp": timestamp,
117+
"protocol": protocol,
118+
"payload": decrypted_payload,
119+
}
120+
print(f"Response with prefix {prefix} and payload {resp2}")
121+
return resp2
122+
123+
124+
listener = RoborockSocketListener(local_ip, asyncio.get_event_loop(), _decode_msg)
125+
listener.connect()
126+
127+
128+
async def main():
129+
response = await send_command(RoborockCommand.GET_STATUS)
130+
print(response)
131+
132+
133+
if __name__ == "__main__":
134+
asyncio.run(main())

roborock/offline/socket_listener.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
import socket
4+
import threading
5+
from asyncio import AbstractEventLoop
6+
7+
import async_timeout
8+
9+
from roborock.exceptions import RoborockException
10+
11+
12+
class RoborockSocketListener:
13+
roborock_port = 58867
14+
15+
def __init__(self, ip: str, loop: AbstractEventLoop, on_message):
16+
self.ip = ip
17+
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
18+
self.loop = loop
19+
self.on_message = on_message
20+
21+
def connect(self):
22+
self.socket.connect(("192.168.1.232", 58867))
23+
24+
async def send_message(self, data, timeout: float | int = 4):
25+
response = {}
26+
async with async_timeout.timeout(timeout):
27+
await self.loop.sock_sendall(self.socket, data)
28+
while response.get('protocol') != 4:
29+
message = await self.loop.sock_recv(self.socket, 4096)
30+
response = self.on_message(message)
31+
return response

0 commit comments

Comments
 (0)