Skip to content

Commit 5a32932

Browse files
Feat/single device api (#45)
* feat: using api for single device and adding new commands * fix: using single device api (cherry picked from commit e689e8d) * chore: linting (cherry picked from commit 2ed367c) * chore: linting (cherry picked from commit 58b4683) * chore: linting * chore: linting * feat: adding discover * fix: discover timeout * chore: linting * chore: linting * fix: cli and add construct to pyproject.toml * chore: linting * chore: linting * chore: linting * chore: linting
1 parent 927e7ea commit 5a32932

File tree

12 files changed

+659
-419
lines changed

12 files changed

+659
-419
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pycryptodome = "~3.17.0"
2727
pycryptodomex = {version = "~3.17.0", markers = "sys_platform == 'darwin'"}
2828
paho-mqtt = "~1.6.1"
2929
dacite = "~1.8.0"
30+
construct = "^2.10.68"
3031

3132

3233
[build-system]

roborock/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
from roborock.code_mappings import *
44
from roborock.containers import *
55
from roborock.exceptions import *
6-
from roborock.typing import *
6+
from roborock.roborock_typing import *

roborock/api.py

Lines changed: 76 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import struct
1515
import time
1616
from random import randint
17-
from typing import Any, Callable, Coroutine, Mapping, Optional
17+
from typing import Any, Callable, Coroutine, Optional
1818

1919
import aiohttp
2020
from Crypto.Cipher import AES
@@ -48,13 +48,13 @@
4848
)
4949
from .roborock_future import RoborockFuture
5050
from .roborock_message import RoborockMessage
51-
from .typing import DeviceProp, DockSummary, RoborockCommand
51+
from .roborock_typing import DeviceProp, DockSummary, RoborockCommand
5252
from .util import unpack_list
5353

5454
_LOGGER = logging.getLogger(__name__)
5555
KEEPALIVE = 60
5656
QUEUE_TIMEOUT = 4
57-
SPECIAL_COMMANDS = [
57+
COMMANDS_SECURED = [
5858
RoborockCommand.GET_MAP_V1,
5959
]
6060

@@ -85,15 +85,18 @@ async def request(self, method: str, url: str, params=None, data=None, headers=N
8585

8686

8787
class RoborockClient:
88-
def __init__(self, endpoint: str, devices_info: Mapping[str, RoborockDeviceInfo]) -> None:
89-
self.devices_info = devices_info
88+
def __init__(self, endpoint: str, device_info: RoborockDeviceInfo) -> None:
89+
self.device_info = device_info
9090
self._endpoint = endpoint
9191
self._nonce = secrets.token_bytes(16)
9292
self._waiting_queue: dict[int, RoborockFuture] = {}
9393
self._last_device_msg_in = self.time_func()
9494
self._last_disconnection = self.time_func()
9595
self.keep_alive = KEEPALIVE
9696

97+
def __del__(self) -> None:
98+
self.sync_disconnect()
99+
97100
@property
98101
def time_func(self) -> Callable[[], float]:
99102
try:
@@ -103,10 +106,16 @@ def time_func(self) -> Callable[[], float]:
103106
time_func = time.time
104107
return time_func
105108

109+
async def async_connect(self):
110+
raise NotImplementedError
111+
112+
def sync_disconnect(self) -> Any:
113+
raise NotImplementedError
114+
106115
async def async_disconnect(self) -> Any:
107116
raise NotImplementedError
108117

109-
def on_message(self, messages: list[RoborockMessage]) -> None:
118+
def on_message_received(self, messages: list[RoborockMessage]) -> None:
110119
try:
111120
self._last_device_msg_in = self.time_func()
112121
for data in messages:
@@ -118,24 +127,23 @@ def on_message(self, messages: list[RoborockMessage]) -> None:
118127
data_point_response = json.loads(data_point)
119128
request_id = data_point_response.get("id")
120129
queue = self._waiting_queue.get(request_id)
121-
if queue:
122-
if queue.protocol == protocol:
123-
error = data_point_response.get("error")
124-
if error:
125-
queue.resolve(
126-
(
127-
None,
128-
VacuumError(
129-
error.get("code"),
130-
error.get("message"),
131-
),
132-
)
130+
if queue and queue.protocol == protocol:
131+
error = data_point_response.get("error")
132+
if error:
133+
queue.resolve(
134+
(
135+
None,
136+
VacuumError(
137+
error.get("code"),
138+
error.get("message"),
139+
),
133140
)
134-
else:
135-
result = data_point_response.get("result")
136-
if isinstance(result, list) and len(result) == 1:
137-
result = result[0]
138-
queue.resolve((result, None))
141+
)
142+
else:
143+
result = data_point_response.get("result")
144+
if isinstance(result, list) and len(result) == 1:
145+
result = result[0]
146+
queue.resolve((result, None))
139147
elif protocol == 301:
140148
payload = data.payload[0:24]
141149
[endpoint, _, request_id, _] = struct.unpack("<15sBH6s", payload)
@@ -149,10 +157,14 @@ def on_message(self, messages: list[RoborockMessage]) -> None:
149157
if isinstance(decrypted, list):
150158
decrypted = decrypted[0]
151159
queue.resolve((decrypted, None))
160+
else:
161+
queue = self._waiting_queue.get(data.seq)
162+
if queue:
163+
queue.resolve((data.payload, None))
152164
except Exception as ex:
153165
_LOGGER.exception(ex)
154166

155-
def on_disconnect(self, exc: Optional[Exception]) -> None:
167+
def on_connection_lost(self, exc: Optional[Exception]) -> None:
156168
self._last_disconnection = self.time_func()
157169
_LOGGER.warning("Roborock client disconnected")
158170
if exc is not None:
@@ -165,6 +177,11 @@ def should_keepalive(self) -> bool:
165177
return False
166178
return True
167179

180+
async def validate_connection(self) -> None:
181+
if not self.should_keepalive():
182+
await self.async_disconnect()
183+
await self.async_connect()
184+
168185
async def _async_response(self, request_id: int, protocol_id: int = 0) -> tuple[Any, VacuumError | None]:
169186
try:
170187
queue = RoborockFuture(protocol_id)
@@ -176,7 +193,7 @@ async def _async_response(self, request_id: int, protocol_id: int = 0) -> tuple[
176193
finally:
177194
del self._waiting_queue[request_id]
178195

179-
def _get_payload(self, method: RoborockCommand, params: Optional[list] = None, secured=False):
196+
def _get_payload(self, method: RoborockCommand, params: Optional[list | dict] = None, secured=False):
180197
timestamp = math.floor(time.time())
181198
request_id = randint(10000, 99999)
182199
inner = {
@@ -200,27 +217,27 @@ def _get_payload(self, method: RoborockCommand, params: Optional[list] = None, s
200217
)
201218
return request_id, timestamp, payload
202219

203-
async def send_command(self, device_id: str, method: RoborockCommand, params: Optional[list] = None):
220+
async def send_command(self, method: RoborockCommand, params: Optional[list | dict] = None):
204221
raise NotImplementedError
205222

206-
async def get_status(self, device_id: str) -> Status | None:
207-
status = await self.send_command(device_id, RoborockCommand.GET_STATUS)
223+
async def get_status(self) -> Status | None:
224+
status = await self.send_command(RoborockCommand.GET_STATUS)
208225
if isinstance(status, dict):
209226
return Status.from_dict(status)
210227
return None
211228

212-
async def get_dnd_timer(self, device_id: str) -> DNDTimer | None:
229+
async def get_dnd_timer(self) -> DNDTimer | None:
213230
try:
214-
dnd_timer = await self.send_command(device_id, RoborockCommand.GET_DND_TIMER)
231+
dnd_timer = await self.send_command(RoborockCommand.GET_DND_TIMER)
215232
if isinstance(dnd_timer, dict):
216233
return DNDTimer.from_dict(dnd_timer)
217234
except RoborockTimeout as e:
218235
_LOGGER.error(e)
219236
return None
220237

221-
async def get_clean_summary(self, device_id: str) -> CleanSummary | None:
238+
async def get_clean_summary(self) -> CleanSummary | None:
222239
try:
223-
clean_summary = await self.send_command(device_id, RoborockCommand.GET_CLEAN_SUMMARY)
240+
clean_summary = await self.send_command(RoborockCommand.GET_CLEAN_SUMMARY)
224241
if isinstance(clean_summary, dict):
225242
return CleanSummary.from_dict(clean_summary)
226243
elif isinstance(clean_summary, list):
@@ -232,55 +249,54 @@ async def get_clean_summary(self, device_id: str) -> CleanSummary | None:
232249
_LOGGER.error(e)
233250
return None
234251

235-
async def get_clean_record(self, device_id: str, record_id: int) -> CleanRecord | None:
252+
async def get_clean_record(self, record_id: int) -> CleanRecord | None:
236253
try:
237-
clean_record = await self.send_command(device_id, RoborockCommand.GET_CLEAN_RECORD, [record_id])
254+
clean_record = await self.send_command(RoborockCommand.GET_CLEAN_RECORD, [record_id])
238255
if isinstance(clean_record, dict):
239256
return CleanRecord.from_dict(clean_record)
240257
except RoborockTimeout as e:
241258
_LOGGER.error(e)
242259
return None
243260

244-
async def get_consumable(self, device_id: str) -> Consumable | None:
261+
async def get_consumable(self) -> Consumable | None:
245262
try:
246-
consumable = await self.send_command(device_id, RoborockCommand.GET_CONSUMABLE)
263+
consumable = await self.send_command(RoborockCommand.GET_CONSUMABLE)
247264
if isinstance(consumable, dict):
248265
return Consumable.from_dict(consumable)
249266
except RoborockTimeout as e:
250267
_LOGGER.error(e)
251268
return None
252269

253-
async def get_wash_towel_mode(self, device_id: str) -> WashTowelMode | None:
270+
async def get_wash_towel_mode(self) -> WashTowelMode | None:
254271
try:
255-
washing_mode = await self.send_command(device_id, RoborockCommand.GET_WASH_TOWEL_MODE)
272+
washing_mode = await self.send_command(RoborockCommand.GET_WASH_TOWEL_MODE)
256273
if isinstance(washing_mode, dict):
257274
return WashTowelMode.from_dict(washing_mode)
258275
except RoborockTimeout as e:
259276
_LOGGER.error(e)
260277
return None
261278

262-
async def get_dust_collection_mode(self, device_id: str) -> DustCollectionMode | None:
279+
async def get_dust_collection_mode(self) -> DustCollectionMode | None:
263280
try:
264-
dust_collection = await self.send_command(device_id, RoborockCommand.GET_DUST_COLLECTION_MODE)
281+
dust_collection = await self.send_command(RoborockCommand.GET_DUST_COLLECTION_MODE)
265282
if isinstance(dust_collection, dict):
266283
return DustCollectionMode.from_dict(dust_collection)
267284
except RoborockTimeout as e:
268285
_LOGGER.error(e)
269286
return None
270287

271-
async def get_smart_wash_params(self, device_id: str) -> SmartWashParams | None:
288+
async def get_smart_wash_params(self) -> SmartWashParams | None:
272289
try:
273-
mop_wash_mode = await self.send_command(device_id, RoborockCommand.GET_SMART_WASH_PARAMS)
290+
mop_wash_mode = await self.send_command(RoborockCommand.GET_SMART_WASH_PARAMS)
274291
if isinstance(mop_wash_mode, dict):
275292
return SmartWashParams.from_dict(mop_wash_mode)
276293
except RoborockTimeout as e:
277294
_LOGGER.error(e)
278295
return None
279296

280-
async def get_dock_summary(self, device_id: str, dock_type: RoborockEnum) -> DockSummary | None:
297+
async def get_dock_summary(self, dock_type: RoborockEnum) -> DockSummary | None:
281298
"""Gets the status summary from the dock with the methods available for a given dock.
282299
283-
:param device_id: Device id
284300
:param dock_type: RoborockDockTypeCode"""
285301
try:
286302
commands: list[
@@ -289,11 +305,11 @@ async def get_dock_summary(self, device_id: str, dock_type: RoborockEnum) -> Doc
289305
Any,
290306
DustCollectionMode | WashTowelMode | SmartWashParams | None,
291307
]
292-
] = [self.get_dust_collection_mode(device_id)]
308+
] = [self.get_dust_collection_mode()]
293309
if dock_type == RoborockDockTypeCode["3"]:
294310
commands += [
295-
self.get_wash_towel_mode(device_id),
296-
self.get_smart_wash_params(device_id),
311+
self.get_wash_towel_mode(),
312+
self.get_smart_wash_params(),
297313
]
298314
[dust_collection_mode, wash_towel_mode, smart_wash_params] = unpack_list(
299315
list(await asyncio.gather(*commands)), 3
@@ -304,21 +320,21 @@ async def get_dock_summary(self, device_id: str, dock_type: RoborockEnum) -> Doc
304320
_LOGGER.error(e)
305321
return None
306322

307-
async def get_prop(self, device_id: str) -> DeviceProp | None:
323+
async def get_prop(self) -> DeviceProp | None:
308324
[status, dnd_timer, clean_summary, consumable] = await asyncio.gather(
309325
*[
310-
self.get_status(device_id),
311-
self.get_dnd_timer(device_id),
312-
self.get_clean_summary(device_id),
313-
self.get_consumable(device_id),
326+
self.get_status(),
327+
self.get_dnd_timer(),
328+
self.get_clean_summary(),
329+
self.get_consumable(),
314330
]
315331
)
316332
last_clean_record = None
317333
if clean_summary and clean_summary.records and len(clean_summary.records) > 0:
318-
last_clean_record = await self.get_clean_record(device_id, clean_summary.records[0])
334+
last_clean_record = await self.get_clean_record(clean_summary.records[0])
319335
dock_summary = None
320336
if status and status.dock_type is not None and status.dock_type != RoborockDockTypeCode["0"]:
321-
dock_summary = await self.get_dock_summary(device_id, status.dock_type)
337+
dock_summary = await self.get_dock_summary(status.dock_type)
322338
if any([status, dnd_timer, clean_summary, consumable]):
323339
return DeviceProp(
324340
status,
@@ -330,27 +346,27 @@ async def get_prop(self, device_id: str) -> DeviceProp | None:
330346
)
331347
return None
332348

333-
async def get_multi_maps_list(self, device_id) -> MultiMapsList | None:
349+
async def get_multi_maps_list(self) -> MultiMapsList | None:
334350
try:
335-
multi_maps_list = await self.send_command(device_id, RoborockCommand.GET_MULTI_MAPS_LIST)
351+
multi_maps_list = await self.send_command(RoborockCommand.GET_MULTI_MAPS_LIST)
336352
if isinstance(multi_maps_list, dict):
337353
return MultiMapsList.from_dict(multi_maps_list)
338354
except RoborockTimeout as e:
339355
_LOGGER.error(e)
340356
return None
341357

342-
async def get_networking(self, device_id) -> NetworkInfo | None:
358+
async def get_networking(self) -> NetworkInfo | None:
343359
try:
344-
networking_info = await self.send_command(device_id, RoborockCommand.GET_NETWORK_INFO)
360+
networking_info = await self.send_command(RoborockCommand.GET_NETWORK_INFO)
345361
if isinstance(networking_info, dict):
346362
return NetworkInfo.from_dict(networking_info)
347363
except RoborockTimeout as e:
348364
_LOGGER.error(e)
349365
return None
350366

351-
async def get_room_mapping(self, device_id: str) -> list[RoomMapping]:
367+
async def get_room_mapping(self) -> list[RoomMapping]:
352368
"""Gets the mapping from segment id -> iot id. Only works on local api."""
353-
mapping = await self.send_command(device_id, RoborockCommand.GET_ROOM_MAPPING)
369+
mapping = await self.send_command(RoborockCommand.GET_ROOM_MAPPING)
354370
if isinstance(mapping, list):
355371
return [
356372
RoomMapping(segment_id=segment_id, iot_id=iot_id) # type: ignore

roborock/cli.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,17 @@ async def login(ctx, email, password):
7171
pass
7272
client = RoborockApiClient(email)
7373
user_data = await client.pass_login(password)
74-
context.update(LoginData({"user_data": user_data, "email": email}))
74+
context.update(LoginData(user_data=user_data, email=email))
7575

7676

7777
async def _discover(ctx):
7878
context: RoborockContext = ctx.obj
7979
login_data = context.login_data()
80+
if not login_data:
81+
raise Exception("You need to login first")
8082
client = RoborockApiClient(login_data.email)
8183
home_data = await client.get_home_data(login_data.user_data)
82-
context.update(LoginData({**login_data, "home_data": home_data}))
84+
context.update(LoginData(**login_data.as_dict(), home_data=home_data))
8385
click.echo(
8486
f"Discovered devices {', '.join([device.name for device in home_data.devices + home_data.received_devices])}"
8587
)
@@ -102,26 +104,30 @@ async def list_devices(ctx):
102104
await _discover(ctx)
103105
login_data = context.login_data()
104106
home_data = login_data.home_data
105-
click.echo(f"Known devices {', '.join([device.name for device in home_data.devices + home_data.received_devices])}")
107+
device_name_id = ", ".join(
108+
[f"{device.name}: {device.duid}" for device in home_data.devices + home_data.received_devices]
109+
)
110+
click.echo(f"Known devices {device_name_id}")
106111

107112

108113
@click.command()
114+
@click.option("--device_id", required=True)
109115
@click.option("--cmd", required=True)
110116
@click.option("--params", required=False)
111117
@click.pass_context
112118
@run_sync()
113-
async def command(ctx, cmd, params):
119+
async def command(ctx, cmd, device_id, params):
114120
context: RoborockContext = ctx.obj
115121
login_data = context.login_data()
116122
if not login_data.home_data:
117123
await _discover(ctx)
118124
login_data = context.login_data()
119125
home_data = login_data.home_data
120-
device_map: dict[str, RoborockDeviceInfo] = {}
121-
for device in home_data.devices + home_data.received_devices:
122-
device_map[device.duid] = RoborockDeviceInfo(device=device)
123-
mqtt_client = RoborockMqttClient(login_data.user_data, device_map)
124-
await mqtt_client.send_command(home_data.devices[0].duid, cmd, params)
126+
devices = home_data.devices + home_data.received_devices
127+
device = next((device for device in devices if device.duid == device_id), None)
128+
device_info = RoborockDeviceInfo(device=device)
129+
mqtt_client = RoborockMqttClient(login_data.user_data, device_info)
130+
await mqtt_client.send_command(cmd, params)
125131
mqtt_client.__del__()
126132

127133

0 commit comments

Comments
 (0)