python-roborock 0.39.2__tar.gz → 0.41.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_roborock-0.39.2 → python_roborock-0.41.0}/PKG-INFO +1 -1
- {python_roborock-0.39.2 → python_roborock-0.41.0}/pyproject.toml +2 -2
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/api.py +7 -200
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/cli.py +2 -2
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/cloud_api.py +5 -38
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/local_api.py +4 -18
- python_roborock-0.41.0/roborock/version_1_apis/__init__.py +0 -0
- python_roborock-0.41.0/roborock/version_1_apis/roborock_client_v1.py +227 -0
- python_roborock-0.41.0/roborock/version_1_apis/roborock_local_client_v1.py +32 -0
- python_roborock-0.41.0/roborock/version_1_apis/roborock_mqtt_client_v1.py +72 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/LICENSE +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/README.md +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/__init__.py +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/code_mappings.py +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/command_cache.py +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/const.py +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/containers.py +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/exceptions.py +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/protocol.py +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/py.typed +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/roborock_future.py +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/roborock_message.py +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/util.py +0 -0
- {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/web_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-roborock"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.41.0"
|
|
4
4
|
description = "A package to control Roborock vacuums."
|
|
5
5
|
authors = ["humbertogontijo <humbertogontijo@users.noreply.github.com>"]
|
|
6
6
|
license = "GPL-3.0-only"
|
|
@@ -33,7 +33,7 @@ construct = "^2.10.57"
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
[build-system]
|
|
36
|
-
requires = ["poetry-core==1.
|
|
36
|
+
requires = ["poetry-core==1.8.0"]
|
|
37
37
|
build-backend = "poetry.core.masonry.api"
|
|
38
38
|
|
|
39
39
|
[tool.poetry.group.dev.dependencies]
|
|
@@ -3,40 +3,25 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import base64
|
|
6
7
|
import dataclasses
|
|
7
8
|
import hashlib
|
|
8
9
|
import json
|
|
9
10
|
import logging
|
|
10
|
-
import math
|
|
11
11
|
import secrets
|
|
12
12
|
import struct
|
|
13
13
|
import time
|
|
14
14
|
from collections.abc import Callable, Coroutine
|
|
15
|
-
from random import randint
|
|
16
15
|
from typing import Any, TypeVar, final
|
|
17
16
|
|
|
18
|
-
from .code_mappings import RoborockDockTypeCode
|
|
19
17
|
from .command_cache import CacheableAttribute, CommandType, RoborockAttribute, find_cacheable_attribute, get_cache_map
|
|
20
18
|
from .containers import (
|
|
21
|
-
ChildLockStatus,
|
|
22
|
-
CleanRecord,
|
|
23
|
-
CleanSummary,
|
|
24
19
|
Consumable,
|
|
25
20
|
DeviceData,
|
|
26
|
-
DnDTimer,
|
|
27
|
-
DustCollectionMode,
|
|
28
|
-
FlowLedStatus,
|
|
29
21
|
ModelStatus,
|
|
30
|
-
MultiMapsList,
|
|
31
|
-
NetworkInfo,
|
|
32
22
|
RoborockBase,
|
|
33
|
-
RoomMapping,
|
|
34
23
|
S7MaxVStatus,
|
|
35
|
-
ServerTimer,
|
|
36
|
-
SmartWashParams,
|
|
37
24
|
Status,
|
|
38
|
-
ValleyElectricityTimer,
|
|
39
|
-
WashTowelMode,
|
|
40
25
|
)
|
|
41
26
|
from .exceptions import (
|
|
42
27
|
RoborockException,
|
|
@@ -53,21 +38,12 @@ from .roborock_message import (
|
|
|
53
38
|
RoborockMessage,
|
|
54
39
|
RoborockMessageProtocol,
|
|
55
40
|
)
|
|
56
|
-
from .roborock_typing import
|
|
57
|
-
from .util import RepeatableTask, RoborockLoggerAdapter, get_running_loop_or_create_one
|
|
41
|
+
from .roborock_typing import RoborockCommand
|
|
42
|
+
from .util import RepeatableTask, RoborockLoggerAdapter, get_running_loop_or_create_one
|
|
58
43
|
|
|
59
44
|
_LOGGER = logging.getLogger(__name__)
|
|
60
45
|
KEEPALIVE = 60
|
|
61
|
-
COMMANDS_SECURED = [
|
|
62
|
-
RoborockCommand.GET_MAP_V1,
|
|
63
|
-
RoborockCommand.GET_MULTI_MAP,
|
|
64
|
-
]
|
|
65
46
|
RT = TypeVar("RT", bound=RoborockBase)
|
|
66
|
-
WASH_N_FILL_DOCK = [
|
|
67
|
-
RoborockDockTypeCode.empty_wash_fill_dock,
|
|
68
|
-
RoborockDockTypeCode.s8_dock,
|
|
69
|
-
RoborockDockTypeCode.p10_dock,
|
|
70
|
-
]
|
|
71
47
|
|
|
72
48
|
|
|
73
49
|
def md5hex(message: str) -> str:
|
|
@@ -156,7 +132,9 @@ class RoborockClient:
|
|
|
156
132
|
self._last_device_msg_in = self.time_func()
|
|
157
133
|
self._last_disconnection = self.time_func()
|
|
158
134
|
self.keep_alive = KEEPALIVE
|
|
159
|
-
self._diagnostic_data: dict[str, dict[str, Any]] = {
|
|
135
|
+
self._diagnostic_data: dict[str, dict[str, Any]] = {
|
|
136
|
+
"misc_info": {"Nonce": base64.b64encode(self._nonce).decode("utf-8")}
|
|
137
|
+
}
|
|
160
138
|
self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER)
|
|
161
139
|
self.cache: dict[CacheableAttribute, AttributeCache] = {
|
|
162
140
|
cacheable_attribute: AttributeCache(attr, self) for cacheable_attribute, attr in get_cache_map().items()
|
|
@@ -283,7 +261,7 @@ class RoborockClient:
|
|
|
283
261
|
try:
|
|
284
262
|
decrypted = Utils.decrypt_cbc(data.payload[24:], self._nonce)
|
|
285
263
|
except ValueError as err:
|
|
286
|
-
raise RoborockException("Failed to decode
|
|
264
|
+
raise RoborockException(f"Failed to decode {data.payload!r} for {data.protocol}") from err
|
|
287
265
|
decompressed = Utils.decompress(decrypted)
|
|
288
266
|
queue = self._waiting_queue.get(request_id)
|
|
289
267
|
if queue:
|
|
@@ -333,35 +311,6 @@ class RoborockClient:
|
|
|
333
311
|
self._waiting_queue[request_id] = queue
|
|
334
312
|
return self._wait_response(request_id, queue)
|
|
335
313
|
|
|
336
|
-
def _get_payload(
|
|
337
|
-
self,
|
|
338
|
-
method: RoborockCommand | str,
|
|
339
|
-
params: list | dict | int | None = None,
|
|
340
|
-
secured=False,
|
|
341
|
-
):
|
|
342
|
-
timestamp = math.floor(time.time())
|
|
343
|
-
request_id = randint(10000, 32767)
|
|
344
|
-
inner = {
|
|
345
|
-
"id": request_id,
|
|
346
|
-
"method": method,
|
|
347
|
-
"params": params or [],
|
|
348
|
-
}
|
|
349
|
-
if secured:
|
|
350
|
-
inner["security"] = {
|
|
351
|
-
"endpoint": self._endpoint,
|
|
352
|
-
"nonce": self._nonce.hex().lower(),
|
|
353
|
-
}
|
|
354
|
-
payload = bytes(
|
|
355
|
-
json.dumps(
|
|
356
|
-
{
|
|
357
|
-
"dps": {"101": json.dumps(inner, separators=(",", ":"))},
|
|
358
|
-
"t": timestamp,
|
|
359
|
-
},
|
|
360
|
-
separators=(",", ":"),
|
|
361
|
-
).encode()
|
|
362
|
-
)
|
|
363
|
-
return request_id, timestamp, payload
|
|
364
|
-
|
|
365
314
|
async def send_message(self, roborock_message: RoborockMessage):
|
|
366
315
|
raise NotImplementedError
|
|
367
316
|
|
|
@@ -399,148 +348,6 @@ class RoborockClient:
|
|
|
399
348
|
return return_type.from_dict(response)
|
|
400
349
|
return response
|
|
401
350
|
|
|
402
|
-
async def get_status(self) -> Status:
|
|
403
|
-
data = self._status_type.from_dict(await self.cache[CacheableAttribute.status].async_value())
|
|
404
|
-
if data is None:
|
|
405
|
-
return self._status_type()
|
|
406
|
-
return data
|
|
407
|
-
|
|
408
|
-
async def get_dnd_timer(self) -> DnDTimer | None:
|
|
409
|
-
return DnDTimer.from_dict(await self.cache[CacheableAttribute.dnd_timer].async_value())
|
|
410
|
-
|
|
411
|
-
async def get_valley_electricity_timer(self) -> ValleyElectricityTimer | None:
|
|
412
|
-
return ValleyElectricityTimer.from_dict(
|
|
413
|
-
await self.cache[CacheableAttribute.valley_electricity_timer].async_value()
|
|
414
|
-
)
|
|
415
|
-
|
|
416
|
-
async def get_clean_summary(self) -> CleanSummary | None:
|
|
417
|
-
clean_summary: dict | list | int = await self.send_command(RoborockCommand.GET_CLEAN_SUMMARY)
|
|
418
|
-
if isinstance(clean_summary, dict):
|
|
419
|
-
return CleanSummary.from_dict(clean_summary)
|
|
420
|
-
elif isinstance(clean_summary, list):
|
|
421
|
-
clean_time, clean_area, clean_count, records = unpack_list(clean_summary, 4)
|
|
422
|
-
return CleanSummary(
|
|
423
|
-
clean_time=clean_time,
|
|
424
|
-
clean_area=clean_area,
|
|
425
|
-
clean_count=clean_count,
|
|
426
|
-
records=records,
|
|
427
|
-
)
|
|
428
|
-
elif isinstance(clean_summary, int):
|
|
429
|
-
return CleanSummary(clean_time=clean_summary)
|
|
430
|
-
return None
|
|
431
|
-
|
|
432
|
-
async def get_clean_record(self, record_id: int) -> CleanRecord | None:
|
|
433
|
-
record: dict | list = await self.send_command(RoborockCommand.GET_CLEAN_RECORD, [record_id])
|
|
434
|
-
if isinstance(record, dict):
|
|
435
|
-
return CleanRecord.from_dict(record)
|
|
436
|
-
elif isinstance(record, list):
|
|
437
|
-
# There are still a few unknown variables in this.
|
|
438
|
-
begin, end, duration, area = unpack_list(record, 4)
|
|
439
|
-
return CleanRecord(begin=begin, end=end, duration=duration, area=area)
|
|
440
|
-
else:
|
|
441
|
-
_LOGGER.warning("Clean record was of a new type, please submit an issue request: %s", record)
|
|
442
|
-
return None
|
|
443
|
-
|
|
444
|
-
async def get_consumable(self) -> Consumable:
|
|
445
|
-
data = Consumable.from_dict(await self.cache[CacheableAttribute.consumable].async_value())
|
|
446
|
-
if data is None:
|
|
447
|
-
return Consumable()
|
|
448
|
-
return data
|
|
449
|
-
|
|
450
|
-
async def get_wash_towel_mode(self) -> WashTowelMode | None:
|
|
451
|
-
return WashTowelMode.from_dict(await self.cache[CacheableAttribute.wash_towel_mode].async_value())
|
|
452
|
-
|
|
453
|
-
async def get_dust_collection_mode(self) -> DustCollectionMode | None:
|
|
454
|
-
return DustCollectionMode.from_dict(await self.cache[CacheableAttribute.dust_collection_mode].async_value())
|
|
455
|
-
|
|
456
|
-
async def get_smart_wash_params(self) -> SmartWashParams | None:
|
|
457
|
-
return SmartWashParams.from_dict(await self.cache[CacheableAttribute.smart_wash_params].async_value())
|
|
458
|
-
|
|
459
|
-
async def get_dock_summary(self, dock_type: RoborockDockTypeCode) -> DockSummary:
|
|
460
|
-
"""Gets the status summary from the dock with the methods available for a given dock.
|
|
461
|
-
|
|
462
|
-
:param dock_type: RoborockDockTypeCode"""
|
|
463
|
-
commands: list[
|
|
464
|
-
Coroutine[
|
|
465
|
-
Any,
|
|
466
|
-
Any,
|
|
467
|
-
DustCollectionMode | WashTowelMode | SmartWashParams | None,
|
|
468
|
-
]
|
|
469
|
-
] = [self.get_dust_collection_mode()]
|
|
470
|
-
if dock_type in WASH_N_FILL_DOCK:
|
|
471
|
-
commands += [
|
|
472
|
-
self.get_wash_towel_mode(),
|
|
473
|
-
self.get_smart_wash_params(),
|
|
474
|
-
]
|
|
475
|
-
[dust_collection_mode, wash_towel_mode, smart_wash_params] = unpack_list(
|
|
476
|
-
list(await asyncio.gather(*commands)), 3
|
|
477
|
-
) # type: DustCollectionMode, WashTowelMode | None, SmartWashParams | None # type: ignore
|
|
478
|
-
|
|
479
|
-
return DockSummary(dust_collection_mode, wash_towel_mode, smart_wash_params)
|
|
480
|
-
|
|
481
|
-
async def get_prop(self) -> DeviceProp | None:
|
|
482
|
-
"""Gets device general properties."""
|
|
483
|
-
# Mypy thinks that each one of these is typed as a union of all the others. so we do type ignore.
|
|
484
|
-
status, clean_summary, consumable = await asyncio.gather(
|
|
485
|
-
*[
|
|
486
|
-
self.get_status(),
|
|
487
|
-
self.get_clean_summary(),
|
|
488
|
-
self.get_consumable(),
|
|
489
|
-
]
|
|
490
|
-
) # type: Status, CleanSummary, Consumable # type: ignore
|
|
491
|
-
last_clean_record = None
|
|
492
|
-
if clean_summary and clean_summary.records and len(clean_summary.records) > 0:
|
|
493
|
-
last_clean_record = await self.get_clean_record(clean_summary.records[0])
|
|
494
|
-
dock_summary = None
|
|
495
|
-
if status and status.dock_type is not None and status.dock_type != RoborockDockTypeCode.no_dock:
|
|
496
|
-
dock_summary = await self.get_dock_summary(status.dock_type)
|
|
497
|
-
if any([status, clean_summary, consumable]):
|
|
498
|
-
return DeviceProp(
|
|
499
|
-
status,
|
|
500
|
-
clean_summary,
|
|
501
|
-
consumable,
|
|
502
|
-
last_clean_record,
|
|
503
|
-
dock_summary,
|
|
504
|
-
)
|
|
505
|
-
return None
|
|
506
|
-
|
|
507
|
-
async def get_multi_maps_list(self) -> MultiMapsList | None:
|
|
508
|
-
return await self.send_command(RoborockCommand.GET_MULTI_MAPS_LIST, return_type=MultiMapsList)
|
|
509
|
-
|
|
510
|
-
async def get_networking(self) -> NetworkInfo | None:
|
|
511
|
-
return await self.send_command(RoborockCommand.GET_NETWORK_INFO, return_type=NetworkInfo)
|
|
512
|
-
|
|
513
|
-
async def get_room_mapping(self) -> list[RoomMapping] | None:
|
|
514
|
-
"""Gets the mapping from segment id -> iot id. Only works on local api."""
|
|
515
|
-
mapping: list = await self.send_command(RoborockCommand.GET_ROOM_MAPPING)
|
|
516
|
-
if isinstance(mapping, list):
|
|
517
|
-
return [
|
|
518
|
-
RoomMapping(segment_id=segment_id, iot_id=iot_id) # type: ignore
|
|
519
|
-
for segment_id, iot_id in [unpack_list(room, 2) for room in mapping if isinstance(room, list)]
|
|
520
|
-
]
|
|
521
|
-
return None
|
|
522
|
-
|
|
523
|
-
async def get_child_lock_status(self) -> ChildLockStatus:
|
|
524
|
-
"""Gets current child lock status."""
|
|
525
|
-
return ChildLockStatus.from_dict(await self.cache[CacheableAttribute.child_lock_status].async_value())
|
|
526
|
-
|
|
527
|
-
async def get_flow_led_status(self) -> FlowLedStatus:
|
|
528
|
-
"""Gets current flow led status."""
|
|
529
|
-
return FlowLedStatus.from_dict(await self.cache[CacheableAttribute.flow_led_status].async_value())
|
|
530
|
-
|
|
531
|
-
async def get_sound_volume(self) -> int | None:
|
|
532
|
-
"""Gets current volume level."""
|
|
533
|
-
return await self.cache[CacheableAttribute.sound_volume].async_value()
|
|
534
|
-
|
|
535
|
-
async def get_server_timer(self) -> list[ServerTimer]:
|
|
536
|
-
"""Gets current server timer."""
|
|
537
|
-
server_timers = await self.cache[CacheableAttribute.server_timer].async_value()
|
|
538
|
-
if server_timers:
|
|
539
|
-
if isinstance(server_timers[0], list):
|
|
540
|
-
return [ServerTimer(*server_timer) for server_timer in server_timers]
|
|
541
|
-
return [ServerTimer(*server_timers)]
|
|
542
|
-
return []
|
|
543
|
-
|
|
544
351
|
def add_listener(
|
|
545
352
|
self, protocol: RoborockDataProtocol, listener: Callable, cache: dict[CacheableAttribute, AttributeCache]
|
|
546
353
|
) -> None:
|
|
@@ -11,10 +11,10 @@ from pyshark.capture.live_capture import LiveCapture, UnknownInterfaceException
|
|
|
11
11
|
from pyshark.packet.packet import Packet # type: ignore
|
|
12
12
|
|
|
13
13
|
from roborock import RoborockException
|
|
14
|
-
from roborock.cloud_api import RoborockMqttClient
|
|
15
14
|
from roborock.containers import DeviceData, LoginData
|
|
16
15
|
from roborock.protocol import MessageParser
|
|
17
16
|
from roborock.util import run_sync
|
|
17
|
+
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
|
|
18
18
|
from roborock.web_api import RoborockApiClient
|
|
19
19
|
|
|
20
20
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -135,7 +135,7 @@ async def command(ctx, cmd, device_id, params):
|
|
|
135
135
|
if model is None:
|
|
136
136
|
raise RoborockException(f"Could not find model for device {device.name}")
|
|
137
137
|
device_info = DeviceData(device=device, model=model)
|
|
138
|
-
mqtt_client =
|
|
138
|
+
mqtt_client = RoborockMqttClientV1(login_data.user_data, device_info)
|
|
139
139
|
await mqtt_client.send_command(cmd, json.loads(params) if params is not None else None)
|
|
140
140
|
mqtt_client.__del__()
|
|
141
141
|
|
|
@@ -11,12 +11,12 @@ from urllib.parse import urlparse
|
|
|
11
11
|
|
|
12
12
|
import paho.mqtt.client as mqtt
|
|
13
13
|
|
|
14
|
-
from .api import
|
|
14
|
+
from .api import KEEPALIVE, RoborockClient, md5hex
|
|
15
15
|
from .containers import DeviceData, UserData
|
|
16
|
-
from .exceptions import
|
|
16
|
+
from .exceptions import RoborockException, VacuumError
|
|
17
17
|
from .protocol import MessageParser, Utils
|
|
18
18
|
from .roborock_future import RoborockFuture
|
|
19
|
-
from .roborock_message import RoborockMessage
|
|
19
|
+
from .roborock_message import RoborockMessage
|
|
20
20
|
from .roborock_typing import RoborockCommand
|
|
21
21
|
from .util import RoborockLoggerAdapter
|
|
22
22
|
|
|
@@ -167,44 +167,11 @@ class RoborockMqttClient(RoborockClient, mqtt.Client):
|
|
|
167
167
|
raise RoborockException(f"Failed to publish ({mqtt.error_string(info.rc)})")
|
|
168
168
|
|
|
169
169
|
async def send_message(self, roborock_message: RoborockMessage):
|
|
170
|
-
|
|
171
|
-
method = roborock_message.get_method()
|
|
172
|
-
params = roborock_message.get_params()
|
|
173
|
-
request_id = roborock_message.get_request_id()
|
|
174
|
-
if request_id is None:
|
|
175
|
-
raise RoborockException(f"Failed build message {roborock_message}")
|
|
176
|
-
response_protocol = (
|
|
177
|
-
RoborockMessageProtocol.MAP_RESPONSE if method in COMMANDS_SECURED else RoborockMessageProtocol.RPC_RESPONSE
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
local_key = self.device_info.device.local_key
|
|
181
|
-
msg = MessageParser.build(roborock_message, local_key, False)
|
|
182
|
-
self._logger.debug(f"id={request_id} Requesting method {method} with {params}")
|
|
183
|
-
async_response = asyncio.ensure_future(self._async_response(request_id, response_protocol))
|
|
184
|
-
self._send_msg_raw(msg)
|
|
185
|
-
(response, err) = await async_response
|
|
186
|
-
self._diagnostic_data[method if method is not None else "unknown"] = {
|
|
187
|
-
"params": roborock_message.get_params(),
|
|
188
|
-
"response": response,
|
|
189
|
-
"error": err,
|
|
190
|
-
}
|
|
191
|
-
if err:
|
|
192
|
-
raise CommandVacuumError(method, err) from err
|
|
193
|
-
if response_protocol == RoborockMessageProtocol.MAP_RESPONSE:
|
|
194
|
-
self._logger.debug(f"id={request_id} Response from {method}: {len(response)} bytes")
|
|
195
|
-
else:
|
|
196
|
-
self._logger.debug(f"id={request_id} Response from {method}: {response}")
|
|
197
|
-
return response
|
|
170
|
+
raise NotImplementedError
|
|
198
171
|
|
|
199
172
|
async def _send_command(
|
|
200
173
|
self,
|
|
201
174
|
method: RoborockCommand | str,
|
|
202
175
|
params: list | dict | int | None = None,
|
|
203
176
|
):
|
|
204
|
-
|
|
205
|
-
request_protocol = RoborockMessageProtocol.RPC_REQUEST
|
|
206
|
-
roborock_message = RoborockMessage(timestamp=timestamp, protocol=request_protocol, payload=payload)
|
|
207
|
-
return await self.send_message(roborock_message)
|
|
208
|
-
|
|
209
|
-
async def get_map_v1(self):
|
|
210
|
-
return await self.send_command(RoborockCommand.GET_MAP_V1)
|
|
177
|
+
raise NotImplementedError
|
|
@@ -7,10 +7,10 @@ from asyncio import Lock, TimerHandle, Transport
|
|
|
7
7
|
import async_timeout
|
|
8
8
|
|
|
9
9
|
from . import DeviceData
|
|
10
|
-
from .api import
|
|
10
|
+
from .api import RoborockClient
|
|
11
11
|
from .exceptions import CommandVacuumError, RoborockConnectionException, RoborockException
|
|
12
12
|
from .protocol import MessageParser
|
|
13
|
-
from .roborock_message import
|
|
13
|
+
from .roborock_message import RoborockMessage, RoborockMessageProtocol
|
|
14
14
|
from .roborock_typing import RoborockCommand
|
|
15
15
|
from .util import RoborockLoggerAdapter
|
|
16
16
|
|
|
@@ -21,7 +21,6 @@ class RoborockLocalClient(RoborockClient, asyncio.Protocol):
|
|
|
21
21
|
def __init__(self, device_data: DeviceData, queue_timeout: int = 4):
|
|
22
22
|
if device_data.host is None:
|
|
23
23
|
raise RoborockException("Host is required")
|
|
24
|
-
super().__init__("abc", device_data, queue_timeout)
|
|
25
24
|
self.host = device_data.host
|
|
26
25
|
self._batch_structs: list[RoborockMessage] = []
|
|
27
26
|
self._executing = False
|
|
@@ -30,6 +29,7 @@ class RoborockLocalClient(RoborockClient, asyncio.Protocol):
|
|
|
30
29
|
self._mutex = Lock()
|
|
31
30
|
self.keep_alive_task: TimerHandle | None = None
|
|
32
31
|
self._logger = RoborockLoggerAdapter(device_data.device.name, _LOGGER)
|
|
32
|
+
RoborockClient.__init__(self, "abc", device_data, queue_timeout)
|
|
33
33
|
|
|
34
34
|
def data_received(self, message):
|
|
35
35
|
if self.remaining:
|
|
@@ -82,19 +82,6 @@ class RoborockLocalClient(RoborockClient, asyncio.Protocol):
|
|
|
82
82
|
async with self._mutex:
|
|
83
83
|
self.sync_disconnect()
|
|
84
84
|
|
|
85
|
-
def build_roborock_message(
|
|
86
|
-
self, method: RoborockCommand | str, params: list | dict | int | None = None
|
|
87
|
-
) -> RoborockMessage:
|
|
88
|
-
secured = True if method in COMMANDS_SECURED else False
|
|
89
|
-
request_id, timestamp, payload = self._get_payload(method, params, secured)
|
|
90
|
-
request_protocol = RoborockMessageProtocol.GENERAL_REQUEST
|
|
91
|
-
message_retry: MessageRetry | None = None
|
|
92
|
-
if method == RoborockCommand.RETRY_REQUEST and isinstance(params, dict):
|
|
93
|
-
message_retry = MessageRetry(method=params["method"], retry_id=params["retry_id"])
|
|
94
|
-
return RoborockMessage(
|
|
95
|
-
timestamp=timestamp, protocol=request_protocol, payload=payload, message_retry=message_retry
|
|
96
|
-
)
|
|
97
|
-
|
|
98
85
|
async def hello(self):
|
|
99
86
|
request_id = 1
|
|
100
87
|
protocol = RoborockMessageProtocol.HELLO_REQUEST
|
|
@@ -125,8 +112,7 @@ class RoborockLocalClient(RoborockClient, asyncio.Protocol):
|
|
|
125
112
|
method: RoborockCommand | str,
|
|
126
113
|
params: list | dict | int | None = None,
|
|
127
114
|
):
|
|
128
|
-
|
|
129
|
-
return await self.send_message(roborock_message)
|
|
115
|
+
raise NotImplementedError
|
|
130
116
|
|
|
131
117
|
def _send_msg_raw(self, data: bytes):
|
|
132
118
|
try:
|
|
File without changes
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Coroutine
|
|
6
|
+
from random import randint
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from roborock import DeviceProp, DockSummary, RoborockCommand, RoborockDockTypeCode
|
|
10
|
+
from roborock.api import RoborockClient
|
|
11
|
+
from roborock.command_cache import CacheableAttribute
|
|
12
|
+
from roborock.containers import (
|
|
13
|
+
ChildLockStatus,
|
|
14
|
+
CleanRecord,
|
|
15
|
+
CleanSummary,
|
|
16
|
+
Consumable,
|
|
17
|
+
DeviceData,
|
|
18
|
+
DnDTimer,
|
|
19
|
+
DustCollectionMode,
|
|
20
|
+
FlowLedStatus,
|
|
21
|
+
ModelStatus,
|
|
22
|
+
MultiMapsList,
|
|
23
|
+
NetworkInfo,
|
|
24
|
+
RoomMapping,
|
|
25
|
+
S7MaxVStatus,
|
|
26
|
+
ServerTimer,
|
|
27
|
+
SmartWashParams,
|
|
28
|
+
Status,
|
|
29
|
+
ValleyElectricityTimer,
|
|
30
|
+
WashTowelMode,
|
|
31
|
+
)
|
|
32
|
+
from roborock.util import unpack_list
|
|
33
|
+
|
|
34
|
+
COMMANDS_SECURED = [
|
|
35
|
+
RoborockCommand.GET_MAP_V1,
|
|
36
|
+
RoborockCommand.GET_MULTI_MAP,
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
WASH_N_FILL_DOCK = [
|
|
40
|
+
RoborockDockTypeCode.empty_wash_fill_dock,
|
|
41
|
+
RoborockDockTypeCode.s8_dock,
|
|
42
|
+
RoborockDockTypeCode.p10_dock,
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RoborockClientV1(RoborockClient):
|
|
47
|
+
def __init__(self, device_info: DeviceData, cache, logger, endpoint: str):
|
|
48
|
+
super().__init__(endpoint, device_info)
|
|
49
|
+
self._status_type: type[Status] = ModelStatus.get(device_info.model, S7MaxVStatus)
|
|
50
|
+
self.cache = cache
|
|
51
|
+
self._logger = logger
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def status_type(self) -> type[Status]:
|
|
55
|
+
"""Gets the status type for this device"""
|
|
56
|
+
return self._status_type
|
|
57
|
+
|
|
58
|
+
async def get_status(self) -> Status:
|
|
59
|
+
data = self._status_type.from_dict(await self.cache[CacheableAttribute.status].async_value())
|
|
60
|
+
if data is None:
|
|
61
|
+
return self._status_type()
|
|
62
|
+
return data
|
|
63
|
+
|
|
64
|
+
async def get_dnd_timer(self) -> DnDTimer | None:
|
|
65
|
+
return DnDTimer.from_dict(await self.cache[CacheableAttribute.dnd_timer].async_value())
|
|
66
|
+
|
|
67
|
+
async def get_valley_electricity_timer(self) -> ValleyElectricityTimer | None:
|
|
68
|
+
return ValleyElectricityTimer.from_dict(
|
|
69
|
+
await self.cache[CacheableAttribute.valley_electricity_timer].async_value()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def get_clean_summary(self) -> CleanSummary | None:
|
|
73
|
+
clean_summary: dict | list | int = await self.send_command(RoborockCommand.GET_CLEAN_SUMMARY)
|
|
74
|
+
if isinstance(clean_summary, dict):
|
|
75
|
+
return CleanSummary.from_dict(clean_summary)
|
|
76
|
+
elif isinstance(clean_summary, list):
|
|
77
|
+
clean_time, clean_area, clean_count, records = unpack_list(clean_summary, 4)
|
|
78
|
+
return CleanSummary(
|
|
79
|
+
clean_time=clean_time,
|
|
80
|
+
clean_area=clean_area,
|
|
81
|
+
clean_count=clean_count,
|
|
82
|
+
records=records,
|
|
83
|
+
)
|
|
84
|
+
elif isinstance(clean_summary, int):
|
|
85
|
+
return CleanSummary(clean_time=clean_summary)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
async def get_clean_record(self, record_id: int) -> CleanRecord | None:
|
|
89
|
+
record: dict | list = await self.send_command(RoborockCommand.GET_CLEAN_RECORD, [record_id])
|
|
90
|
+
if isinstance(record, dict):
|
|
91
|
+
return CleanRecord.from_dict(record)
|
|
92
|
+
elif isinstance(record, list):
|
|
93
|
+
# There are still a few unknown variables in this.
|
|
94
|
+
begin, end, duration, area = unpack_list(record, 4)
|
|
95
|
+
return CleanRecord(begin=begin, end=end, duration=duration, area=area)
|
|
96
|
+
else:
|
|
97
|
+
self._logger.warning("Clean record was of a new type, please submit an issue request: %s", record)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
async def get_consumable(self) -> Consumable:
|
|
101
|
+
data = Consumable.from_dict(await self.cache[CacheableAttribute.consumable].async_value())
|
|
102
|
+
if data is None:
|
|
103
|
+
return Consumable()
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
async def get_wash_towel_mode(self) -> WashTowelMode | None:
|
|
107
|
+
return WashTowelMode.from_dict(await self.cache[CacheableAttribute.wash_towel_mode].async_value())
|
|
108
|
+
|
|
109
|
+
async def get_dust_collection_mode(self) -> DustCollectionMode | None:
|
|
110
|
+
return DustCollectionMode.from_dict(await self.cache[CacheableAttribute.dust_collection_mode].async_value())
|
|
111
|
+
|
|
112
|
+
async def get_smart_wash_params(self) -> SmartWashParams | None:
|
|
113
|
+
return SmartWashParams.from_dict(await self.cache[CacheableAttribute.smart_wash_params].async_value())
|
|
114
|
+
|
|
115
|
+
async def get_dock_summary(self, dock_type: RoborockDockTypeCode) -> DockSummary:
|
|
116
|
+
"""Gets the status summary from the dock with the methods available for a given dock.
|
|
117
|
+
|
|
118
|
+
:param dock_type: RoborockDockTypeCode"""
|
|
119
|
+
commands: list[
|
|
120
|
+
Coroutine[
|
|
121
|
+
Any,
|
|
122
|
+
Any,
|
|
123
|
+
DustCollectionMode | WashTowelMode | SmartWashParams | None,
|
|
124
|
+
]
|
|
125
|
+
] = [self.get_dust_collection_mode()]
|
|
126
|
+
if dock_type in WASH_N_FILL_DOCK:
|
|
127
|
+
commands += [
|
|
128
|
+
self.get_wash_towel_mode(),
|
|
129
|
+
self.get_smart_wash_params(),
|
|
130
|
+
]
|
|
131
|
+
[dust_collection_mode, wash_towel_mode, smart_wash_params] = unpack_list(
|
|
132
|
+
list(await asyncio.gather(*commands)), 3
|
|
133
|
+
) # type: DustCollectionMode, WashTowelMode | None, SmartWashParams | None # type: ignore
|
|
134
|
+
|
|
135
|
+
return DockSummary(dust_collection_mode, wash_towel_mode, smart_wash_params)
|
|
136
|
+
|
|
137
|
+
async def get_prop(self) -> DeviceProp | None:
|
|
138
|
+
"""Gets device general properties."""
|
|
139
|
+
# Mypy thinks that each one of these is typed as a union of all the others. so we do type ignore.
|
|
140
|
+
status, clean_summary, consumable = await asyncio.gather(
|
|
141
|
+
*[
|
|
142
|
+
self.get_status(),
|
|
143
|
+
self.get_clean_summary(),
|
|
144
|
+
self.get_consumable(),
|
|
145
|
+
]
|
|
146
|
+
) # type: Status, CleanSummary, Consumable # type: ignore
|
|
147
|
+
last_clean_record = None
|
|
148
|
+
if clean_summary and clean_summary.records and len(clean_summary.records) > 0:
|
|
149
|
+
last_clean_record = await self.get_clean_record(clean_summary.records[0])
|
|
150
|
+
dock_summary = None
|
|
151
|
+
if status and status.dock_type is not None and status.dock_type != RoborockDockTypeCode.no_dock:
|
|
152
|
+
dock_summary = await self.get_dock_summary(status.dock_type)
|
|
153
|
+
if any([status, clean_summary, consumable]):
|
|
154
|
+
return DeviceProp(
|
|
155
|
+
status,
|
|
156
|
+
clean_summary,
|
|
157
|
+
consumable,
|
|
158
|
+
last_clean_record,
|
|
159
|
+
dock_summary,
|
|
160
|
+
)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
async def get_multi_maps_list(self) -> MultiMapsList | None:
|
|
164
|
+
return await self.send_command(RoborockCommand.GET_MULTI_MAPS_LIST, return_type=MultiMapsList)
|
|
165
|
+
|
|
166
|
+
async def get_networking(self) -> NetworkInfo | None:
|
|
167
|
+
return await self.send_command(RoborockCommand.GET_NETWORK_INFO, return_type=NetworkInfo)
|
|
168
|
+
|
|
169
|
+
async def get_room_mapping(self) -> list[RoomMapping] | None:
|
|
170
|
+
"""Gets the mapping from segment id -> iot id. Only works on local api."""
|
|
171
|
+
mapping: list = await self.send_command(RoborockCommand.GET_ROOM_MAPPING)
|
|
172
|
+
if isinstance(mapping, list):
|
|
173
|
+
return [
|
|
174
|
+
RoomMapping(segment_id=segment_id, iot_id=iot_id) # type: ignore
|
|
175
|
+
for segment_id, iot_id in [unpack_list(room, 2) for room in mapping if isinstance(room, list)]
|
|
176
|
+
]
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
async def get_child_lock_status(self) -> ChildLockStatus:
|
|
180
|
+
"""Gets current child lock status."""
|
|
181
|
+
return ChildLockStatus.from_dict(await self.cache[CacheableAttribute.child_lock_status].async_value())
|
|
182
|
+
|
|
183
|
+
async def get_flow_led_status(self) -> FlowLedStatus:
|
|
184
|
+
"""Gets current flow led status."""
|
|
185
|
+
return FlowLedStatus.from_dict(await self.cache[CacheableAttribute.flow_led_status].async_value())
|
|
186
|
+
|
|
187
|
+
async def get_sound_volume(self) -> int | None:
|
|
188
|
+
"""Gets current volume level."""
|
|
189
|
+
return await self.cache[CacheableAttribute.sound_volume].async_value()
|
|
190
|
+
|
|
191
|
+
async def get_server_timer(self) -> list[ServerTimer]:
|
|
192
|
+
"""Gets current server timer."""
|
|
193
|
+
server_timers = await self.cache[CacheableAttribute.server_timer].async_value()
|
|
194
|
+
if server_timers:
|
|
195
|
+
if isinstance(server_timers[0], list):
|
|
196
|
+
return [ServerTimer(*server_timer) for server_timer in server_timers]
|
|
197
|
+
return [ServerTimer(*server_timers)]
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
def _get_payload(
|
|
201
|
+
self,
|
|
202
|
+
method: RoborockCommand | str,
|
|
203
|
+
params: list | dict | int | None = None,
|
|
204
|
+
secured=False,
|
|
205
|
+
):
|
|
206
|
+
timestamp = math.floor(time.time())
|
|
207
|
+
request_id = randint(10000, 32767)
|
|
208
|
+
inner = {
|
|
209
|
+
"id": request_id,
|
|
210
|
+
"method": method,
|
|
211
|
+
"params": params or [],
|
|
212
|
+
}
|
|
213
|
+
if secured:
|
|
214
|
+
inner["security"] = {
|
|
215
|
+
"endpoint": self._endpoint,
|
|
216
|
+
"nonce": self._nonce.hex().lower(),
|
|
217
|
+
}
|
|
218
|
+
payload = bytes(
|
|
219
|
+
json.dumps(
|
|
220
|
+
{
|
|
221
|
+
"dps": {"101": json.dumps(inner, separators=(",", ":"))},
|
|
222
|
+
"t": timestamp,
|
|
223
|
+
},
|
|
224
|
+
separators=(",", ":"),
|
|
225
|
+
).encode()
|
|
226
|
+
)
|
|
227
|
+
return request_id, timestamp, payload
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from roborock.local_api import RoborockLocalClient
|
|
2
|
+
|
|
3
|
+
from .. import DeviceData, RoborockCommand
|
|
4
|
+
from ..roborock_message import MessageRetry, RoborockMessage, RoborockMessageProtocol
|
|
5
|
+
from .roborock_client_v1 import COMMANDS_SECURED, RoborockClientV1
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RoborockLocalClientV1(RoborockLocalClient, RoborockClientV1):
|
|
9
|
+
def __init__(self, device_data: DeviceData, queue_timeout: int = 4):
|
|
10
|
+
RoborockLocalClient.__init__(self, device_data, queue_timeout)
|
|
11
|
+
RoborockClientV1.__init__(self, device_data, self.cache, self._logger, "abc")
|
|
12
|
+
|
|
13
|
+
def build_roborock_message(
|
|
14
|
+
self, method: RoborockCommand | str, params: list | dict | int | None = None
|
|
15
|
+
) -> RoborockMessage:
|
|
16
|
+
secured = True if method in COMMANDS_SECURED else False
|
|
17
|
+
request_id, timestamp, payload = self._get_payload(method, params, secured)
|
|
18
|
+
request_protocol = RoborockMessageProtocol.GENERAL_REQUEST
|
|
19
|
+
message_retry: MessageRetry | None = None
|
|
20
|
+
if method == RoborockCommand.RETRY_REQUEST and isinstance(params, dict):
|
|
21
|
+
message_retry = MessageRetry(method=params["method"], retry_id=params["retry_id"])
|
|
22
|
+
return RoborockMessage(
|
|
23
|
+
timestamp=timestamp, protocol=request_protocol, payload=payload, message_retry=message_retry
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
async def _send_command(
|
|
27
|
+
self,
|
|
28
|
+
method: RoborockCommand | str,
|
|
29
|
+
params: list | dict | int | None = None,
|
|
30
|
+
):
|
|
31
|
+
roborock_message = self.build_roborock_message(method, params)
|
|
32
|
+
return await self.send_message(roborock_message)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
|
|
4
|
+
import paho.mqtt.client as mqtt
|
|
5
|
+
|
|
6
|
+
from roborock.cloud_api import RoborockMqttClient
|
|
7
|
+
|
|
8
|
+
from ..containers import DeviceData, UserData
|
|
9
|
+
from ..exceptions import CommandVacuumError, RoborockException
|
|
10
|
+
from ..protocol import MessageParser, Utils
|
|
11
|
+
from ..roborock_message import RoborockMessage, RoborockMessageProtocol
|
|
12
|
+
from ..roborock_typing import RoborockCommand
|
|
13
|
+
from .roborock_client_v1 import COMMANDS_SECURED, RoborockClientV1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1):
|
|
17
|
+
def __init__(self, user_data: UserData, device_info: DeviceData, queue_timeout: int = 10) -> None:
|
|
18
|
+
rriot = user_data.rriot
|
|
19
|
+
if rriot is None:
|
|
20
|
+
raise RoborockException("Got no rriot data from user_data")
|
|
21
|
+
endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode()
|
|
22
|
+
|
|
23
|
+
RoborockMqttClient.__init__(self, user_data, device_info, queue_timeout)
|
|
24
|
+
RoborockClientV1.__init__(self, device_info, self.cache, self._logger, endpoint)
|
|
25
|
+
|
|
26
|
+
def _send_msg_raw(self, msg: bytes) -> None:
|
|
27
|
+
info = self.publish(f"rr/m/i/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}", msg)
|
|
28
|
+
if info.rc != mqtt.MQTT_ERR_SUCCESS:
|
|
29
|
+
raise RoborockException(f"Failed to publish ({mqtt.error_string(info.rc)})")
|
|
30
|
+
|
|
31
|
+
async def send_message(self, roborock_message: RoborockMessage):
|
|
32
|
+
await self.validate_connection()
|
|
33
|
+
method = roborock_message.get_method()
|
|
34
|
+
params = roborock_message.get_params()
|
|
35
|
+
request_id = roborock_message.get_request_id()
|
|
36
|
+
if request_id is None:
|
|
37
|
+
raise RoborockException(f"Failed build message {roborock_message}")
|
|
38
|
+
response_protocol = (
|
|
39
|
+
RoborockMessageProtocol.MAP_RESPONSE if method in COMMANDS_SECURED else RoborockMessageProtocol.RPC_RESPONSE
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
local_key = self.device_info.device.local_key
|
|
43
|
+
msg = MessageParser.build(roborock_message, local_key, False)
|
|
44
|
+
self._logger.debug(f"id={request_id} Requesting method {method} with {params}")
|
|
45
|
+
async_response = asyncio.ensure_future(self._async_response(request_id, response_protocol))
|
|
46
|
+
self._send_msg_raw(msg)
|
|
47
|
+
(response, err) = await async_response
|
|
48
|
+
self._diagnostic_data[method if method is not None else "unknown"] = {
|
|
49
|
+
"params": roborock_message.get_params(),
|
|
50
|
+
"response": response,
|
|
51
|
+
"error": err,
|
|
52
|
+
}
|
|
53
|
+
if err:
|
|
54
|
+
raise CommandVacuumError(method, err) from err
|
|
55
|
+
if response_protocol == RoborockMessageProtocol.MAP_RESPONSE:
|
|
56
|
+
self._logger.debug(f"id={request_id} Response from {method}: {len(response)} bytes")
|
|
57
|
+
else:
|
|
58
|
+
self._logger.debug(f"id={request_id} Response from {method}: {response}")
|
|
59
|
+
return response
|
|
60
|
+
|
|
61
|
+
async def _send_command(
|
|
62
|
+
self,
|
|
63
|
+
method: RoborockCommand | str,
|
|
64
|
+
params: list | dict | int | None = None,
|
|
65
|
+
):
|
|
66
|
+
request_id, timestamp, payload = self._get_payload(method, params, True)
|
|
67
|
+
request_protocol = RoborockMessageProtocol.RPC_REQUEST
|
|
68
|
+
roborock_message = RoborockMessage(timestamp=timestamp, protocol=request_protocol, payload=payload)
|
|
69
|
+
return await self.send_message(roborock_message)
|
|
70
|
+
|
|
71
|
+
async def get_map_v1(self):
|
|
72
|
+
return await self.send_command(RoborockCommand.GET_MAP_V1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|