python-roborock 2.40.1__tar.gz → 2.41.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.40.1 → python_roborock-2.41.1}/PKG-INFO +1 -1
  2. {python_roborock-2.40.1 → python_roborock-2.41.1}/pyproject.toml +1 -1
  3. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/containers.py +15 -14
  4. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/device_manager.py +2 -0
  5. python_roborock-2.41.1/roborock/devices/traits/dnd.py +41 -0
  6. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/traits/status.py +3 -3
  7. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/v1_channel.py +8 -4
  8. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/v1_rpc_channel.py +19 -16
  9. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/protocols/v1_protocol.py +3 -1
  10. {python_roborock-2.40.1 → python_roborock-2.41.1}/LICENSE +0 -0
  11. {python_roborock-2.40.1 → python_roborock-2.41.1}/README.md +0 -0
  12. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/__init__.py +0 -0
  13. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/api.py +0 -0
  14. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/b01_containers.py +0 -0
  15. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/broadcast_protocol.py +0 -0
  16. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/callbacks.py +0 -0
  17. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/clean_modes.py +0 -0
  18. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/cli.py +0 -0
  19. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/cloud_api.py +0 -0
  20. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/code_mappings.py +0 -0
  21. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/command_cache.py +0 -0
  22. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/const.py +0 -0
  23. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/device_features.py +0 -0
  24. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/README.md +0 -0
  25. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/__init__.py +0 -0
  26. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/a01_channel.py +0 -0
  27. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/b01_channel.py +0 -0
  28. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/cache.py +0 -0
  29. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/channel.py +0 -0
  30. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/device.py +0 -0
  31. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/local_channel.py +0 -0
  32. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/mqtt_channel.py +0 -0
  33. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/traits/b01/__init__.py +0 -0
  34. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/traits/b01/props.py +0 -0
  35. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/traits/dyad.py +0 -0
  36. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/traits/trait.py +0 -0
  37. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/devices/traits/zeo.py +0 -0
  38. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/exceptions.py +0 -0
  39. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/mqtt/__init__.py +0 -0
  40. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/mqtt/roborock_session.py +0 -0
  41. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/mqtt/session.py +0 -0
  42. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/protocol.py +0 -0
  43. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/protocols/a01_protocol.py +0 -0
  44. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/protocols/b01_protocol.py +0 -0
  45. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/py.typed +0 -0
  46. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/roborock_future.py +0 -0
  47. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/roborock_message.py +0 -0
  48. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/roborock_typing.py +0 -0
  49. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/util.py +0 -0
  50. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/version_1_apis/__init__.py +0 -0
  51. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  52. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  53. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  54. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/version_a01_apis/__init__.py +0 -0
  55. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  56. {python_roborock-2.40.1 → python_roborock-2.41.1}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  57. {python_roborock-2.40.1 → python_roborock-2.41.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.40.1
3
+ Version: 2.41.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.40.1"
3
+ version = "2.41.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"
@@ -109,8 +109,6 @@ def _decamelize(s: str):
109
109
 
110
110
  @dataclass
111
111
  class RoborockBase:
112
- _ignore_keys = [] # type: ignore
113
-
114
112
  @staticmethod
115
113
  def _convert_to_class_obj(class_type: type, value):
116
114
  if get_origin(class_type) is list:
@@ -134,8 +132,8 @@ class RoborockBase:
134
132
  return None
135
133
  field_types = {field.name: field.type for field in dataclasses.fields(cls)}
136
134
  result: dict[str, Any] = {}
137
- for key, value in data.items():
138
- key = _decamelize(key)
135
+ for orig_key, value in data.items():
136
+ key = _decamelize(orig_key)
139
137
  if (field_type := field_types.get(key)) is None:
140
138
  continue
141
139
  if value == "None" or value is None:
@@ -178,16 +176,18 @@ class RoborockBaseTimer(RoborockBase):
178
176
  end_hour: int | None = None
179
177
  end_minute: int | None = None
180
178
  enabled: int | None = None
181
- start_time: datetime.time | None = None
182
- end_time: datetime.time | None = None
183
179
 
184
- def __post_init__(self) -> None:
185
- self.start_time = (
180
+ @property
181
+ def start_time(self) -> datetime.time | None:
182
+ return (
186
183
  datetime.time(hour=self.start_hour, minute=self.start_minute)
187
184
  if self.start_hour is not None and self.start_minute is not None
188
185
  else None
189
186
  )
190
- self.end_time = (
187
+
188
+ @property
189
+ def end_time(self) -> datetime.time | None:
190
+ return (
191
191
  datetime.time(hour=self.end_hour, minute=self.end_minute)
192
192
  if self.end_hour is not None and self.end_minute is not None
193
193
  else None
@@ -684,19 +684,20 @@ class MultiMapsListMapInfoBakMaps(RoborockBase):
684
684
 
685
685
  @dataclass
686
686
  class MultiMapsListMapInfo(RoborockBase):
687
- _ignore_keys = ["mapFlag"]
688
-
689
- mapFlag: int
687
+ map_flag: int
690
688
  name: str
691
689
  add_time: Any | None = None
692
690
  length: Any | None = None
693
691
  bak_maps: list[MultiMapsListMapInfoBakMaps] | None = None
694
692
 
693
+ @property
694
+ def mapFlag(self) -> int:
695
+ """Alias for map_flag, returns the map flag as an integer."""
696
+ return self.map_flag
697
+
695
698
 
696
699
  @dataclass
697
700
  class MultiMapsList(RoborockBase):
698
- _ignore_keys = ["mapFlag"]
699
-
700
701
  max_multi_map: int | None = None
701
702
  max_bak_map: int | None = None
702
703
  multi_map_count: int | None = None
@@ -22,6 +22,7 @@ from .cache import Cache, NoCache
22
22
  from .channel import Channel
23
23
  from .mqtt_channel import create_mqtt_channel
24
24
  from .traits.b01.props import B01PropsApi
25
+ from .traits.dnd import DoNotDisturbTrait
25
26
  from .traits.dyad import DyadApi
26
27
  from .traits.status import StatusTrait
27
28
  from .traits.trait import Trait
@@ -152,6 +153,7 @@ async def create_device_manager(
152
153
  case DeviceVersion.V1:
153
154
  channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, cache)
154
155
  traits.append(StatusTrait(product, channel.rpc_channel))
156
+ traits.append(DoNotDisturbTrait(channel.rpc_channel))
155
157
  case DeviceVersion.A01:
156
158
  mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
157
159
  match product.category:
@@ -0,0 +1,41 @@
1
+ """Module for Roborock V1 devices.
2
+
3
+ This interface is experimental and subject to breaking changes without notice
4
+ until the API is stable.
5
+ """
6
+
7
+ import logging
8
+
9
+ from roborock.containers import DnDTimer
10
+ from roborock.devices.v1_rpc_channel import V1RpcChannel
11
+ from roborock.roborock_typing import RoborockCommand
12
+
13
+ from .trait import Trait
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+ __all__ = [
18
+ "DoNotDisturbTrait",
19
+ ]
20
+
21
+
22
+ class DoNotDisturbTrait(Trait):
23
+ """Trait for managing Do Not Disturb (DND) settings on Roborock devices."""
24
+
25
+ name = "do_not_disturb"
26
+
27
+ def __init__(self, rpc_channel: V1RpcChannel) -> None:
28
+ """Initialize the DoNotDisturbTrait."""
29
+ self._rpc_channel = rpc_channel
30
+
31
+ async def get_dnd_timer(self) -> DnDTimer:
32
+ """Get the current Do Not Disturb (DND) timer settings of the device."""
33
+ return await self._rpc_channel.send_command(RoborockCommand.GET_DND_TIMER, response_type=DnDTimer)
34
+
35
+ async def set_dnd_timer(self, dnd_timer: DnDTimer) -> None:
36
+ """Set the Do Not Disturb (DND) timer settings of the device."""
37
+ await self._rpc_channel.send_command(RoborockCommand.SET_DND_TIMER, params=dnd_timer.as_dict())
38
+
39
+ async def clear_dnd_timer(self) -> None:
40
+ """Clear the Do Not Disturb (DND) timer settings of the device."""
41
+ await self._rpc_channel.send_command(RoborockCommand.CLOSE_DND_TIMER)
@@ -12,20 +12,20 @@ from roborock.containers import (
12
12
  S7MaxVStatus,
13
13
  Status,
14
14
  )
15
+ from roborock.devices.v1_rpc_channel import V1RpcChannel
15
16
  from roborock.roborock_typing import RoborockCommand
16
17
 
17
- from ..v1_rpc_channel import V1RpcChannel
18
18
  from .trait import Trait
19
19
 
20
20
  _LOGGER = logging.getLogger(__name__)
21
21
 
22
22
  __all__ = [
23
- "Status",
23
+ "StatusTrait",
24
24
  ]
25
25
 
26
26
 
27
27
  class StatusTrait(Trait):
28
- """Unified Roborock device class with automatic connection setup."""
28
+ """Trait for managing the status of Roborock devices."""
29
29
 
30
30
  name = "status"
31
31
 
@@ -22,7 +22,7 @@ from .cache import Cache
22
22
  from .channel import Channel
23
23
  from .local_channel import LocalChannel, LocalSession, create_local_session
24
24
  from .mqtt_channel import MqttChannel
25
- from .v1_rpc_channel import V1RpcChannel, create_combined_rpc_channel, create_mqtt_rpc_channel
25
+ from .v1_rpc_channel import PickFirstAvailable, V1RpcChannel, create_local_rpc_channel, create_mqtt_rpc_channel
26
26
 
27
27
  _LOGGER = logging.getLogger(__name__)
28
28
 
@@ -60,7 +60,11 @@ class V1Channel(Channel):
60
60
  self._mqtt_rpc_channel = create_mqtt_rpc_channel(mqtt_channel, security_data)
61
61
  self._local_session = local_session
62
62
  self._local_channel: LocalChannel | None = None
63
- self._combined_rpc_channel: V1RpcChannel | None = None
63
+ self._local_rpc_channel: V1RpcChannel | None = None
64
+ # Prefer local, fallback to MQTT
65
+ self._combined_rpc_channel = PickFirstAvailable(
66
+ [lambda: self._local_rpc_channel, lambda: self._mqtt_rpc_channel]
67
+ )
64
68
  self._mqtt_unsub: Callable[[], None] | None = None
65
69
  self._local_unsub: Callable[[], None] | None = None
66
70
  self._callback: Callable[[RoborockMessage], None] | None = None
@@ -84,7 +88,7 @@ class V1Channel(Channel):
84
88
  @property
85
89
  def rpc_channel(self) -> V1RpcChannel:
86
90
  """Return the combined RPC channel prefers local with a fallback to MQTT."""
87
- return self._combined_rpc_channel or self._mqtt_rpc_channel
91
+ return self._combined_rpc_channel
88
92
 
89
93
  @property
90
94
  def mqtt_rpc_channel(self) -> V1RpcChannel:
@@ -160,7 +164,7 @@ class V1Channel(Channel):
160
164
  except RoborockException as e:
161
165
  self._local_channel = None
162
166
  raise RoborockException(f"Error connecting to local device {self._device_uid}: {e}") from e
163
- self._combined_rpc_channel = create_combined_rpc_channel(self._local_channel, self._mqtt_rpc_channel)
167
+ self._local_rpc_channel = create_local_rpc_channel(self._local_channel)
164
168
  return await self._local_channel.subscribe(self._on_local_message)
165
169
 
166
170
  def _on_mqtt_message(self, message: RoborockMessage) -> None:
@@ -88,16 +88,15 @@ class BaseV1RpcChannel(V1RpcChannel):
88
88
  raise NotImplementedError
89
89
 
90
90
 
91
- class CombinedV1RpcChannel(BaseV1RpcChannel):
92
- """A V1 RPC channel that can use both local and MQTT channels, preferring local when available."""
91
+ class PickFirstAvailable(BaseV1RpcChannel):
92
+ """A V1 RPC channel that tries multiple channels and picks the first that works."""
93
93
 
94
94
  def __init__(
95
- self, local_channel: LocalChannel, local_rpc_channel: V1RpcChannel, mqtt_channel: V1RpcChannel
95
+ self,
96
+ channel_cbs: list[Callable[[], V1RpcChannel | None]],
96
97
  ) -> None:
97
- """Initialize the combined channel with local and MQTT channels."""
98
- self._local_channel = local_channel
99
- self._local_rpc_channel = local_rpc_channel
100
- self._mqtt_rpc_channel = mqtt_channel
98
+ """Initialize the pick-first-available channel."""
99
+ self._channel_cbs = channel_cbs
101
100
 
102
101
  async def _send_raw_command(
103
102
  self,
@@ -106,9 +105,10 @@ class CombinedV1RpcChannel(BaseV1RpcChannel):
106
105
  params: ParamsType = None,
107
106
  ) -> Any:
108
107
  """Send a command and return a parsed response RoborockBase type."""
109
- if self._local_channel.is_connected:
110
- return await self._local_rpc_channel.send_command(method, params=params)
111
- return await self._mqtt_rpc_channel.send_command(method, params=params)
108
+ for channel_cb in self._channel_cbs:
109
+ if channel := channel_cb():
110
+ return await channel.send_command(method, params=params)
111
+ raise RoborockException("No available connection to send command")
112
112
 
113
113
 
114
114
  class PayloadEncodedV1RpcChannel(BaseV1RpcChannel):
@@ -132,8 +132,10 @@ class PayloadEncodedV1RpcChannel(BaseV1RpcChannel):
132
132
  params: ParamsType = None,
133
133
  ) -> Any:
134
134
  """Send a command and return a parsed response RoborockBase type."""
135
- _LOGGER.debug("Sending command (%s): %s, params=%s", self._name, method, params)
136
135
  request_message = RequestMessage(method, params=params)
136
+ _LOGGER.debug(
137
+ "Sending command (%s, request_id=%s): %s, params=%s", self._name, request_message.request_id, method, params
138
+ )
137
139
  message = self._payload_encoder(request_message)
138
140
 
139
141
  future: asyncio.Future[dict[str, Any]] = asyncio.Future()
@@ -141,8 +143,10 @@ class PayloadEncodedV1RpcChannel(BaseV1RpcChannel):
141
143
  def find_response(response_message: RoborockMessage) -> None:
142
144
  try:
143
145
  decoded = decode_rpc_response(response_message)
144
- except RoborockException:
146
+ except RoborockException as ex:
147
+ _LOGGER.debug("Exception while decoding message (%s): %s", response_message, ex)
145
148
  return
149
+ _LOGGER.debug("Received response (request_id=%s): %s", self._name, decoded.request_id)
146
150
  if decoded.request_id == request_message.request_id:
147
151
  future.set_result(decoded.data)
148
152
 
@@ -166,11 +170,10 @@ def create_mqtt_rpc_channel(mqtt_channel: MqttChannel, security_data: SecurityDa
166
170
  )
167
171
 
168
172
 
169
- def create_combined_rpc_channel(local_channel: LocalChannel, mqtt_rpc_channel: V1RpcChannel) -> V1RpcChannel:
170
- """Create a V1 RPC channel that combines local and MQTT channels."""
171
- local_rpc_channel = PayloadEncodedV1RpcChannel(
173
+ def create_local_rpc_channel(local_channel: LocalChannel) -> V1RpcChannel:
174
+ """Create a V1 RPC channel using a local channel."""
175
+ return PayloadEncodedV1RpcChannel(
172
176
  "local",
173
177
  local_channel,
174
178
  lambda x: x.encode_message(RoborockMessageProtocol.GENERAL_REQUEST),
175
179
  )
176
- return CombinedV1RpcChannel(local_channel, local_rpc_channel, mqtt_rpc_channel)
@@ -109,7 +109,7 @@ class ResponseMessage:
109
109
  def decode_rpc_response(message: RoborockMessage) -> ResponseMessage:
110
110
  """Decode a V1 RPC_RESPONSE message."""
111
111
  if not message.payload:
112
- raise RoborockException("Invalid V1 message format: missing payload")
112
+ return ResponseMessage(request_id=message.seq, data={})
113
113
  try:
114
114
  payload = json.loads(message.payload.decode())
115
115
  except (json.JSONDecodeError, TypeError) as e:
@@ -141,6 +141,8 @@ def decode_rpc_response(message: RoborockMessage) -> ResponseMessage:
141
141
  _LOGGER.debug("Decoded V1 message result: %s", result)
142
142
  if isinstance(result, list) and result:
143
143
  result = result[0]
144
+ if isinstance(result, str) and result == "ok":
145
+ result = {}
144
146
  if not isinstance(result, dict):
145
147
  raise RoborockException(f"Invalid V1 message format: 'result' should be a dictionary for {message.payload!r}")
146
148
  return ResponseMessage(request_id=request_id, data=result)