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.
Files changed (25) hide show
  1. {python_roborock-0.39.2 → python_roborock-0.41.0}/PKG-INFO +1 -1
  2. {python_roborock-0.39.2 → python_roborock-0.41.0}/pyproject.toml +2 -2
  3. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/api.py +7 -200
  4. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/cli.py +2 -2
  5. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/cloud_api.py +5 -38
  6. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/local_api.py +4 -18
  7. python_roborock-0.41.0/roborock/version_1_apis/__init__.py +0 -0
  8. python_roborock-0.41.0/roborock/version_1_apis/roborock_client_v1.py +227 -0
  9. python_roborock-0.41.0/roborock/version_1_apis/roborock_local_client_v1.py +32 -0
  10. python_roborock-0.41.0/roborock/version_1_apis/roborock_mqtt_client_v1.py +72 -0
  11. {python_roborock-0.39.2 → python_roborock-0.41.0}/LICENSE +0 -0
  12. {python_roborock-0.39.2 → python_roborock-0.41.0}/README.md +0 -0
  13. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/__init__.py +0 -0
  14. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/code_mappings.py +0 -0
  15. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/command_cache.py +0 -0
  16. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/const.py +0 -0
  17. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/containers.py +0 -0
  18. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/exceptions.py +0 -0
  19. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/protocol.py +0 -0
  20. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/py.typed +0 -0
  21. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/roborock_future.py +0 -0
  22. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/roborock_message.py +0 -0
  23. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/roborock_typing.py +0 -0
  24. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/util.py +0 -0
  25. {python_roborock-0.39.2 → python_roborock-0.41.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 0.39.2
3
+ Version: 0.41.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Home-page: https://github.com/humbertogontijo/python-roborock
6
6
  License: GPL-3.0-only
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-roborock"
3
- version = "0.39.2"
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.7.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 DeviceProp, DockSummary, RoborockCommand
57
- from .util import RepeatableTask, RoborockLoggerAdapter, get_running_loop_or_create_one, unpack_list
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 %s for %s", data.payload, data.protocol) from err
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 = RoborockMqttClient(login_data.user_data, device_info)
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 COMMANDS_SECURED, KEEPALIVE, RoborockClient, md5hex
14
+ from .api import KEEPALIVE, RoborockClient, md5hex
15
15
  from .containers import DeviceData, UserData
16
- from .exceptions import CommandVacuumError, RoborockException, VacuumError
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, RoborockMessageProtocol
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
- await self.validate_connection()
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
- request_id, timestamp, payload = super()._get_payload(method, params, True)
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 COMMANDS_SECURED, RoborockClient
10
+ from .api import RoborockClient
11
11
  from .exceptions import CommandVacuumError, RoborockConnectionException, RoborockException
12
12
  from .protocol import MessageParser
13
- from .roborock_message import MessageRetry, RoborockMessage, RoborockMessageProtocol
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
- roborock_message = self.build_roborock_message(method, params)
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:
@@ -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)