python-roborock 3.14.3__tar.gz → 3.16.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 (100) hide show
  1. {python_roborock-3.14.3 → python_roborock-3.16.0}/PKG-INFO +1 -1
  2. {python_roborock-3.14.3 → python_roborock-3.16.0}/pyproject.toml +1 -1
  3. python_roborock-3.16.0/roborock/data/b01_q7/b01_q7_containers.py +130 -0
  4. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/containers.py +9 -0
  5. python_roborock-3.16.0/roborock/devices/b01_channel.py +77 -0
  6. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/device.py +20 -8
  7. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/b01/q7/__init__.py +5 -3
  8. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/mqtt/roborock_session.py +29 -15
  9. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/protocol.py +9 -6
  10. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/protocols/b01_protocol.py +5 -5
  11. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/web_api.py +23 -0
  12. python_roborock-3.14.3/roborock/data/b01_q7/b01_q7_containers.py +0 -130
  13. python_roborock-3.14.3/roborock/devices/b01_channel.py +0 -27
  14. {python_roborock-3.14.3 → python_roborock-3.16.0}/.gitignore +0 -0
  15. {python_roborock-3.14.3 → python_roborock-3.16.0}/LICENSE +0 -0
  16. {python_roborock-3.14.3 → python_roborock-3.16.0}/README.md +0 -0
  17. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/__init__.py +0 -0
  18. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/api.py +0 -0
  19. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/broadcast_protocol.py +0 -0
  20. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/callbacks.py +0 -0
  21. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/cli.py +0 -0
  22. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/cloud_api.py +0 -0
  23. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/command_cache.py +0 -0
  24. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/const.py +0 -0
  25. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/__init__.py +0 -0
  26. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/b01_q10/__init__.py +0 -0
  27. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  28. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  29. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/b01_q7/__init__.py +0 -0
  30. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  31. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/code_mappings.py +0 -0
  32. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/dyad/__init__.py +0 -0
  33. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  34. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/dyad/dyad_containers.py +0 -0
  35. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/v1/__init__.py +0 -0
  36. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  37. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  38. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/v1/v1_containers.py +0 -0
  39. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/zeo/__init__.py +0 -0
  40. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  41. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/data/zeo/zeo_containers.py +0 -0
  42. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/device_features.py +0 -0
  43. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/README.md +0 -0
  44. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/__init__.py +0 -0
  45. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/a01_channel.py +0 -0
  46. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/cache.py +0 -0
  47. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/channel.py +0 -0
  48. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/device_manager.py +0 -0
  49. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/file_cache.py +0 -0
  50. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/local_channel.py +0 -0
  51. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/mqtt_channel.py +0 -0
  52. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/__init__.py +0 -0
  53. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/a01/__init__.py +0 -0
  54. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/b01/__init__.py +0 -0
  55. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  56. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/traits_mixin.py +0 -0
  57. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/__init__.py +0 -0
  58. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  59. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  60. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/command.py +0 -0
  61. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/common.py +0 -0
  62. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  63. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/device_features.py +0 -0
  64. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  65. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  66. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  67. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/home.py +0 -0
  68. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/led_status.py +0 -0
  69. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/map_content.py +0 -0
  70. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/maps.py +0 -0
  71. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/network_info.py +0 -0
  72. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/rooms.py +0 -0
  73. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/routines.py +0 -0
  74. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  75. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/status.py +0 -0
  76. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  77. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/volume.py +0 -0
  78. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  79. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/devices/v1_channel.py +0 -0
  80. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/exceptions.py +0 -0
  81. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/map/__init__.py +0 -0
  82. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/map/map_parser.py +0 -0
  83. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/mqtt/__init__.py +0 -0
  84. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/mqtt/health_manager.py +0 -0
  85. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/mqtt/session.py +0 -0
  86. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/protocols/__init__.py +0 -0
  87. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/protocols/a01_protocol.py +0 -0
  88. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/protocols/v1_protocol.py +0 -0
  89. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/py.typed +0 -0
  90. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/roborock_future.py +0 -0
  91. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/roborock_message.py +0 -0
  92. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/roborock_typing.py +0 -0
  93. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/util.py +0 -0
  94. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/version_1_apis/__init__.py +0 -0
  95. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  96. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  97. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  98. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/version_a01_apis/__init__.py +0 -0
  99. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  100. {python_roborock-3.14.3 → python_roborock-3.16.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 3.14.3
3
+ Version: 3.16.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/humbertogontijo/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-roborock"
3
- version = "3.14.3"
3
+ version = "3.16.0"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
6
6
  requires-python = ">=3.11, <4"
@@ -0,0 +1,130 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from ..containers import RoborockBase
4
+ from .b01_q7_code_mappings import B01Fault, SCWindMapping, WorkModeMapping, WorkStatusMapping
5
+
6
+
7
+ @dataclass
8
+ class NetStatus(RoborockBase):
9
+ """Represents the network status of the device."""
10
+
11
+ rssi: str
12
+ loss: int
13
+ ping: int
14
+ ip: str
15
+ mac: str
16
+ ssid: str
17
+ frequency: int
18
+ bssid: str
19
+
20
+
21
+ @dataclass
22
+ class OrderTotal(RoborockBase):
23
+ """Represents the order total information."""
24
+
25
+ total: int
26
+ enable: int
27
+
28
+
29
+ @dataclass
30
+ class Privacy(RoborockBase):
31
+ """Represents the privacy settings of the device."""
32
+
33
+ ai_recognize: int
34
+ dirt_recognize: int
35
+ pet_recognize: int
36
+ carpet_turbo: int
37
+ carpet_avoid: int
38
+ carpet_show: int
39
+ map_uploads: int
40
+ ai_agent: int
41
+ ai_avoidance: int
42
+ record_uploads: int
43
+ along_floor: int
44
+ auto_upgrade: int
45
+
46
+
47
+ @dataclass
48
+ class PvCharging(RoborockBase):
49
+ """Represents the photovoltaic charging status."""
50
+
51
+ status: int
52
+ begin_time: int
53
+ end_time: int
54
+
55
+
56
+ @dataclass
57
+ class Recommend(RoborockBase):
58
+ """Represents cleaning recommendations."""
59
+
60
+ sill: int
61
+ wall: int
62
+ room_id: list[int] = field(default_factory=list)
63
+
64
+
65
+ @dataclass
66
+ class B01Props(RoborockBase):
67
+ """
68
+ Represents the complete properties and status for a Roborock B01 model.
69
+ This dataclass is generated based on the device's status JSON object.
70
+ """
71
+
72
+ status: WorkStatusMapping | None = None
73
+ fault: B01Fault | None = None
74
+ wind: SCWindMapping | None = None
75
+ water: int | None = None
76
+ mode: int | None = None
77
+ quantity: int | None = None
78
+ alarm: int | None = None
79
+ volume: int | None = None
80
+ hypa: int | None = None
81
+ main_brush: int | None = None
82
+ side_brush: int | None = None
83
+ mop_life: int | None = None
84
+ main_sensor: int | None = None
85
+ net_status: NetStatus | None = None
86
+ repeat_state: int | None = None
87
+ tank_state: int | None = None
88
+ sweep_type: int | None = None
89
+ clean_path_preference: int | None = None
90
+ cloth_state: int | None = None
91
+ time_zone: int | None = None
92
+ time_zone_info: str | None = None
93
+ language: int | None = None
94
+ cleaning_time: int | None = None
95
+ real_clean_time: int | None = None
96
+ cleaning_area: int | None = None
97
+ custom_type: int | None = None
98
+ sound: int | None = None
99
+ work_mode: WorkModeMapping | None = None
100
+ station_act: int | None = None
101
+ charge_state: int | None = None
102
+ current_map_id: int | None = None
103
+ map_num: int | None = None
104
+ dust_action: int | None = None
105
+ quiet_is_open: int | None = None
106
+ quiet_begin_time: int | None = None
107
+ quiet_end_time: int | None = None
108
+ clean_finish: int | None = None
109
+ voice_type: int | None = None
110
+ voice_type_version: int | None = None
111
+ order_total: OrderTotal | None = None
112
+ build_map: int | None = None
113
+ privacy: Privacy | None = None
114
+ dust_auto_state: int | None = None
115
+ dust_frequency: int | None = None
116
+ child_lock: int | None = None
117
+ multi_floor: int | None = None
118
+ map_save: int | None = None
119
+ light_mode: int | None = None
120
+ green_laser: int | None = None
121
+ dust_bag_used: int | None = None
122
+ order_save_mode: int | None = None
123
+ manufacturer: str | None = None
124
+ back_to_wash: int | None = None
125
+ charge_station_type: int | None = None
126
+ pv_cut_charge: int | None = None
127
+ pv_charging: PvCharging | None = None
128
+ serial_number: str | None = None
129
+ recommend: Recommend | None = None
130
+ add_sweep_status: int | None = None
@@ -284,6 +284,15 @@ class HomeDataScene(RoborockBase):
284
284
  name: str
285
285
 
286
286
 
287
+ @dataclass
288
+ class HomeDataSchedule(RoborockBase):
289
+ id: int
290
+ cron: str
291
+ repeated: bool
292
+ enabled: bool
293
+ param: dict | None = None
294
+
295
+
287
296
  @dataclass
288
297
  class HomeData(RoborockBase):
289
298
  id: int
@@ -0,0 +1,77 @@
1
+ """Thin wrapper around the MQTT channel for Roborock B01 devices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from typing import Any
9
+
10
+ from roborock.exceptions import RoborockException
11
+ from roborock.protocols.b01_protocol import (
12
+ CommandType,
13
+ ParamsType,
14
+ decode_rpc_response,
15
+ encode_mqtt_payload,
16
+ )
17
+ from roborock.roborock_message import RoborockMessage
18
+ from roborock.util import get_next_int
19
+
20
+ from .mqtt_channel import MqttChannel
21
+
22
+ _LOGGER = logging.getLogger(__name__)
23
+ _TIMEOUT = 10.0
24
+
25
+
26
+ async def send_decoded_command(
27
+ mqtt_channel: MqttChannel,
28
+ dps: int,
29
+ command: CommandType,
30
+ params: ParamsType,
31
+ ) -> dict[str, Any]:
32
+ """Send a command on the MQTT channel and get a decoded response."""
33
+ _LOGGER.debug("Sending MQTT command: %s", params)
34
+ msg_id = str(get_next_int(100000000000, 999999999999))
35
+ roborock_message = encode_mqtt_payload(dps, command, params, msg_id)
36
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
37
+
38
+ def find_response(response_message: RoborockMessage) -> None:
39
+ """Handle incoming messages and resolve the future."""
40
+ try:
41
+ decoded_dps = decode_rpc_response(response_message)
42
+ except RoborockException as ex:
43
+ _LOGGER.info("Failed to decode b01 message: %s: %s", response_message, ex)
44
+ return
45
+
46
+ for dps_value in decoded_dps.values():
47
+ # valid responses are JSON strings wrapped in the dps value
48
+ if not isinstance(dps_value, str):
49
+ _LOGGER.debug("Received unexpected response: %s", dps_value)
50
+ continue
51
+
52
+ try:
53
+ inner = json.loads(dps_value)
54
+ except (json.JSONDecodeError, TypeError):
55
+ _LOGGER.debug("Received unexpected response: %s", dps_value)
56
+ continue
57
+
58
+ if isinstance(inner, dict) and inner.get("msgId") == msg_id:
59
+ _LOGGER.debug("Received query response: %s", inner)
60
+ data = inner.get("data")
61
+ if not future.done():
62
+ if isinstance(data, dict):
63
+ future.set_result(data)
64
+ else:
65
+ future.set_exception(RoborockException(f"Unexpected data type for response: {data}"))
66
+
67
+ unsub = await mqtt_channel.subscribe(find_response)
68
+
69
+ _LOGGER.debug("Sending MQTT message: %s", roborock_message)
70
+ try:
71
+ await mqtt_channel.publish(roborock_message)
72
+ try:
73
+ return await asyncio.wait_for(future, timeout=_TIMEOUT)
74
+ except TimeoutError as ex:
75
+ raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
76
+ finally:
77
+ unsub()
@@ -147,34 +147,45 @@ class RoborockDevice(ABC, TraitsMixin):
147
147
  called. The device will automatically attempt to reconnect if the connection
148
148
  is lost.
149
149
  """
150
- start_attempt: asyncio.Event = asyncio.Event()
150
+ # The future will be set to True if the first attempt succeeds, False if
151
+ # it fails, or an exception if an unexpected error occurs.
152
+ # We use this to wait a short time for the first attempt to complete. We
153
+ # don't actually care about the result, just that we waited long enough.
154
+ start_attempt: asyncio.Future[bool] = asyncio.Future()
151
155
 
152
156
  async def connect_loop() -> None:
153
- backoff = MIN_BACKOFF_INTERVAL
154
157
  try:
158
+ backoff = MIN_BACKOFF_INTERVAL
155
159
  while True:
156
160
  try:
157
161
  await self.connect()
158
- start_attempt.set()
162
+ if not start_attempt.done():
163
+ start_attempt.set_result(True)
159
164
  self._has_connected = True
160
165
  self._ready_callbacks(self)
161
166
  return
162
167
  except RoborockException as e:
163
- start_attempt.set()
168
+ if not start_attempt.done():
169
+ start_attempt.set_result(False)
164
170
  self._logger.info("Failed to connect (retry %s): %s", backoff.total_seconds(), e)
165
171
  await asyncio.sleep(backoff.total_seconds())
166
172
  backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
173
+ except Exception as e: # pylint: disable=broad-except
174
+ if not start_attempt.done():
175
+ start_attempt.set_exception(e)
176
+ self._logger.exception("Uncaught error during connect: %s", e)
177
+ return
167
178
  except asyncio.CancelledError:
168
179
  self._logger.debug("connect_loop was cancelled for device %s", self.duid)
169
- # Clean exit on cancellation
170
- return
171
180
  finally:
172
- start_attempt.set()
181
+ if not start_attempt.done():
182
+ start_attempt.set_result(False)
173
183
 
174
184
  self._connect_task = asyncio.create_task(connect_loop())
175
185
 
176
186
  try:
177
- await asyncio.wait_for(start_attempt.wait(), timeout=START_ATTEMPT_TIMEOUT.total_seconds())
187
+ async with asyncio.timeout(START_ATTEMPT_TIMEOUT.total_seconds()):
188
+ await start_attempt
178
189
  except TimeoutError:
179
190
  self._logger.debug("Initial connection attempt took longer than expected, will keep trying in background")
180
191
 
@@ -189,6 +200,7 @@ class RoborockDevice(ABC, TraitsMixin):
189
200
  except RoborockException:
190
201
  unsub()
191
202
  raise
203
+ self._logger.info("Connected to device")
192
204
  self._unsub = unsub
193
205
 
194
206
  async def close(self) -> None:
@@ -1,6 +1,7 @@
1
1
  """Traits for Q7 B01 devices.
2
2
  Potentially other devices may fall into this category in the future."""
3
3
 
4
+ from roborock import B01Props
4
5
  from roborock.devices.b01_channel import send_decoded_command
5
6
  from roborock.devices.mqtt_channel import MqttChannel
6
7
  from roborock.devices.traits import Trait
@@ -13,17 +14,18 @@ __all__ = [
13
14
 
14
15
 
15
16
  class Q7PropertiesApi(Trait):
16
- """API for interacting with Q7 B01 devices."""
17
+ """API for interacting with B01 devices."""
17
18
 
18
19
  def __init__(self, channel: MqttChannel) -> None:
19
20
  """Initialize the B01Props API."""
20
21
  self._channel = channel
21
22
 
22
- async def query_values(self, props: list[RoborockB01Props]) -> None:
23
+ async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
23
24
  """Query the device for the values of the given Q7 properties."""
24
- await send_decoded_command(
25
+ result = await send_decoded_command(
25
26
  self._channel, dps=10000, command=RoborockB01Q7Methods.GET_PROP, params={"property": props}
26
27
  )
28
+ return B01Props.from_dict(result)
27
29
 
28
30
 
29
31
  def create(channel: MqttChannel) -> Q7PropertiesApi:
@@ -69,6 +69,7 @@ class RoborockMqttSession(MqttSession):
69
69
  self._stop = False
70
70
  self._backoff = MIN_BACKOFF_INTERVAL
71
71
  self._client: aiomqtt.Client | None = None
72
+ self._client_subscribed_topics: set[str] = set()
72
73
  self._client_lock = asyncio.Lock()
73
74
  self._listeners: CallbackMap[str, bytes] = CallbackMap(_LOGGER)
74
75
  self._connection_task: asyncio.Task[None] | None = None
@@ -218,7 +219,7 @@ class RoborockMqttSession(MqttSession):
218
219
  # Re-establish any existing subscriptions
219
220
  async with self._client_lock:
220
221
  self._client = client
221
- for topic in self._listeners.keys():
222
+ for topic in self._client_subscribed_topics:
222
223
  _LOGGER.debug("Re-establishing subscription to topic %s", topic)
223
224
  # TODO: If this fails it will break the whole connection. Make
224
225
  # this retry again in the background with backoff.
@@ -249,32 +250,42 @@ class RoborockMqttSession(MqttSession):
249
250
  unsub = self._listeners.add_callback(topic, callback)
250
251
 
251
252
  async with self._client_lock:
252
- if self._client:
253
- _LOGGER.debug("Establishing subscription to topic %s", topic)
254
- try:
255
- await self._client.subscribe(topic)
256
- except MqttError as err:
257
- # Clean up the callback if subscription fails
258
- unsub()
259
- raise MqttSessionException(f"Error subscribing to topic: {err}") from err
260
- else:
261
- _LOGGER.debug("Client not connected, will establish subscription later")
262
-
263
- def schedule_unsubscribe():
253
+ if topic not in self._client_subscribed_topics:
254
+ self._client_subscribed_topics.add(topic)
255
+ if self._client:
256
+ _LOGGER.debug("Establishing subscription to topic %s", topic)
257
+ try:
258
+ await self._client.subscribe(topic)
259
+ except MqttError as err:
260
+ # Clean up the callback if subscription fails
261
+ unsub()
262
+ self._client_subscribed_topics.discard(topic)
263
+ raise MqttSessionException(f"Error subscribing to topic: {err}") from err
264
+ else:
265
+ _LOGGER.debug("Client not connected, will establish subscription later")
266
+
267
+ def schedule_unsubscribe() -> None:
264
268
  async def idle_unsubscribe():
265
269
  try:
266
270
  await asyncio.sleep(self._topic_idle_timeout.total_seconds())
267
271
  # Only unsubscribe if there are no callbacks left for this topic
268
272
  if not self._listeners.get_callbacks(topic):
269
273
  async with self._client_lock:
274
+ # Check again if we have listeners, in case a subscribe happened
275
+ # while we were waiting for the lock or after we popped the timer.
276
+ if self._listeners.get_callbacks(topic):
277
+ _LOGGER.debug("Skipping unsubscribe for %s, new listeners added", topic)
278
+ return
279
+
280
+ self._idle_timers.pop(topic, None)
281
+ self._client_subscribed_topics.discard(topic)
282
+
270
283
  if self._client:
271
284
  _LOGGER.debug("Idle timeout expired, unsubscribing from topic %s", topic)
272
285
  try:
273
286
  await self._client.unsubscribe(topic)
274
287
  except MqttError as err:
275
288
  _LOGGER.warning("Error unsubscribing from topic %s: %s", topic, err)
276
- # Clean up timer from dict
277
- self._idle_timers.pop(topic, None)
278
289
  except asyncio.CancelledError:
279
290
  _LOGGER.debug("Idle unsubscribe for topic %s cancelled", topic)
280
291
 
@@ -286,7 +297,10 @@ class RoborockMqttSession(MqttSession):
286
297
  unsub() # Remove the callback from CallbackMap
287
298
  # If no more callbacks for this topic, start idle timer
288
299
  if not self._listeners.get_callbacks(topic):
300
+ _LOGGER.debug("Unsubscribing topic %s, starting idle timer", topic)
289
301
  schedule_unsubscribe()
302
+ else:
303
+ _LOGGER.debug("Unsubscribing topic %s, still have active callbacks", topic)
290
304
 
291
305
  return delayed_unsub
292
306
 
@@ -276,12 +276,11 @@ class EncryptionAdapter(Construct):
276
276
  if context.version == b"A01":
277
277
  iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24]
278
278
  decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
279
- f = decipher.encrypt(obj)
280
- return f
279
+ return decipher.encrypt(pad(obj, AES.block_size))
281
280
  elif context.version == b"B01":
282
281
  iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25]
283
282
  decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
284
- return decipher.encrypt(obj)
283
+ return decipher.encrypt(pad(obj, AES.block_size))
285
284
  elif context.version == b"L01":
286
285
  return Utils.encrypt_gcm_l01(
287
286
  plaintext=obj,
@@ -301,12 +300,11 @@ class EncryptionAdapter(Construct):
301
300
  if context.version == b"A01":
302
301
  iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24]
303
302
  decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
304
- f = decipher.decrypt(obj)
305
- return f
303
+ return unpad(decipher.decrypt(obj), AES.block_size)
306
304
  elif context.version == b"B01":
307
305
  iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25]
308
306
  decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
309
- return decipher.decrypt(obj)
307
+ return unpad(decipher.decrypt(obj), AES.block_size)
310
308
  elif context.version == b"L01":
311
309
  return Utils.decrypt_gcm_l01(
312
310
  payload=obj,
@@ -350,6 +348,11 @@ class PrefixedStruct(Struct):
350
348
  # Read remaining data to find a valid header
351
349
  data = stream.read()
352
350
 
351
+ if not data:
352
+ # EOF reached, let the parser fail naturally without logging
353
+ stream_seek(stream, current_pos, 0, path)
354
+ return super()._parse(stream, context, path)
355
+
353
356
  start_index = -1
354
357
  # Find the earliest occurrence of any valid version in a single pass
355
358
  for i in range(len(data) - 2):
@@ -13,7 +13,6 @@ from roborock.roborock_message import (
13
13
  RoborockMessage,
14
14
  RoborockMessageProtocol,
15
15
  )
16
- from roborock.util import get_next_int
17
16
 
18
17
  _LOGGER = logging.getLogger(__name__)
19
18
 
@@ -22,13 +21,13 @@ CommandType = RoborockB01Q7Methods | str
22
21
  ParamsType = list | dict | int | None
23
22
 
24
23
 
25
- def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType) -> RoborockMessage:
24
+ def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType, msg_id: str) -> RoborockMessage:
26
25
  """Encode payload for B01 commands over MQTT."""
27
26
  dps_data = {
28
27
  "dps": {
29
28
  dps: {
30
29
  "method": str(command),
31
- "msgId": str(get_next_int(100000000000, 999999999999)),
30
+ "msgId": msg_id,
32
31
  "params": params or [],
33
32
  }
34
33
  }
@@ -47,8 +46,9 @@ def decode_rpc_response(message: RoborockMessage) -> dict[int, Any]:
47
46
  raise RoborockException("Invalid B01 message format: missing payload")
48
47
  try:
49
48
  unpadded = unpad(message.payload, AES.block_size)
50
- except ValueError as err:
51
- raise RoborockException(f"Unable to unpad B01 payload: {err}")
49
+ except ValueError:
50
+ # It would be better to fail down the line.
51
+ unpadded = message.payload
52
52
 
53
53
  try:
54
54
  payload = json.loads(unpadded.decode())
@@ -14,6 +14,7 @@ import aiohttp
14
14
  from aiohttp import ContentTypeError, FormData
15
15
  from pyrate_limiter import BucketFullException, Duration, Limiter, Rate
16
16
 
17
+ from roborock import HomeDataSchedule
17
18
  from roborock.data import HomeData, HomeDataRoom, HomeDataScene, ProductResponse, RRiot, UserData
18
19
  from roborock.exceptions import (
19
20
  RoborockAccountDoesNotExist,
@@ -607,6 +608,28 @@ class RoborockApiClient:
607
608
  if not execute_scene_response.get("success"):
608
609
  raise RoborockException(execute_scene_response)
609
610
 
611
+ async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]:
612
+ rriot = user_data.rriot
613
+ if rriot is None:
614
+ raise RoborockException("rriot is none")
615
+ if rriot.r.a is None:
616
+ raise RoborockException("Missing field 'a' in rriot reference")
617
+ schedules_request = PreparedRequest(
618
+ rriot.r.a,
619
+ self.session,
620
+ {
621
+ "Authorization": _get_hawk_authentication(rriot, f"/user/devices/{device_id}/jobs"),
622
+ },
623
+ )
624
+ schedules_response = await schedules_request.request("get", f"/user/devices/{str(device_id)}/jobs")
625
+ if not schedules_response.get("success"):
626
+ raise RoborockException(schedules_response)
627
+ schedules = schedules_response.get("result")
628
+ if isinstance(schedules, list):
629
+ return [HomeDataSchedule.from_dict(schedule) for schedule in schedules]
630
+ else:
631
+ raise RoborockException(f"schedule_response result was an unexpected type: {schedules}")
632
+
610
633
  async def get_products(self, user_data: UserData) -> ProductResponse:
611
634
  """Gets all products and their schemas, good for determining status codes and model numbers."""
612
635
  base_url = await self.base_url
@@ -1,130 +0,0 @@
1
- from dataclasses import dataclass, field
2
-
3
- from ..containers import RoborockBase
4
- from .b01_q7_code_mappings import B01Fault, SCWindMapping, WorkModeMapping, WorkStatusMapping
5
-
6
-
7
- @dataclass
8
- class NetStatus(RoborockBase):
9
- """Represents the network status of the device."""
10
-
11
- rssi: str
12
- loss: int
13
- ping: int
14
- ip: str
15
- mac: str
16
- ssid: str
17
- frequency: int
18
- bssid: str
19
-
20
-
21
- @dataclass
22
- class OrderTotal(RoborockBase):
23
- """Represents the order total information."""
24
-
25
- total: int
26
- enable: int
27
-
28
-
29
- @dataclass
30
- class Privacy(RoborockBase):
31
- """Represents the privacy settings of the device."""
32
-
33
- ai_recognize: int
34
- dirt_recognize: int
35
- pet_recognize: int
36
- carpet_turbo: int
37
- carpet_avoid: int
38
- carpet_show: int
39
- map_uploads: int
40
- ai_agent: int
41
- ai_avoidance: int
42
- record_uploads: int
43
- along_floor: int
44
- auto_upgrade: int
45
-
46
-
47
- @dataclass
48
- class PvCharging(RoborockBase):
49
- """Represents the photovoltaic charging status."""
50
-
51
- status: int
52
- begin_time: int
53
- end_time: int
54
-
55
-
56
- @dataclass
57
- class Recommend(RoborockBase):
58
- """Represents cleaning recommendations."""
59
-
60
- sill: int
61
- wall: int
62
- room_id: list[int] = field(default_factory=list)
63
-
64
-
65
- @dataclass
66
- class B01Props(RoborockBase):
67
- """
68
- Represents the complete properties and status for a Roborock B01 model.
69
- This dataclass is generated based on the device's status JSON object.
70
- """
71
-
72
- status: WorkStatusMapping
73
- fault: B01Fault
74
- wind: SCWindMapping
75
- water: int
76
- mode: int
77
- quantity: int
78
- alarm: int
79
- volume: int
80
- hypa: int
81
- main_brush: int
82
- side_brush: int
83
- mop_life: int
84
- main_sensor: int
85
- net_status: NetStatus
86
- repeat_state: int
87
- tank_state: int
88
- sweep_type: int
89
- clean_path_preference: int
90
- cloth_state: int
91
- time_zone: int
92
- time_zone_info: str
93
- language: int
94
- cleaning_time: int
95
- real_clean_time: int
96
- cleaning_area: int
97
- custom_type: int
98
- sound: int
99
- work_mode: WorkModeMapping
100
- station_act: int
101
- charge_state: int
102
- current_map_id: int
103
- map_num: int
104
- dust_action: int
105
- quiet_is_open: int
106
- quiet_begin_time: int
107
- quiet_end_time: int
108
- clean_finish: int
109
- voice_type: int
110
- voice_type_version: int
111
- order_total: OrderTotal
112
- build_map: int
113
- privacy: Privacy
114
- dust_auto_state: int
115
- dust_frequency: int
116
- child_lock: int
117
- multi_floor: int
118
- map_save: int
119
- light_mode: int
120
- green_laser: int
121
- dust_bag_used: int
122
- order_save_mode: int
123
- manufacturer: str
124
- back_to_wash: int
125
- charge_station_type: int
126
- pv_cut_charge: int
127
- pv_charging: PvCharging
128
- serial_number: str
129
- recommend: Recommend
130
- add_sweep_status: int
@@ -1,27 +0,0 @@
1
- """Thin wrapper around the MQTT channel for Roborock B01 devices."""
2
-
3
- from __future__ import annotations
4
-
5
- import logging
6
-
7
- from roborock.protocols.b01_protocol import (
8
- CommandType,
9
- ParamsType,
10
- encode_mqtt_payload,
11
- )
12
-
13
- from .mqtt_channel import MqttChannel
14
-
15
- _LOGGER = logging.getLogger(__name__)
16
-
17
-
18
- async def send_decoded_command(
19
- mqtt_channel: MqttChannel,
20
- dps: int,
21
- command: CommandType,
22
- params: ParamsType,
23
- ) -> None:
24
- """Send a command on the MQTT channel and get a decoded response."""
25
- _LOGGER.debug("Sending MQTT command: %s", params)
26
- roborock_message = encode_mqtt_payload(dps, command, params)
27
- await mqtt_channel.publish(roborock_message)