python-roborock 2.21.0__tar.gz → 2.23.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 (35) hide show
  1. {python_roborock-2.21.0 → python_roborock-2.23.0}/PKG-INFO +1 -1
  2. {python_roborock-2.21.0 → python_roborock-2.23.0}/pyproject.toml +2 -2
  3. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/cli.py +54 -2
  4. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/cloud_api.py +13 -13
  5. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/mqtt/roborock_session.py +3 -4
  6. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/protocol.py +22 -1
  7. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +13 -0
  8. {python_roborock-2.21.0 → python_roborock-2.23.0}/LICENSE +0 -0
  9. {python_roborock-2.21.0 → python_roborock-2.23.0}/README.md +0 -0
  10. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/__init__.py +0 -0
  11. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/api.py +0 -0
  12. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/code_mappings.py +0 -0
  13. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/command_cache.py +0 -0
  14. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/const.py +0 -0
  15. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/containers.py +0 -0
  16. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/devices/README.md +0 -0
  17. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/devices/__init__.py +0 -0
  18. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/devices/device.py +0 -0
  19. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/devices/device_manager.py +0 -0
  20. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/exceptions.py +0 -0
  21. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/local_api.py +0 -0
  22. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/mqtt/__init__.py +0 -0
  23. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/mqtt/session.py +0 -0
  24. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/py.typed +0 -0
  25. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/roborock_future.py +0 -0
  26. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/roborock_message.py +0 -0
  27. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/roborock_typing.py +0 -0
  28. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/util.py +0 -0
  29. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/version_1_apis/__init__.py +0 -0
  30. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  31. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  32. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  33. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/version_a01_apis/__init__.py +0 -0
  34. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  35. {python_roborock-2.21.0 → python_roborock-2.23.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 2.21.0
3
+ Version: 2.23.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 = "2.21.0"
3
+ version = "2.23.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"
@@ -75,4 +75,4 @@ select=["E", "F", "UP", "I"]
75
75
  [tool.pytest.ini_options]
76
76
  asyncio_mode = "auto"
77
77
  asyncio_default_fixture_loop_scope = "function"
78
- timeout = 20
78
+ timeout = 30
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import json
4
5
  import logging
5
6
  from pathlib import Path
@@ -12,7 +13,8 @@ from pyshark.packet.packet import Packet # type: ignore
12
13
 
13
14
  from roborock import RoborockException
14
15
  from roborock.containers import DeviceData, HomeDataProduct, LoginData
15
- from roborock.protocol import MessageParser
16
+ from roborock.mqtt.roborock_session import create_mqtt_session
17
+ from roborock.protocol import MessageParser, create_mqtt_params
16
18
  from roborock.util import run_sync
17
19
  from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1
18
20
  from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
@@ -45,7 +47,8 @@ class RoborockContext:
45
47
  if self._login_data is None:
46
48
  raise RoborockException("You must login first")
47
49
 
48
- def login_data(self):
50
+ def login_data(self) -> LoginData:
51
+ """Get the login data."""
49
52
  self.validate()
50
53
  return self._login_data
51
54
 
@@ -90,6 +93,54 @@ async def login(ctx, email, password):
90
93
  context.update(LoginData(user_data=user_data, email=email))
91
94
 
92
95
 
96
+ @click.command()
97
+ @click.pass_context
98
+ @click.option("--duration", default=10, help="Duration to run the MQTT session in seconds")
99
+ @run_sync()
100
+ async def session(ctx, duration: int):
101
+ context: RoborockContext = ctx.obj
102
+ login_data = context.login_data()
103
+
104
+ # Discovery devices if not already available
105
+ if not login_data.home_data:
106
+ await _discover(ctx)
107
+ login_data = context.login_data()
108
+ if not login_data.home_data or not login_data.home_data.devices:
109
+ raise RoborockException("Unable to discover devices")
110
+
111
+ all_devices = login_data.home_data.devices + login_data.home_data.received_devices
112
+ click.echo(f"Discovered devices: {', '.join([device.name for device in all_devices])}")
113
+
114
+ rriot = login_data.user_data.rriot
115
+ params = create_mqtt_params(rriot)
116
+
117
+ mqtt_session = await create_mqtt_session(params)
118
+ click.echo("Starting MQTT session...")
119
+ if not mqtt_session.connected:
120
+ raise RoborockException("Failed to connect to MQTT broker")
121
+
122
+ def on_message(bytes: bytes):
123
+ """Callback function to handle incoming MQTT messages."""
124
+ # Decode the first 20 bytes of the message for display
125
+ bytes = bytes[:20]
126
+
127
+ click.echo(f"Received message: {bytes}...")
128
+
129
+ unsubs = []
130
+ for device in all_devices:
131
+ device_topic = f"rr/m/o/{rriot.u}/{params.username}/{device.duid}"
132
+ unsub = await mqtt_session.subscribe(device_topic, on_message)
133
+ unsubs.append(unsub)
134
+
135
+ click.echo("MQTT session started. Listening for messages...")
136
+ await asyncio.sleep(duration)
137
+
138
+ click.echo("Stopping MQTT session...")
139
+ for unsub in unsubs:
140
+ unsub()
141
+ await mqtt_session.close()
142
+
143
+
93
144
  async def _discover(ctx):
94
145
  context: RoborockContext = ctx.obj
95
146
  login_data = context.login_data()
@@ -264,6 +315,7 @@ cli.add_command(execute_scene)
264
315
  cli.add_command(status)
265
316
  cli.add_command(command)
266
317
  cli.add_command(parser)
318
+ cli.add_command(session)
267
319
 
268
320
 
269
321
  def main():
@@ -6,14 +6,19 @@ import threading
6
6
  from abc import ABC
7
7
  from asyncio import Lock
8
8
  from typing import Any
9
- from urllib.parse import urlparse
10
9
 
11
10
  import paho.mqtt.client as mqtt
12
11
 
13
12
  from .api import KEEPALIVE, RoborockClient
14
13
  from .containers import DeviceData, UserData
15
14
  from .exceptions import RoborockException, VacuumError
16
- from .protocol import Decoder, Encoder, create_mqtt_decoder, create_mqtt_encoder, md5hex
15
+ from .protocol import (
16
+ Decoder,
17
+ Encoder,
18
+ create_mqtt_decoder,
19
+ create_mqtt_encoder,
20
+ create_mqtt_params,
21
+ )
17
22
  from .roborock_future import RoborockFuture
18
23
 
19
24
  _LOGGER = logging.getLogger(__name__)
@@ -53,25 +58,20 @@ class RoborockMqttClient(RoborockClient, ABC):
53
58
  if rriot is None:
54
59
  raise RoborockException("Got no rriot data from user_data")
55
60
  RoborockClient.__init__(self, device_info)
61
+ mqtt_params = create_mqtt_params(rriot)
56
62
  self._mqtt_user = rriot.u
57
- self._hashed_user = md5hex(self._mqtt_user + ":" + rriot.k)[2:10]
58
- url = urlparse(rriot.r.m)
59
- if not isinstance(url.hostname, str):
60
- raise RoborockException("Url parsing returned an invalid hostname")
61
- self._mqtt_host = str(url.hostname)
62
- self._mqtt_port = url.port
63
- self._mqtt_ssl = url.scheme == "ssl"
63
+ self._hashed_user = mqtt_params.username
64
+ self._mqtt_host = mqtt_params.host
65
+ self._mqtt_port = mqtt_params.port
64
66
 
65
67
  self._mqtt_client = _Mqtt()
66
68
  self._mqtt_client.on_connect = self._mqtt_on_connect
67
69
  self._mqtt_client.on_message = self._mqtt_on_message
68
70
  self._mqtt_client.on_disconnect = self._mqtt_on_disconnect
69
- if self._mqtt_ssl:
71
+ if mqtt_params.tls:
70
72
  self._mqtt_client.tls_set()
71
73
 
72
- self._mqtt_password = rriot.s
73
- self._hashed_password = md5hex(self._mqtt_password + ":" + rriot.k)[16:]
74
- self._mqtt_client.username_pw_set(self._hashed_user, self._hashed_password)
74
+ self._mqtt_client.username_pw_set(mqtt_params.username, mqtt_params.password)
75
75
  self._waiting_queue: dict[int, RoborockFuture] = {}
76
76
  self._mutex = Lock()
77
77
  self._decoder: Decoder = create_mqtt_decoder(device_info.device.local_key)
@@ -116,9 +116,9 @@ class RoborockMqttSession(MqttSession):
116
116
  _LOGGER.info("MQTT error: %s", err)
117
117
  except asyncio.CancelledError as err:
118
118
  if start_future:
119
- _LOGGER.debug("MQTT loop was cancelled")
119
+ _LOGGER.debug("MQTT loop was cancelled while starting")
120
120
  start_future.set_exception(err)
121
- _LOGGER.debug("MQTT loop was cancelled while starting")
121
+ _LOGGER.debug("MQTT loop was cancelled")
122
122
  return
123
123
  # Catch exceptions to avoid crashing the loop
124
124
  # and to allow the loop to retry.
@@ -171,8 +171,7 @@ class RoborockMqttSession(MqttSession):
171
171
  self._client = None
172
172
 
173
173
  async def _process_message_loop(self, client: aiomqtt.Client) -> None:
174
- _LOGGER.debug("client=%s", client)
175
- _LOGGER.debug("Processing MQTT messages: %s", client.messages)
174
+ _LOGGER.debug("Processing MQTT messages")
176
175
  async for message in client.messages:
177
176
  _LOGGER.debug("Received message: %s", message)
178
177
  for listener in self._listeners.get(message.topic.value, []):
@@ -8,6 +8,7 @@ import json
8
8
  import logging
9
9
  from asyncio import BaseTransport, Lock
10
10
  from collections.abc import Callable
11
+ from urllib.parse import urlparse
11
12
 
12
13
  from construct import ( # type: ignore
13
14
  Bytes,
@@ -30,7 +31,9 @@ from construct import ( # type: ignore
30
31
  from Crypto.Cipher import AES
31
32
  from Crypto.Util.Padding import pad, unpad
32
33
 
33
- from roborock import BroadcastMessage, RoborockException
34
+ from roborock.containers import BroadcastMessage, RRiot
35
+ from roborock.exceptions import RoborockException
36
+ from roborock.mqtt.session import MqttParams
34
37
  from roborock.roborock_message import RoborockMessage
35
38
 
36
39
  _LOGGER = logging.getLogger(__name__)
@@ -361,6 +364,24 @@ MessageParser: _Parser = _Parser(_Messages, True)
361
364
  BroadcastParser: _Parser = _Parser(_BroadcastMessage, False)
362
365
 
363
366
 
367
+ def create_mqtt_params(rriot: RRiot) -> MqttParams:
368
+ """Return the MQTT parameters for this user."""
369
+ url = urlparse(rriot.r.m)
370
+ if not isinstance(url.hostname, str):
371
+ raise RoborockException(f"Url parsing '{rriot.r.m}' returned an invalid hostname")
372
+ if not url.port:
373
+ raise RoborockException(f"Url parsing '{rriot.r.m}' returned an invalid port")
374
+ hashed_user = md5hex(rriot.u + ":" + rriot.k)[2:10]
375
+ hashed_password = md5hex(rriot.s + ":" + rriot.k)[16:]
376
+ return MqttParams(
377
+ host=str(url.hostname),
378
+ port=url.port,
379
+ tls=(url.scheme == "ssl"),
380
+ username=hashed_user,
381
+ password=hashed_password,
382
+ )
383
+
384
+
364
385
  Decoder = Callable[[bytes], list[RoborockMessage]]
365
386
  Encoder = Callable[[RoborockMessage], bytes]
366
387
 
@@ -73,3 +73,16 @@ class RoborockMqttClientA01(RoborockMqttClient, RoborockClientA01):
73
73
  payload=pad(json.dumps(payload).encode("utf-8"), AES.block_size),
74
74
  )
75
75
  )
76
+
77
+ async def set_value(
78
+ self, protocol: RoborockDyadDataProtocol | RoborockZeoProtocol, value: typing.Any
79
+ ) -> dict[int, typing.Any]:
80
+ """Set a value for a specific protocol on the A01 device."""
81
+ payload = {"dps": {int(protocol): value}}
82
+ return await self.send_message(
83
+ RoborockMessage(
84
+ protocol=RoborockMessageProtocol.RPC_REQUEST,
85
+ version=b"A01",
86
+ payload=pad(json.dumps(payload).encode("utf-8"), AES.block_size),
87
+ )
88
+ )