Skip to content

Commit 0a9227e

Browse files
committed
chore: init commit
1 parent 43694ea commit 0a9227e

File tree

5 files changed

+437
-0
lines changed

5 files changed

+437
-0
lines changed

decode.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import json
2+
3+
from roborock.protocol import MessageParser
4+
5+
LOCAL_KEY = "0geZKM8gZkySDz8O"
6+
7+
STORAGE = {"methods": {}}
8+
9+
10+
def compare_dicts(dict1, dict2):
11+
changed_vars = {}
12+
# print(dict1)
13+
# print(dict2)
14+
try:
15+
IGNORE_KEYs = {"msg_seq", "id"}
16+
if isinstance(dict1, dict) and isinstance(dict2, dict):
17+
for key, value in dict1.items():
18+
if key not in IGNORE_KEYs:
19+
if dict2.get(key) != value:
20+
print(f"Status change: {key} changed to {dict2.get(key)}")
21+
else:
22+
if dict1 != dict2:
23+
print(f"{dict1} != {dict2}")
24+
except Exception:
25+
print(dict1)
26+
print(dict2)
27+
28+
29+
def decode(message):
30+
global STORAGE
31+
parsed_message = MessageParser.parse(message, LOCAL_KEY)
32+
if parsed_message[0]:
33+
if parsed_message[0][0]:
34+
payload = parsed_message[0][0]
35+
if b"abc" in payload.payload:
36+
print("map")
37+
return "Map update"
38+
json_payload = json.loads(payload.payload.decode())
39+
print(json_payload)
40+
# print(json_payload)
41+
data_point_number, data_point = list(json_payload.get("dps").items())[0]
42+
method = payload.get_method()
43+
if isinstance(data_point, str):
44+
data_point_response = json.loads(data_point)
45+
46+
params = data_point_response.get("params")
47+
result = data_point_response.get("result")
48+
dp_id = data_point_response.get("id")
49+
else:
50+
dp_id = None
51+
params = None
52+
result = None
53+
dumped_result = None
54+
if result is not None:
55+
dumped_result = json.dumps(result, indent=4)
56+
# print(result)
57+
dumped_result = f"Result: \n{dumped_result}\n" if dumped_result else ""
58+
final_response = (
59+
f"Protocol: {parsed_message[0][0].protocol}\n"
60+
f"Method: {method}\n"
61+
f"Params: {params}\n"
62+
f"{dumped_result}"
63+
f"DPS: {data_point_number}\n"
64+
f"ID: {dp_id}\n"
65+
)
66+
response_dict = {
67+
"method": method,
68+
"params": params,
69+
"result": result,
70+
"dps": data_point_number,
71+
"id": dp_id,
72+
}
73+
# if method != "get_prop":
74+
# print(response_dict)
75+
if dp_id not in STORAGE:
76+
STORAGE[dp_id] = {"outgoing": response_dict}
77+
else:
78+
STORAGE[dp_id]["incoming"] = response_dict
79+
method = STORAGE[dp_id]["outgoing"]["method"]
80+
if method != "get_prop" and method != "get_dynamic_map_diff":
81+
print(STORAGE[dp_id])
82+
if method in STORAGE["methods"] and result != ["ok"]:
83+
last_res = STORAGE["methods"][method]
84+
# if result is not None and last_res is not None:
85+
# changes = compare_dicts(last_res[0], result[0])
86+
STORAGE["methods"][method] = result
87+
# if changes:
88+
# print(changes)
89+
# else:
90+
# print("No changes")
91+
# print(last_res)
92+
# print(result)
93+
if result != ["ok"]:
94+
STORAGE["methods"][method] = result
95+
else:
96+
print(result)
97+
return final_response
98+
return parsed_message
99+
100+
101+
from mitmproxy import contentviews
102+
from mitmproxy.addonmanager import Loader
103+
from mitmproxy.contentviews import base, mqtt
104+
from mitmproxy.utils import strutils
105+
106+
107+
class RoborockControlPacket(mqtt.MQTTControlPacket):
108+
def __init__(self, packet):
109+
super().__init__(packet)
110+
111+
def pprint(self):
112+
s = f"[{self.Names[self.packet_type]}]"
113+
if self.packet_type == self.PUBLISH:
114+
if not self.payload:
115+
return "Empty payload"
116+
topic_name = strutils.bytes_to_escaped_str(self.topic_name)
117+
payload = strutils.bytes_to_escaped_str(self.payload)
118+
try:
119+
payload = decode(self.payload)
120+
121+
except Exception as ex:
122+
raise ex
123+
s += f" {payload} \n" f"Topic: '{topic_name}'"
124+
return s
125+
else:
126+
return super().pprint()
127+
128+
129+
class Roborock(mqtt.ViewMQTT):
130+
name = "Roborock"
131+
132+
def __call__(self, data, **metadata):
133+
mqtt_packet = RoborockControlPacket(data)
134+
text = mqtt_packet.pprint()
135+
return "Roborock", base.format_text(text)
136+
137+
138+
view = Roborock()
139+
140+
141+
def load(loader: Loader):
142+
contentviews.add(view)
143+
144+
145+
def tcp_message(flow):
146+
message = flow.messages[-1]
147+
if b"rr/m/" in message.content:
148+
RoborockControlPacket(message.content).pprint()
149+
150+
151+
def done():
152+
contentviews.remove(view)

reverse_engineerer.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import asyncio
2+
import subprocess
3+
4+
from roborock.web_api import RoborockApiClient
5+
6+
7+
class ReverseEngineerer:
8+
def __init__(self, username: str, password: str):
9+
self.username = username
10+
self.password = password
11+
12+
async def setup(self):
13+
web_api = RoborockApiClient(username=self.username)
14+
user_data = await web_api.pass_login(self.password)
15+
home_data = await web_api.get_home_data_v2(user_data)
16+
17+
print()
18+
device_selection = ""
19+
for i, device in enumerate(home_data.devices):
20+
device_selection += f"{i}) {device.name}\n"
21+
selected_id = input("Which device would you like to work with? Please select the number.\n" + device_selection)
22+
23+
device = home_data.devices[int(selected_id)]
24+
local_key = device.local_key
25+
print(f"Local key is: {local_key}")
26+
with open("key.txt", "w") as f:
27+
f.write(local_key)
28+
print("Running mitmproxy...")
29+
subprocess.run(["mitmweb", "--mode", "wireguard", "-s", "decode.py", "-q"])
30+
31+
32+
re = ReverseEngineerer("conway220@gmail.com", "1h!M7yb29mX")
33+
asyncio.run(re.setup())

reverse_engineering.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Reverse Engineering
2+
My hope with this guide is that contributing to this project becomes easier for those not familiar with reverse engineering.
3+
To start, download this repository by cloning it or just downloading the zip in github.
4+
5+
## Pre-requisites
6+
1) You'll need python on your system.
7+
2) You need to download [mitm](https://mitmproxy.org/)
8+
3) You need python-roborock accessible wherever you run this code
9+
```bash
10+
pip install python-roborock
11+
```
12+
4) I have tested this with a iPhone, it will likely work on Android, but i cannot be 100% sure.
13+
5) Your computer and phone should be on the same WiFi network as your vacuum
14+
6) You need the WireGuard app on your phone.
15+
16+
## Getting Started.
17+
1) Add your username and password to reverse_engineerer.py
18+
2) Run the code
19+
3) Select the device you want to work with
20+
4) open the Wireguard app and scan the QR code that was opened in the web browser that opened after selecting your device.
21+
5) Navigate to mitm.it on your phones browser and follow the instructions there to install the certificate.
22+
6)

roborock/mqtt_manager.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import dataclasses
5+
import logging
6+
from collections.abc import Coroutine
7+
from typing import Callable, Self
8+
from urllib.parse import urlparse
9+
10+
import aiomqtt
11+
from aiomqtt import TLSParameters
12+
13+
from roborock import RoborockException, UserData
14+
from roborock.protocol import MessageParser, md5hex
15+
16+
from .containers import DeviceData
17+
18+
LOGGER = logging.getLogger(__name__)
19+
20+
21+
@dataclasses.dataclass
22+
class ClientWrapper:
23+
publish_function: Coroutine[None]
24+
unsubscribe_function: Coroutine[None]
25+
subscribe_function: Coroutine[None]
26+
27+
28+
class RoborockMqttManager:
29+
client_wrappers: dict[str, ClientWrapper] = {}
30+
_instance: Self = None
31+
32+
def __new__(cls) -> RoborockMqttManager:
33+
if cls._instance is None:
34+
cls._instance = super().__new__(cls)
35+
return cls._instance
36+
37+
async def connect(self, user_data: UserData):
38+
# Add some kind of lock so we don't try to connect if we are already trying to connect the same account.
39+
if user_data.rriot.u not in self.client_wrappers:
40+
loop = asyncio.get_event_loop()
41+
loop.create_task(self._new_connect(user_data))
42+
43+
async def _new_connect(self, user_data: UserData):
44+
rriot = user_data.rriot
45+
mqtt_user = rriot.u
46+
hashed_user = md5hex(mqtt_user + ":" + rriot.k)[2:10]
47+
url = urlparse(rriot.r.m)
48+
if not isinstance(url.hostname, str):
49+
raise RoborockException("Url parsing returned an invalid hostname")
50+
mqtt_host = str(url.hostname)
51+
mqtt_port = url.port
52+
53+
mqtt_password = rriot.s
54+
hashed_password = md5hex(mqtt_password + ":" + rriot.k)[16:]
55+
LOGGER.debug("Connecting to %s for %s", mqtt_host, mqtt_user)
56+
57+
async with aiomqtt.Client(
58+
hostname=mqtt_host,
59+
port=mqtt_port,
60+
username=hashed_user,
61+
password=hashed_password,
62+
keepalive=60,
63+
tls_params=TLSParameters(),
64+
) as client:
65+
# TODO: Handle logic for when client loses connection
66+
LOGGER.info("Connected to %s for %s", mqtt_host, mqtt_user)
67+
callbacks: dict[str, Callable] = {}
68+
device_map = {}
69+
70+
async def publish(device: DeviceData, payload: bytes):
71+
await client.publish(f"rr/m/i/{mqtt_user}/{hashed_user}/{device.device.duid}", payload=payload)
72+
73+
async def subscribe(device: DeviceData, callback):
74+
LOGGER.debug(f"Subscribing to rr/m/o/{mqtt_user}/{hashed_user}/{device.device.duid}")
75+
await client.subscribe(f"rr/m/o/{mqtt_user}/{hashed_user}/{device.device.duid}")
76+
LOGGER.debug(f"Subscribed to rr/m/o/{mqtt_user}/{hashed_user}/{device.device.duid}")
77+
callbacks[device.device.duid] = callback
78+
device_map[device.device.duid] = device
79+
return
80+
81+
async def unsubscribe(device: DeviceData):
82+
await client.unsubscribe(f"rr/m/o/{mqtt_user}/{hashed_user}/{device.device.duid}")
83+
84+
self.client_wrappers[user_data.rriot.u] = ClientWrapper(
85+
publish_function=publish, unsubscribe_function=unsubscribe, subscribe_function=subscribe
86+
)
87+
async for message in client.messages:
88+
try:
89+
device_id = message.topic.value.split("/")[-1]
90+
device = device_map[device_id]
91+
message = MessageParser.parse(message.payload, device.device.local_key)
92+
callbacks[device_id](message)
93+
except Exception:
94+
...
95+
96+
async def disconnect(self, user_data: UserData):
97+
await self.client_wrappers[user_data.rriot.u].disconnect()
98+
99+
async def subscribe(self, user_data: UserData, device: DeviceData, callback):
100+
if user_data.rriot.u not in self.client_wrappers:
101+
await self.connect(user_data)
102+
# add some kind of lock to make sure we don't subscribe until the connection is successful
103+
await asyncio.sleep(2)
104+
await self.client_wrappers[user_data.rriot.u].subscribe_function(device, callback)
105+
106+
async def unsubscribe(self):
107+
pass
108+
109+
async def publish(self, user_data: UserData, device, payload: bytes):
110+
LOGGER.debug("Publishing topic for %s, Message: %s", device.device.duid, payload)
111+
if user_data.rriot.u not in self.client_wrappers:
112+
await self.connect(user_data)
113+
await self.client_wrappers[user_data.rriot.u].publish_function(device, payload)

0 commit comments

Comments
 (0)