python-roborock 3.8.3__tar.gz → 3.8.5__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 (97) hide show
  1. {python_roborock-3.8.3 → python_roborock-3.8.5}/PKG-INFO +1 -1
  2. {python_roborock-3.8.3 → python_roborock-3.8.5}/pyproject.toml +1 -1
  3. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/cache.py +7 -1
  4. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/mqtt_channel.py +1 -1
  5. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/__init__.py +1 -1
  6. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/common.py +1 -1
  7. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/home.py +28 -6
  8. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/v1_channel.py +178 -37
  9. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/protocols/v1_protocol.py +35 -2
  10. python_roborock-3.8.3/roborock/devices/v1_rpc_channel.py +0 -221
  11. {python_roborock-3.8.3 → python_roborock-3.8.5}/.gitignore +0 -0
  12. {python_roborock-3.8.3 → python_roborock-3.8.5}/LICENSE +0 -0
  13. {python_roborock-3.8.3 → python_roborock-3.8.5}/README.md +0 -0
  14. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/__init__.py +0 -0
  15. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/api.py +0 -0
  16. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/broadcast_protocol.py +0 -0
  17. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/callbacks.py +0 -0
  18. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/cli.py +0 -0
  19. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/cloud_api.py +0 -0
  20. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/command_cache.py +0 -0
  21. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/const.py +0 -0
  22. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/__init__.py +0 -0
  23. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/b01_q10/__init__.py +0 -0
  24. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  25. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  26. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/b01_q7/__init__.py +0 -0
  27. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  28. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  29. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/code_mappings.py +0 -0
  30. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/containers.py +0 -0
  31. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/dyad/__init__.py +0 -0
  32. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  33. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/dyad/dyad_containers.py +0 -0
  34. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/v1/__init__.py +0 -0
  35. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/v1/v1_clean_modes.py +0 -0
  36. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/v1/v1_code_mappings.py +0 -0
  37. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/v1/v1_containers.py +0 -0
  38. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/zeo/__init__.py +0 -0
  39. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  40. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/data/zeo/zeo_containers.py +0 -0
  41. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/device_features.py +0 -0
  42. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/README.md +0 -0
  43. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/__init__.py +0 -0
  44. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/a01_channel.py +0 -0
  45. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/b01_channel.py +0 -0
  46. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/channel.py +0 -0
  47. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/device.py +0 -0
  48. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/device_manager.py +0 -0
  49. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/file_cache.py +0 -0
  50. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/local_channel.py +0 -0
  51. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/__init__.py +0 -0
  52. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/a01/__init__.py +0 -0
  53. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/b01/__init__.py +0 -0
  54. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/traits_mixin.py +0 -0
  55. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/child_lock.py +0 -0
  56. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/clean_summary.py +0 -0
  57. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/command.py +0 -0
  58. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/consumeable.py +0 -0
  59. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/device_features.py +0 -0
  60. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  61. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  62. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  63. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/led_status.py +0 -0
  64. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/map_content.py +0 -0
  65. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/maps.py +0 -0
  66. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/network_info.py +0 -0
  67. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/rooms.py +0 -0
  68. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/routines.py +0 -0
  69. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  70. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/status.py +0 -0
  71. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  72. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/volume.py +0 -0
  73. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  74. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/exceptions.py +0 -0
  75. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/map/__init__.py +0 -0
  76. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/map/map_parser.py +0 -0
  77. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/mqtt/__init__.py +0 -0
  78. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/mqtt/health_manager.py +0 -0
  79. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/mqtt/roborock_session.py +0 -0
  80. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/mqtt/session.py +0 -0
  81. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/protocol.py +0 -0
  82. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/protocols/__init__.py +0 -0
  83. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/protocols/a01_protocol.py +0 -0
  84. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/protocols/b01_protocol.py +0 -0
  85. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/py.typed +0 -0
  86. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/roborock_future.py +0 -0
  87. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/roborock_message.py +0 -0
  88. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/roborock_typing.py +0 -0
  89. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/util.py +0 -0
  90. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/version_1_apis/__init__.py +0 -0
  91. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  92. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  93. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  94. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/version_a01_apis/__init__.py +0 -0
  95. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  96. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  97. {python_roborock-3.8.3 → python_roborock-3.8.5}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 3.8.3
3
+ Version: 3.8.5
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.8.3"
3
+ version = "3.8.5"
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"
@@ -26,7 +26,13 @@ class CacheData:
26
26
  """Home map information indexed by map_flag."""
27
27
 
28
28
  home_map_content: dict[int, bytes] = field(default_factory=dict)
29
- """Home cache content for each map data indexed by map_flag."""
29
+ """Home cache content for each map data indexed by map_flag.
30
+
31
+ This is deprecated in favor of `home_map_content_base64`.
32
+ """
33
+
34
+ home_map_content_base64: dict[int, str] = field(default_factory=dict)
35
+ """Home cache content for each map data (encoded base64) indexed by map_flag."""
30
36
 
31
37
  device_features: DeviceFeatures | None = None
32
38
  """Device features information."""
@@ -90,5 +90,5 @@ class MqttChannel(Channel):
90
90
  def create_mqtt_channel(
91
91
  user_data: UserData, mqtt_params: MqttParams, mqtt_session: MqttSession, device: HomeDataDevice
92
92
  ) -> MqttChannel:
93
- """Create a V1Channel for the given device."""
93
+ """Create a MQTT channel for the given device."""
94
94
  return MqttChannel(mqtt_session, device.duid, device.local_key, user_data.rriot, mqtt_params)
@@ -38,8 +38,8 @@ from roborock.data.containers import HomeData, HomeDataProduct, RoborockBase
38
38
  from roborock.data.v1.v1_code_mappings import RoborockDockTypeCode
39
39
  from roborock.devices.cache import Cache
40
40
  from roborock.devices.traits import Trait
41
- from roborock.devices.v1_rpc_channel import V1RpcChannel
42
41
  from roborock.map.map_parser import MapParserConfig
42
+ from roborock.protocols.v1_protocol import V1RpcChannel
43
43
  from roborock.web_api import UserWebApiClient
44
44
 
45
45
  from .child_lock import ChildLockTrait
@@ -9,7 +9,7 @@ from dataclasses import dataclass, fields
9
9
  from typing import ClassVar, Self
10
10
 
11
11
  from roborock.data import RoborockBase
12
- from roborock.devices.v1_rpc_channel import V1RpcChannel
12
+ from roborock.protocols.v1_protocol import V1RpcChannel
13
13
  from roborock.roborock_typing import RoborockCommand
14
14
 
15
15
  _LOGGER = logging.getLogger(__name__)
@@ -16,6 +16,7 @@ the current map's information and room names as needed.
16
16
  """
17
17
 
18
18
  import asyncio
19
+ import base64
19
20
  import logging
20
21
  from typing import Self
21
22
 
@@ -86,14 +87,20 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
86
87
  After discovery, the home cache will be populated and can be accessed via the `home_map_info` property.
87
88
  """
88
89
  cache_data = await self._cache.get()
89
- if cache_data.home_map_info and cache_data.home_map_content:
90
+ if cache_data.home_map_info and (cache_data.home_map_content or cache_data.home_map_content_base64):
90
91
  _LOGGER.debug("Home cache already populated, skipping discovery")
91
92
  self._home_map_info = cache_data.home_map_info
92
93
  self._discovery_completed = True
93
94
  try:
94
- self._home_map_content = {
95
- k: self._map_content.parse_map_content(v) for k, v in cache_data.home_map_content.items()
96
- }
95
+ if cache_data.home_map_content_base64:
96
+ self._home_map_content = {
97
+ k: self._map_content.parse_map_content(base64.b64decode(v))
98
+ for k, v in cache_data.home_map_content_base64.items()
99
+ }
100
+ else:
101
+ self._home_map_content = {
102
+ k: self._map_content.parse_map_content(v) for k, v in cache_data.home_map_content.items()
103
+ }
97
104
  except (ValueError, RoborockException) as ex:
98
105
  _LOGGER.warning("Failed to parse cached home map content, will re-discover: %s", ex)
99
106
  self._home_map_content = {}
@@ -218,7 +225,12 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
218
225
  """Update the entire home cache with new map info and content."""
219
226
  cache_data = await self._cache.get()
220
227
  cache_data.home_map_info = home_map_info
221
- cache_data.home_map_content = {k: v.raw_api_response for k, v in home_map_content.items() if v.raw_api_response}
228
+ cache_data.home_map_content_base64 = {
229
+ k: base64.b64encode(v.raw_api_response).decode("utf-8")
230
+ for k, v in home_map_content.items()
231
+ if v.raw_api_response
232
+ }
233
+ cache_data.home_map_content = {}
222
234
  await self._cache.set(cache_data)
223
235
  self._home_map_info = home_map_info
224
236
  self._home_map_content = home_map_content
@@ -237,8 +249,18 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
237
249
  if update_cache:
238
250
  cache_data = await self._cache.get()
239
251
  cache_data.home_map_info[map_flag] = map_info
252
+ # Migrate existing cached content to base64 if needed
253
+ if cache_data.home_map_content and not cache_data.home_map_content_base64:
254
+ cache_data.home_map_content_base64 = {
255
+ k: base64.b64encode(v).decode("utf-8") for k, v in cache_data.home_map_content.items()
256
+ }
257
+ cache_data.home_map_content = {}
240
258
  if map_content.raw_api_response:
241
- cache_data.home_map_content[map_flag] = map_content.raw_api_response
259
+ if cache_data.home_map_content_base64 is None:
260
+ cache_data.home_map_content_base64 = {}
261
+ cache_data.home_map_content_base64[map_flag] = base64.b64encode(map_content.raw_api_response).decode(
262
+ "utf-8"
263
+ )
242
264
  await self._cache.set(cache_data)
243
265
 
244
266
  if self._home_map_info is None:
@@ -8,37 +8,43 @@ import asyncio
8
8
  import datetime
9
9
  import logging
10
10
  from collections.abc import Callable
11
- from typing import TypeVar
11
+ from dataclasses import dataclass
12
+ from typing import Any, TypeVar
12
13
 
13
14
  from roborock.data import HomeDataDevice, NetworkInfo, RoborockBase, UserData
14
15
  from roborock.exceptions import RoborockException
16
+ from roborock.mqtt.health_manager import HealthManager
15
17
  from roborock.mqtt.session import MqttParams, MqttSession
16
18
  from roborock.protocols.v1_protocol import (
19
+ CommandType,
20
+ MapResponse,
21
+ ParamsType,
22
+ RequestMessage,
23
+ ResponseData,
24
+ ResponseMessage,
17
25
  SecurityData,
26
+ V1RpcChannel,
27
+ create_map_response_decoder,
18
28
  create_security_data,
29
+ decode_rpc_response,
19
30
  )
20
- from roborock.roborock_message import RoborockMessage
31
+ from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
21
32
  from roborock.roborock_typing import RoborockCommand
22
33
 
23
34
  from .cache import Cache
24
35
  from .channel import Channel
25
36
  from .local_channel import LocalChannel, LocalSession, create_local_session
26
37
  from .mqtt_channel import MqttChannel
27
- from .v1_rpc_channel import (
28
- PickFirstAvailable,
29
- V1RpcChannel,
30
- create_local_rpc_channel,
31
- create_map_rpc_channel,
32
- create_mqtt_rpc_channel,
33
- )
34
38
 
35
39
  _LOGGER = logging.getLogger(__name__)
36
40
 
37
41
  __all__ = [
38
- "V1Channel",
42
+ "create_v1_channel",
39
43
  ]
40
44
 
41
45
  _T = TypeVar("_T", bound=RoborockBase)
46
+ _TIMEOUT = 10.0
47
+
42
48
 
43
49
  # Exponential backoff parameters for reconnecting to local
44
50
  MIN_RECONNECT_INTERVAL = datetime.timedelta(minutes=1)
@@ -50,6 +56,106 @@ NETWORK_INFO_REFRESH_INTERVAL = datetime.timedelta(hours=12)
50
56
  LOCAL_CONNECTION_CHECK_INTERVAL = datetime.timedelta(seconds=15)
51
57
 
52
58
 
59
+ @dataclass(frozen=True)
60
+ class RpcStrategy:
61
+ """Strategy for encoding/sending/decoding RPC commands."""
62
+
63
+ name: str # For debug logging
64
+ channel: LocalChannel | MqttChannel
65
+ encoder: Callable[[RequestMessage], RoborockMessage]
66
+ decoder: Callable[[RoborockMessage], ResponseMessage | MapResponse | None]
67
+ health_manager: HealthManager | None = None
68
+
69
+
70
+ class RpcChannel(V1RpcChannel):
71
+ """Provides an RPC interface around a pub/sub transport channel."""
72
+
73
+ def __init__(self, rpc_strategies: list[RpcStrategy]) -> None:
74
+ """Initialize the RpcChannel with on ordered list of strategies."""
75
+ self._rpc_strategies = rpc_strategies
76
+
77
+ async def send_command(
78
+ self,
79
+ method: CommandType,
80
+ *,
81
+ response_type: type[_T] | None = None,
82
+ params: ParamsType = None,
83
+ ) -> _T | Any:
84
+ """Send a command and return either a decoded or parsed response."""
85
+ request = RequestMessage(method, params=params)
86
+
87
+ # Try each channel in order until one succeeds
88
+ last_exception = None
89
+ for strategy in self._rpc_strategies:
90
+ try:
91
+ decoded_response = await self._send_rpc(strategy, request)
92
+ except RoborockException as e:
93
+ _LOGGER.warning("Command %s failed on %s channel: %s", method, strategy.name, e)
94
+ last_exception = e
95
+ except Exception as e:
96
+ _LOGGER.exception("Unexpected error sending command %s on %s channel", method, strategy.name)
97
+ last_exception = RoborockException(f"Unexpected error: {e}")
98
+ else:
99
+ if response_type is not None:
100
+ if not isinstance(decoded_response, dict):
101
+ raise RoborockException(
102
+ f"Expected dict response to parse {response_type.__name__}, got {type(decoded_response)}"
103
+ )
104
+ return response_type.from_dict(decoded_response)
105
+ return decoded_response
106
+
107
+ raise last_exception or RoborockException("No available connection to send command")
108
+
109
+ @staticmethod
110
+ async def _send_rpc(strategy: RpcStrategy, request: RequestMessage) -> ResponseData | bytes:
111
+ """Send a command and return a decoded response type.
112
+
113
+ This provides an RPC interface over a given channel strategy. The device
114
+ channel only supports publish and subscribe, so this function handles
115
+ associating requests with their corresponding responses.
116
+ """
117
+ future: asyncio.Future[ResponseData | bytes] = asyncio.Future()
118
+ _LOGGER.debug(
119
+ "Sending command (%s, request_id=%s): %s, params=%s",
120
+ strategy.name,
121
+ request.request_id,
122
+ request.method,
123
+ request.params,
124
+ )
125
+
126
+ message = strategy.encoder(request)
127
+
128
+ def find_response(response_message: RoborockMessage) -> None:
129
+ try:
130
+ decoded = strategy.decoder(response_message)
131
+ except RoborockException as ex:
132
+ _LOGGER.debug("Exception while decoding message (%s): %s", response_message, ex)
133
+ return
134
+ if decoded is None:
135
+ return
136
+ _LOGGER.debug("Received response (%s, request_id=%s)", strategy.name, decoded.request_id)
137
+ if decoded.request_id == request.request_id:
138
+ if isinstance(decoded, ResponseMessage) and decoded.api_error:
139
+ future.set_exception(decoded.api_error)
140
+ else:
141
+ future.set_result(decoded.data)
142
+
143
+ unsub = await strategy.channel.subscribe(find_response)
144
+ try:
145
+ await strategy.channel.publish(message)
146
+ result = await asyncio.wait_for(future, timeout=_TIMEOUT)
147
+ except TimeoutError as ex:
148
+ if strategy.health_manager:
149
+ await strategy.health_manager.on_timeout()
150
+ future.cancel()
151
+ raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
152
+ finally:
153
+ unsub()
154
+ if strategy.health_manager:
155
+ await strategy.health_manager.on_success()
156
+ return result
157
+
158
+
53
159
  class V1Channel(Channel):
54
160
  """Unified V1 protocol channel with automatic MQTT/local connection handling.
55
161
 
@@ -66,23 +172,13 @@ class V1Channel(Channel):
66
172
  local_session: LocalSession,
67
173
  cache: Cache,
68
174
  ) -> None:
69
- """Initialize the V1Channel.
70
-
71
- Args:
72
- mqtt_channel: MQTT channel for cloud communication
73
- local_session: Factory that creates LocalChannels for a hostname.
74
- """
175
+ """Initialize the V1Channel."""
75
176
  self._device_uid = device_uid
177
+ self._security_data = security_data
76
178
  self._mqtt_channel = mqtt_channel
77
- self._mqtt_rpc_channel = create_mqtt_rpc_channel(mqtt_channel, security_data)
179
+ self._mqtt_health_manager = HealthManager(self._mqtt_channel.restart)
78
180
  self._local_session = local_session
79
181
  self._local_channel: LocalChannel | None = None
80
- self._local_rpc_channel: V1RpcChannel | None = None
81
- # Prefer local, fallback to MQTT
82
- self._combined_rpc_channel = PickFirstAvailable(
83
- [lambda: self._local_rpc_channel, lambda: self._mqtt_rpc_channel]
84
- )
85
- self._map_rpc_channel = create_map_rpc_channel(mqtt_channel, security_data)
86
182
  self._mqtt_unsub: Callable[[], None] | None = None
87
183
  self._local_unsub: Callable[[], None] | None = None
88
184
  self._callback: Callable[[RoborockMessage], None] | None = None
@@ -107,18 +203,60 @@ class V1Channel(Channel):
107
203
 
108
204
  @property
109
205
  def rpc_channel(self) -> V1RpcChannel:
110
- """Return the combined RPC channel prefers local with a fallback to MQTT."""
111
- return self._combined_rpc_channel
206
+ """Return the combined RPC channel that prefers local with a fallback to MQTT."""
207
+ strategies = []
208
+ if local_rpc_strategy := self._create_local_rpc_strategy():
209
+ strategies.append(local_rpc_strategy)
210
+ strategies.append(self._create_mqtt_rpc_strategy())
211
+ return RpcChannel(strategies)
112
212
 
113
213
  @property
114
214
  def mqtt_rpc_channel(self) -> V1RpcChannel:
115
- """Return the MQTT RPC channel."""
116
- return self._mqtt_rpc_channel
215
+ """Return the MQTT-only RPC channel."""
216
+ return RpcChannel([self._create_mqtt_rpc_strategy()])
117
217
 
118
218
  @property
119
219
  def map_rpc_channel(self) -> V1RpcChannel:
120
220
  """Return the map RPC channel used for fetching map content."""
121
- return self._map_rpc_channel
221
+ decoder = create_map_response_decoder(security_data=self._security_data)
222
+ return RpcChannel([self._create_mqtt_rpc_strategy(decoder)])
223
+
224
+ def _create_local_rpc_strategy(self) -> RpcStrategy | None:
225
+ """Create the RPC strategy for local transport."""
226
+ if self._local_channel is None or not self.is_local_connected:
227
+ return None
228
+ return RpcStrategy(
229
+ name="local",
230
+ channel=self._local_channel,
231
+ encoder=self._local_encoder,
232
+ decoder=decode_rpc_response,
233
+ )
234
+
235
+ def _local_encoder(self, x: RequestMessage) -> RoborockMessage:
236
+ """Encode a request message for local transport.
237
+
238
+ This will read the current local channel's protocol version which
239
+ changes as the protocol version is discovered.
240
+ """
241
+ if self._local_channel is None:
242
+ raise ValueError("Local channel unavailable for encoding")
243
+ return x.encode_message(
244
+ RoborockMessageProtocol.GENERAL_REQUEST,
245
+ version=self._local_channel.protocol_version,
246
+ )
247
+
248
+ def _create_mqtt_rpc_strategy(self, decoder: Callable[[RoborockMessage], Any] = decode_rpc_response) -> RpcStrategy:
249
+ """Create the RPC strategy for MQTT transport with optional custom decoder."""
250
+ return RpcStrategy(
251
+ name="mqtt",
252
+ channel=self._mqtt_channel,
253
+ encoder=lambda x: x.encode_message(
254
+ RoborockMessageProtocol.RPC_REQUEST,
255
+ security_data=self._security_data,
256
+ ),
257
+ decoder=decoder,
258
+ health_manager=self._mqtt_health_manager,
259
+ )
122
260
 
123
261
  async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
124
262
  """Subscribe to all messages from the device.
@@ -142,7 +280,7 @@ class V1Channel(Channel):
142
280
  # Make an initial, optimistic attempt to connect to local with the
143
281
  # cache. The cache information will be refreshed by the background task.
144
282
  try:
145
- await self._local_connect(use_cache=True)
283
+ await self._local_connect(prefer_cache=True)
146
284
  except RoborockException as err:
147
285
  _LOGGER.warning("Could not establish local connection for device %s: %s", self._device_uid, err)
148
286
 
@@ -175,20 +313,24 @@ class V1Channel(Channel):
175
313
  self._callback = callback
176
314
  return unsub
177
315
 
178
- async def _get_networking_info(self, *, use_cache: bool = True) -> NetworkInfo:
316
+ async def _get_networking_info(self, *, prefer_cache: bool = True) -> NetworkInfo:
179
317
  """Retrieve networking information for the device.
180
318
 
181
319
  This is a cloud only command used to get the local device's IP address.
182
320
  """
183
321
  cache_data = await self._cache.get()
184
- if use_cache and cache_data.network_info and (network_info := cache_data.network_info.get(self._device_uid)):
322
+ if prefer_cache and cache_data.network_info and (network_info := cache_data.network_info.get(self._device_uid)):
185
323
  _LOGGER.debug("Using cached network info for device %s", self._device_uid)
186
324
  return network_info
187
325
  try:
188
- network_info = await self._mqtt_rpc_channel.send_command(
326
+ network_info = await self.mqtt_rpc_channel.send_command(
189
327
  RoborockCommand.GET_NETWORK_INFO, response_type=NetworkInfo
190
328
  )
191
329
  except RoborockException as e:
330
+ _LOGGER.debug("Error fetching network info for device %s", self._device_uid)
331
+ if cache_data.network_info and (network_info := cache_data.network_info.get(self._device_uid)):
332
+ _LOGGER.debug("Falling back to cached network info for device %s after error", self._device_uid)
333
+ return network_info
192
334
  raise RoborockException(f"Network info failed for device {self._device_uid}") from e
193
335
  _LOGGER.debug("Network info for device %s: %s", self._device_uid, network_info)
194
336
  self._last_network_info_refresh = datetime.datetime.now(datetime.UTC)
@@ -196,12 +338,12 @@ class V1Channel(Channel):
196
338
  await self._cache.set(cache_data)
197
339
  return network_info
198
340
 
199
- async def _local_connect(self, *, use_cache: bool = True) -> None:
341
+ async def _local_connect(self, *, prefer_cache: bool = True) -> None:
200
342
  """Set up local connection if possible."""
201
343
  _LOGGER.debug(
202
- "Attempting to connect to local channel for device %s (use_cache=%s)", self._device_uid, use_cache
344
+ "Attempting to connect to local channel for device %s (prefer_cache=%s)", self._device_uid, prefer_cache
203
345
  )
204
- networking_info = await self._get_networking_info(use_cache=use_cache)
346
+ networking_info = await self._get_networking_info(prefer_cache=prefer_cache)
205
347
  host = networking_info.ip
206
348
  _LOGGER.debug("Connecting to local channel at %s", host)
207
349
  # Create a new local channel and connect
@@ -212,7 +354,6 @@ class V1Channel(Channel):
212
354
  raise RoborockException(f"Error connecting to local device {self._device_uid}: {e}") from e
213
355
  # Wire up the new channel
214
356
  self._local_channel = local_channel
215
- self._local_rpc_channel = create_local_rpc_channel(self._local_channel)
216
357
  self._local_unsub = await self._local_channel.subscribe(self._on_local_message)
217
358
  _LOGGER.info("Successfully connected to local device %s", self._device_uid)
218
359
 
@@ -236,7 +377,7 @@ class V1Channel(Channel):
236
377
  reconnect_backoff = min(reconnect_backoff * RECONNECT_MULTIPLIER, MAX_RECONNECT_INTERVAL)
237
378
 
238
379
  use_cache = self._should_use_cache(local_connect_failures)
239
- await self._local_connect(use_cache=use_cache)
380
+ await self._local_connect(prefer_cache=use_cache)
240
381
  # Reset backoff and failures on success
241
382
  reconnect_backoff = MIN_RECONNECT_INTERVAL
242
383
  local_connect_failures = 0
@@ -12,9 +12,9 @@ import time
12
12
  from collections.abc import Callable
13
13
  from dataclasses import dataclass, field
14
14
  from enum import StrEnum
15
- from typing import Any
15
+ from typing import Any, Protocol, TypeVar, overload
16
16
 
17
- from roborock.data import RRiot
17
+ from roborock.data import RoborockBase, RRiot
18
18
  from roborock.exceptions import RoborockException, RoborockUnsupportedFeature
19
19
  from roborock.protocol import Utils
20
20
  from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
@@ -27,6 +27,7 @@ __all__ = [
27
27
  "SecurityData",
28
28
  "create_security_data",
29
29
  "decode_rpc_response",
30
+ "V1RpcChannel",
30
31
  ]
31
32
 
32
33
  CommandType = RoborockCommand | str
@@ -208,3 +209,35 @@ def create_map_response_decoder(security_data: SecurityData) -> Callable[[Roboro
208
209
  return MapResponse(request_id=request_id, data=decompressed)
209
210
 
210
211
  return _decode_map_response
212
+
213
+
214
+ _T = TypeVar("_T", bound=RoborockBase)
215
+
216
+
217
+ class V1RpcChannel(Protocol):
218
+ """Protocol for V1 RPC channels.
219
+
220
+ This is a wrapper around a raw channel that provides a high-level interface
221
+ for sending commands and receiving responses.
222
+ """
223
+
224
+ @overload
225
+ async def send_command(
226
+ self,
227
+ method: CommandType,
228
+ *,
229
+ params: ParamsType = None,
230
+ ) -> Any:
231
+ """Send a command and return a decoded response."""
232
+ ...
233
+
234
+ @overload
235
+ async def send_command(
236
+ self,
237
+ method: CommandType,
238
+ *,
239
+ response_type: type[_T],
240
+ params: ParamsType = None,
241
+ ) -> _T:
242
+ """Send a command and return a parsed response RoborockBase type."""
243
+ ...
@@ -1,221 +0,0 @@
1
- """V1 Rpc Channel for Roborock devices.
2
-
3
- This is a wrapper around the V1 channel that provides a higher level interface
4
- for sending typed commands and receiving typed responses. This also provides
5
- a simple interface for sending commands and receiving responses over both MQTT
6
- and local connections, preferring local when available.
7
- """
8
-
9
- import asyncio
10
- import logging
11
- from collections.abc import Callable
12
- from typing import Any, Protocol, TypeVar, overload
13
-
14
- from roborock.data import RoborockBase
15
- from roborock.exceptions import RoborockException
16
- from roborock.mqtt.health_manager import HealthManager
17
- from roborock.protocols.v1_protocol import (
18
- CommandType,
19
- MapResponse,
20
- ParamsType,
21
- RequestMessage,
22
- ResponseData,
23
- ResponseMessage,
24
- SecurityData,
25
- create_map_response_decoder,
26
- decode_rpc_response,
27
- )
28
- from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
29
-
30
- from .local_channel import LocalChannel
31
- from .mqtt_channel import MqttChannel
32
-
33
- _LOGGER = logging.getLogger(__name__)
34
- _TIMEOUT = 10.0
35
-
36
-
37
- _T = TypeVar("_T", bound=RoborockBase)
38
- _V = TypeVar("_V")
39
-
40
-
41
- class V1RpcChannel(Protocol):
42
- """Protocol for V1 RPC channels.
43
-
44
- This is a wrapper around a raw channel that provides a high-level interface
45
- for sending commands and receiving responses.
46
- """
47
-
48
- @overload
49
- async def send_command(
50
- self,
51
- method: CommandType,
52
- *,
53
- params: ParamsType = None,
54
- ) -> Any:
55
- """Send a command and return a decoded response."""
56
- ...
57
-
58
- @overload
59
- async def send_command(
60
- self,
61
- method: CommandType,
62
- *,
63
- response_type: type[_T],
64
- params: ParamsType = None,
65
- ) -> _T:
66
- """Send a command and return a parsed response RoborockBase type."""
67
- ...
68
-
69
-
70
- class BaseV1RpcChannel(V1RpcChannel):
71
- """Base implementation that provides the typed response logic."""
72
-
73
- async def send_command(
74
- self,
75
- method: CommandType,
76
- *,
77
- response_type: type[_T] | None = None,
78
- params: ParamsType = None,
79
- ) -> _T | Any:
80
- """Send a command and return either a decoded or parsed response."""
81
- decoded_response = await self._send_raw_command(method, params=params)
82
-
83
- if response_type is not None:
84
- return response_type.from_dict(decoded_response)
85
- return decoded_response
86
-
87
- async def _send_raw_command(
88
- self,
89
- method: CommandType,
90
- *,
91
- params: ParamsType = None,
92
- ) -> Any:
93
- """Send a raw command and return the decoded response. Must be implemented by subclasses."""
94
- raise NotImplementedError
95
-
96
-
97
- class PickFirstAvailable(BaseV1RpcChannel):
98
- """A V1 RPC channel that tries multiple channels and picks the first that works."""
99
-
100
- def __init__(
101
- self,
102
- channel_cbs: list[Callable[[], V1RpcChannel | None]],
103
- ) -> None:
104
- """Initialize the pick-first-available channel."""
105
- self._channel_cbs = channel_cbs
106
-
107
- async def _send_raw_command(
108
- self,
109
- method: CommandType,
110
- *,
111
- params: ParamsType = None,
112
- ) -> Any:
113
- """Send a command and return a parsed response RoborockBase type."""
114
- for channel_cb in self._channel_cbs:
115
- if channel := channel_cb():
116
- return await channel.send_command(method, params=params)
117
- raise RoborockException("No available connection to send command")
118
-
119
-
120
- class PayloadEncodedV1RpcChannel(BaseV1RpcChannel):
121
- """Protocol for V1 channels that send encoded commands."""
122
-
123
- def __init__(
124
- self,
125
- name: str,
126
- channel: MqttChannel | LocalChannel,
127
- payload_encoder: Callable[[RequestMessage], RoborockMessage],
128
- decoder: Callable[[RoborockMessage], ResponseMessage] | Callable[[RoborockMessage], MapResponse | None],
129
- health_manager: HealthManager | None = None,
130
- ) -> None:
131
- """Initialize the channel with a raw channel and an encoder function."""
132
- self._name = name
133
- self._channel = channel
134
- self._payload_encoder = payload_encoder
135
- self._decoder = decoder
136
- self._health_manager = health_manager
137
-
138
- async def _send_raw_command(
139
- self,
140
- method: CommandType,
141
- *,
142
- params: ParamsType = None,
143
- ) -> ResponseData | bytes:
144
- """Send a command and return a parsed response RoborockBase type."""
145
- request_message = RequestMessage(method, params=params)
146
- _LOGGER.debug(
147
- "Sending command (%s, request_id=%s): %s, params=%s", self._name, request_message.request_id, method, params
148
- )
149
- message = self._payload_encoder(request_message)
150
-
151
- future: asyncio.Future[ResponseData | bytes] = asyncio.Future()
152
-
153
- def find_response(response_message: RoborockMessage) -> None:
154
- try:
155
- decoded = self._decoder(response_message)
156
- except RoborockException as ex:
157
- _LOGGER.debug("Exception while decoding message (%s): %s", response_message, ex)
158
- return
159
- if decoded is None:
160
- return
161
- _LOGGER.debug("Received response (%s, request_id=%s)", self._name, decoded.request_id)
162
- if decoded.request_id == request_message.request_id:
163
- if isinstance(decoded, ResponseMessage) and decoded.api_error:
164
- future.set_exception(decoded.api_error)
165
- else:
166
- future.set_result(decoded.data)
167
-
168
- unsub = await self._channel.subscribe(find_response)
169
- try:
170
- await self._channel.publish(message)
171
- result = await asyncio.wait_for(future, timeout=_TIMEOUT)
172
- except TimeoutError as ex:
173
- if self._health_manager:
174
- await self._health_manager.on_timeout()
175
- future.cancel()
176
- raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
177
- finally:
178
- unsub()
179
-
180
- if self._health_manager:
181
- await self._health_manager.on_success()
182
- return result
183
-
184
-
185
- def create_mqtt_rpc_channel(mqtt_channel: MqttChannel, security_data: SecurityData) -> V1RpcChannel:
186
- """Create a V1 RPC channel using an MQTT channel."""
187
- return PayloadEncodedV1RpcChannel(
188
- "mqtt",
189
- mqtt_channel,
190
- lambda x: x.encode_message(RoborockMessageProtocol.RPC_REQUEST, security_data=security_data),
191
- decode_rpc_response,
192
- health_manager=HealthManager(mqtt_channel.restart),
193
- )
194
-
195
-
196
- def create_local_rpc_channel(local_channel: LocalChannel) -> V1RpcChannel:
197
- """Create a V1 RPC channel using a local channel."""
198
- return PayloadEncodedV1RpcChannel(
199
- "local",
200
- local_channel,
201
- lambda x: x.encode_message(RoborockMessageProtocol.GENERAL_REQUEST, version=local_channel.protocol_version),
202
- decode_rpc_response,
203
- )
204
-
205
-
206
- def create_map_rpc_channel(
207
- mqtt_channel: MqttChannel,
208
- security_data: SecurityData,
209
- ) -> V1RpcChannel:
210
- """Create a V1 RPC channel that fetches map data.
211
-
212
- This will prefer local channels when available, falling back to MQTT
213
- channels if not. If neither is available, an exception will be raised
214
- when trying to send a command.
215
- """
216
- return PayloadEncodedV1RpcChannel(
217
- "map",
218
- mqtt_channel,
219
- lambda x: x.encode_message(RoborockMessageProtocol.RPC_REQUEST, security_data=security_data),
220
- create_map_response_decoder(security_data=security_data),
221
- )
File without changes