python-roborock 2.39.2__tar.gz → 2.40.1__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 (57) hide show
  1. {python_roborock-2.39.2 → python_roborock-2.40.1}/PKG-INFO +1 -1
  2. {python_roborock-2.39.2 → python_roborock-2.40.1}/pyproject.toml +1 -1
  3. python_roborock-2.40.1/roborock/broadcast_protocol.py +114 -0
  4. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/containers.py +1 -0
  5. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/protocol.py +2 -2
  6. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/protocols/v1_protocol.py +4 -5
  7. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/version_1_apis/roborock_client_v1.py +9 -8
  8. python_roborock-2.39.2/roborock/broadcast_protocol.py +0 -68
  9. {python_roborock-2.39.2 → python_roborock-2.40.1}/LICENSE +0 -0
  10. {python_roborock-2.39.2 → python_roborock-2.40.1}/README.md +0 -0
  11. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/__init__.py +0 -0
  12. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/api.py +0 -0
  13. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/b01_containers.py +0 -0
  14. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/callbacks.py +0 -0
  15. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/clean_modes.py +0 -0
  16. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/cli.py +0 -0
  17. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/cloud_api.py +0 -0
  18. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/code_mappings.py +0 -0
  19. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/command_cache.py +0 -0
  20. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/const.py +0 -0
  21. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/device_features.py +0 -0
  22. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/README.md +0 -0
  23. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/__init__.py +0 -0
  24. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/a01_channel.py +0 -0
  25. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/b01_channel.py +0 -0
  26. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/cache.py +0 -0
  27. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/channel.py +0 -0
  28. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/device.py +0 -0
  29. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/device_manager.py +0 -0
  30. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/local_channel.py +0 -0
  31. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/mqtt_channel.py +0 -0
  32. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/traits/b01/__init__.py +0 -0
  33. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/traits/b01/props.py +0 -0
  34. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/traits/dyad.py +0 -0
  35. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/traits/status.py +0 -0
  36. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/traits/trait.py +0 -0
  37. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/traits/zeo.py +0 -0
  38. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/v1_channel.py +0 -0
  39. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/devices/v1_rpc_channel.py +0 -0
  40. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/exceptions.py +0 -0
  41. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/mqtt/__init__.py +0 -0
  42. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/mqtt/roborock_session.py +0 -0
  43. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/mqtt/session.py +0 -0
  44. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/protocols/a01_protocol.py +0 -0
  45. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/protocols/b01_protocol.py +0 -0
  46. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/py.typed +0 -0
  47. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/roborock_future.py +0 -0
  48. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/roborock_message.py +0 -0
  49. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/roborock_typing.py +0 -0
  50. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/util.py +0 -0
  51. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/version_1_apis/__init__.py +0 -0
  52. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  53. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  54. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/version_a01_apis/__init__.py +0 -0
  55. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  56. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  57. {python_roborock-2.39.2 → python_roborock-2.40.1}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 2.39.2
3
+ Version: 2.40.1
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.39.2"
3
+ version = "2.40.1"
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"
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ from asyncio import BaseTransport, Lock
8
+
9
+ from construct import ( # type: ignore
10
+ Bytes,
11
+ Checksum,
12
+ GreedyBytes,
13
+ Int16ub,
14
+ Int32ub,
15
+ Prefixed,
16
+ RawCopy,
17
+ Struct,
18
+ )
19
+ from Crypto.Cipher import AES
20
+
21
+ from roborock import RoborockException
22
+ from roborock.containers import BroadcastMessage
23
+ from roborock.protocol import EncryptionAdapter, Utils, _Parser
24
+
25
+ _LOGGER = logging.getLogger(__name__)
26
+
27
+ BROADCAST_TOKEN = b"qWKYcdQWrbm9hPqe"
28
+
29
+
30
+ class RoborockProtocol(asyncio.DatagramProtocol):
31
+ def __init__(self, timeout: int = 5):
32
+ self.timeout = timeout
33
+ self.transport: BaseTransport | None = None
34
+ self.devices_found: list[BroadcastMessage] = []
35
+ self._mutex = Lock()
36
+
37
+ def datagram_received(self, data: bytes, _):
38
+ """Handle incoming broadcast datagrams."""
39
+ try:
40
+ version = data[:3]
41
+ if version == b"L01":
42
+ [parsed_msg], _ = L01Parser.parse(data)
43
+ encrypted_payload = parsed_msg.payload
44
+ if encrypted_payload is None:
45
+ raise RoborockException("No encrypted payload found in broadcast message")
46
+ ciphertext = encrypted_payload[:-16]
47
+ tag = encrypted_payload[-16:]
48
+
49
+ key = hashlib.sha256(BROADCAST_TOKEN).digest()
50
+ iv_digest_input = data[:9]
51
+ digest = hashlib.sha256(iv_digest_input).digest()
52
+ iv = digest[:12]
53
+
54
+ cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
55
+ decrypted_payload_bytes = cipher.decrypt_and_verify(ciphertext, tag)
56
+ json_payload = json.loads(decrypted_payload_bytes)
57
+ parsed_message = BroadcastMessage(duid=json_payload["duid"], ip=json_payload["ip"], version=version)
58
+ _LOGGER.debug(f"Received L01 broadcast: {parsed_message}")
59
+ self.devices_found.append(parsed_message)
60
+ else:
61
+ # Fallback to the original protocol parser for other versions
62
+ [broadcast_message], _ = BroadcastParser.parse(data)
63
+ if broadcast_message.payload:
64
+ json_payload = json.loads(broadcast_message.payload)
65
+ parsed_message = BroadcastMessage(duid=json_payload["duid"], ip=json_payload["ip"], version=version)
66
+ _LOGGER.debug(f"Received broadcast: {parsed_message}")
67
+ self.devices_found.append(parsed_message)
68
+ except Exception as e:
69
+ _LOGGER.warning(f"Failed to decode message: {data!r}. Error: {e}")
70
+
71
+ async def discover(self) -> list[BroadcastMessage]:
72
+ async with self._mutex:
73
+ try:
74
+ loop = asyncio.get_event_loop()
75
+ self.transport, _ = await loop.create_datagram_endpoint(lambda: self, local_addr=("0.0.0.0", 58866))
76
+ await asyncio.sleep(self.timeout)
77
+ return self.devices_found
78
+ finally:
79
+ self.close()
80
+ self.devices_found = []
81
+
82
+ def close(self):
83
+ self.transport.close() if self.transport else None
84
+
85
+
86
+ _BroadcastMessage = Struct(
87
+ "message"
88
+ / RawCopy(
89
+ Struct(
90
+ "version" / Bytes(3),
91
+ "seq" / Int32ub,
92
+ "protocol" / Int16ub,
93
+ "payload" / EncryptionAdapter(lambda ctx: BROADCAST_TOKEN),
94
+ )
95
+ ),
96
+ "checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data),
97
+ )
98
+
99
+ _L01BroadcastMessage = Struct(
100
+ "message"
101
+ / RawCopy(
102
+ Struct(
103
+ "version" / Bytes(3),
104
+ "field1" / Bytes(4), # Unknown field
105
+ "field2" / Bytes(2), # Unknown field
106
+ "payload" / Prefixed(Int16ub, GreedyBytes), # Encrypted payload with length prefix
107
+ )
108
+ ),
109
+ "checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data),
110
+ )
111
+
112
+
113
+ BroadcastParser: _Parser = _Parser(_BroadcastMessage, False)
114
+ L01Parser: _Parser = _Parser(_L01BroadcastMessage, False)
@@ -783,6 +783,7 @@ class FlowLedStatus(RoborockBase):
783
783
  class BroadcastMessage(RoborockBase):
784
784
  duid: str
785
785
  ip: str
786
+ version: bytes
786
787
 
787
788
 
788
789
  class ServerTimer(NamedTuple):
@@ -304,10 +304,10 @@ class _Parser:
304
304
  messages.append(
305
305
  RoborockMessage(
306
306
  version=message.message.value.version,
307
- seq=message.message.value.seq,
307
+ seq=message.message.value.get("seq"),
308
308
  random=message.message.value.get("random"),
309
309
  timestamp=message.message.value.get("timestamp"),
310
- protocol=message.message.value.protocol,
310
+ protocol=message.message.value.get("protocol"),
311
311
  payload=message.message.value.payload,
312
312
  )
313
313
  )
@@ -157,19 +157,18 @@ class MapResponse:
157
157
  """The map data, decrypted and decompressed."""
158
158
 
159
159
 
160
- def create_map_response_decoder(security_data: SecurityData) -> Callable[[RoborockMessage], MapResponse]:
160
+ def create_map_response_decoder(security_data: SecurityData) -> Callable[[RoborockMessage], MapResponse | None]:
161
161
  """Create a decoder for V1 map response messages."""
162
162
 
163
- def _decode_map_response(message: RoborockMessage) -> MapResponse:
163
+ def _decode_map_response(message: RoborockMessage) -> MapResponse | None:
164
164
  """Decode a V1 map response message."""
165
165
  if not message.payload or len(message.payload) < 24:
166
166
  raise RoborockException("Invalid V1 map response format: missing payload")
167
167
  header, body = message.payload[:24], message.payload[24:]
168
168
  [endpoint, _, request_id, _] = struct.unpack("<8s8sH6s", header)
169
169
  if not endpoint.decode().startswith(security_data.endpoint):
170
- raise RoborockException(
171
- f"Invalid V1 map response endpoint: {endpoint!r}, expected {security_data.endpoint!r}"
172
- )
170
+ _LOGGER.debug("Received map response requested not made by this device, ignoring.")
171
+ return None
173
172
  try:
174
173
  decrypted = Utils.decrypt_cbc(body, security_data.nonce)
175
174
  except ValueError as err:
@@ -150,7 +150,7 @@ class RoborockClientV1(RoborockClient, ABC):
150
150
  """Roborock client base class for version 1 devices."""
151
151
 
152
152
  _listeners: dict[str, ListenerModel] = {}
153
- _map_response_decoder: Callable[[RoborockMessage], MapResponse] | None = None
153
+ _map_response_decoder: Callable[[RoborockMessage], MapResponse | None] | None = None
154
154
 
155
155
  def __init__(self, device_info: DeviceData, security_data: SecurityData | None) -> None:
156
156
  """Initializes the Roborock client."""
@@ -439,13 +439,14 @@ class RoborockClientV1(RoborockClient, ABC):
439
439
  elif data.payload and protocol == RoborockMessageProtocol.MAP_RESPONSE:
440
440
  if self._map_response_decoder is not None:
441
441
  map_response = self._map_response_decoder(data)
442
- queue = self._waiting_queue.get(map_response.request_id)
443
- if queue:
444
- queue.set_result(map_response.data)
445
- else:
446
- self._logger.debug(
447
- "Received unsolicited map response for request_id %s", map_response.request_id
448
- )
442
+ if map_response is not None:
443
+ queue = self._waiting_queue.get(map_response.request_id)
444
+ if queue:
445
+ queue.set_result(map_response.data)
446
+ else:
447
+ self._logger.debug(
448
+ "Received unsolicited map response for request_id %s", map_response.request_id
449
+ )
449
450
  else:
450
451
  queue = self._waiting_queue.get(data.seq)
451
452
  if queue:
@@ -1,68 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import json
5
- import logging
6
- from asyncio import BaseTransport, Lock
7
-
8
- from construct import ( # type: ignore
9
- Bytes,
10
- Checksum,
11
- Int16ub,
12
- Int32ub,
13
- RawCopy,
14
- Struct,
15
- )
16
-
17
- from roborock.containers import BroadcastMessage
18
- from roborock.protocol import EncryptionAdapter, Utils, _Parser
19
-
20
- _LOGGER = logging.getLogger(__name__)
21
-
22
- BROADCAST_TOKEN = b"qWKYcdQWrbm9hPqe"
23
-
24
-
25
- class RoborockProtocol(asyncio.DatagramProtocol):
26
- def __init__(self, timeout: int = 5):
27
- self.timeout = timeout
28
- self.transport: BaseTransport | None = None
29
- self.devices_found: list[BroadcastMessage] = []
30
- self._mutex = Lock()
31
-
32
- def datagram_received(self, data, _):
33
- [broadcast_message], _ = BroadcastParser.parse(data)
34
- if broadcast_message.payload:
35
- parsed_message = BroadcastMessage.from_dict(json.loads(broadcast_message.payload))
36
- _LOGGER.debug(f"Received broadcast: {parsed_message}")
37
- self.devices_found.append(parsed_message)
38
-
39
- async def discover(self):
40
- async with self._mutex:
41
- try:
42
- loop = asyncio.get_event_loop()
43
- self.transport, _ = await loop.create_datagram_endpoint(lambda: self, local_addr=("0.0.0.0", 58866))
44
- await asyncio.sleep(self.timeout)
45
- return self.devices_found
46
- finally:
47
- self.close()
48
- self.devices_found = []
49
-
50
- def close(self):
51
- self.transport.close() if self.transport else None
52
-
53
-
54
- _BroadcastMessage = Struct(
55
- "message"
56
- / RawCopy(
57
- Struct(
58
- "version" / Bytes(3),
59
- "seq" / Int32ub,
60
- "protocol" / Int16ub,
61
- "payload" / EncryptionAdapter(lambda ctx: BROADCAST_TOKEN),
62
- )
63
- ),
64
- "checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data),
65
- )
66
-
67
-
68
- BroadcastParser: _Parser = _Parser(_BroadcastMessage, False)