python-roborock 3.8.4__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.4 → python_roborock-3.8.5}/PKG-INFO +1 -1
  2. {python_roborock-3.8.4 → python_roborock-3.8.5}/pyproject.toml +1 -1
  3. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/mqtt_channel.py +1 -1
  4. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/__init__.py +1 -1
  5. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/common.py +1 -1
  6. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/v1_channel.py +167 -30
  7. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/protocols/v1_protocol.py +35 -2
  8. python_roborock-3.8.4/roborock/devices/v1_rpc_channel.py +0 -221
  9. {python_roborock-3.8.4 → python_roborock-3.8.5}/.gitignore +0 -0
  10. {python_roborock-3.8.4 → python_roborock-3.8.5}/LICENSE +0 -0
  11. {python_roborock-3.8.4 → python_roborock-3.8.5}/README.md +0 -0
  12. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/__init__.py +0 -0
  13. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/api.py +0 -0
  14. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/broadcast_protocol.py +0 -0
  15. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/callbacks.py +0 -0
  16. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/cli.py +0 -0
  17. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/cloud_api.py +0 -0
  18. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/command_cache.py +0 -0
  19. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/const.py +0 -0
  20. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/__init__.py +0 -0
  21. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/b01_q10/__init__.py +0 -0
  22. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  23. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  24. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/b01_q7/__init__.py +0 -0
  25. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  26. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  27. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/code_mappings.py +0 -0
  28. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/containers.py +0 -0
  29. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/dyad/__init__.py +0 -0
  30. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  31. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/dyad/dyad_containers.py +0 -0
  32. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/v1/__init__.py +0 -0
  33. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/v1/v1_clean_modes.py +0 -0
  34. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/v1/v1_code_mappings.py +0 -0
  35. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/v1/v1_containers.py +0 -0
  36. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/zeo/__init__.py +0 -0
  37. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  38. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/data/zeo/zeo_containers.py +0 -0
  39. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/device_features.py +0 -0
  40. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/README.md +0 -0
  41. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/__init__.py +0 -0
  42. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/a01_channel.py +0 -0
  43. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/b01_channel.py +0 -0
  44. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/cache.py +0 -0
  45. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/channel.py +0 -0
  46. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/device.py +0 -0
  47. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/device_manager.py +0 -0
  48. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/file_cache.py +0 -0
  49. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/local_channel.py +0 -0
  50. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/__init__.py +0 -0
  51. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/a01/__init__.py +0 -0
  52. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/b01/__init__.py +0 -0
  53. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/traits_mixin.py +0 -0
  54. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/child_lock.py +0 -0
  55. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/clean_summary.py +0 -0
  56. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/command.py +0 -0
  57. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/consumeable.py +0 -0
  58. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/device_features.py +0 -0
  59. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  60. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  61. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  62. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/home.py +0 -0
  63. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/led_status.py +0 -0
  64. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/map_content.py +0 -0
  65. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/maps.py +0 -0
  66. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/network_info.py +0 -0
  67. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/rooms.py +0 -0
  68. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/routines.py +0 -0
  69. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  70. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/status.py +0 -0
  71. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  72. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/volume.py +0 -0
  73. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  74. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/exceptions.py +0 -0
  75. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/map/__init__.py +0 -0
  76. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/map/map_parser.py +0 -0
  77. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/mqtt/__init__.py +0 -0
  78. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/mqtt/health_manager.py +0 -0
  79. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/mqtt/roborock_session.py +0 -0
  80. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/mqtt/session.py +0 -0
  81. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/protocol.py +0 -0
  82. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/protocols/__init__.py +0 -0
  83. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/protocols/a01_protocol.py +0 -0
  84. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/protocols/b01_protocol.py +0 -0
  85. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/py.typed +0 -0
  86. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/roborock_future.py +0 -0
  87. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/roborock_message.py +0 -0
  88. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/roborock_typing.py +0 -0
  89. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/util.py +0 -0
  90. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/version_1_apis/__init__.py +0 -0
  91. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  92. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  93. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  94. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/version_a01_apis/__init__.py +0 -0
  95. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  96. {python_roborock-3.8.4 → python_roborock-3.8.5}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  97. {python_roborock-3.8.4 → 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.4
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.4"
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"
@@ -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__)
@@ -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.
@@ -185,7 +323,7 @@ class V1Channel(Channel):
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:
@@ -216,7 +354,6 @@ class V1Channel(Channel):
216
354
  raise RoborockException(f"Error connecting to local device {self._device_uid}: {e}") from e
217
355
  # Wire up the new channel
218
356
  self._local_channel = local_channel
219
- self._local_rpc_channel = create_local_rpc_channel(self._local_channel)
220
357
  self._local_unsub = await self._local_channel.subscribe(self._on_local_message)
221
358
  _LOGGER.info("Successfully connected to local device %s", self._device_uid)
222
359
 
@@ -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