python-roborock 2.28.0__tar.gz → 2.29.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 (49) hide show
  1. {python_roborock-2.28.0 → python_roborock-2.29.0}/PKG-INFO +1 -1
  2. {python_roborock-2.28.0 → python_roborock-2.29.0}/pyproject.toml +1 -1
  3. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/cli.py +4 -1
  4. python_roborock-2.29.0/roborock/devices/a01_channel.py +43 -0
  5. python_roborock-2.29.0/roborock/devices/channel.py +27 -0
  6. python_roborock-2.29.0/roborock/devices/device.py +88 -0
  7. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/devices/device_manager.py +36 -10
  8. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/devices/local_channel.py +3 -1
  9. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/devices/mqtt_channel.py +25 -3
  10. python_roborock-2.29.0/roborock/devices/traits/dyad.py +36 -0
  11. python_roborock-2.29.0/roborock/devices/traits/status.py +43 -0
  12. python_roborock-2.29.0/roborock/devices/traits/trait.py +10 -0
  13. python_roborock-2.29.0/roborock/devices/traits/zeo.py +36 -0
  14. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/devices/v1_channel.py +7 -1
  15. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/protocols/a01_protocol.py +5 -1
  16. python_roborock-2.28.0/roborock/devices/device.py +0 -120
  17. {python_roborock-2.28.0 → python_roborock-2.29.0}/LICENSE +0 -0
  18. {python_roborock-2.28.0 → python_roborock-2.29.0}/README.md +0 -0
  19. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/__init__.py +0 -0
  20. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/api.py +0 -0
  21. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/cloud_api.py +0 -0
  22. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/code_mappings.py +0 -0
  23. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/command_cache.py +0 -0
  24. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/const.py +0 -0
  25. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/containers.py +0 -0
  26. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/device_features.py +0 -0
  27. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/devices/README.md +0 -0
  28. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/devices/__init__.py +0 -0
  29. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/devices/v1_rpc_channel.py +0 -0
  30. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/exceptions.py +0 -0
  31. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/local_api.py +0 -0
  32. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/mqtt/__init__.py +0 -0
  33. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/mqtt/roborock_session.py +0 -0
  34. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/mqtt/session.py +0 -0
  35. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/protocol.py +0 -0
  36. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/protocols/v1_protocol.py +0 -0
  37. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/py.typed +0 -0
  38. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/roborock_future.py +0 -0
  39. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/roborock_message.py +0 -0
  40. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/roborock_typing.py +0 -0
  41. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/util.py +0 -0
  42. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/version_1_apis/__init__.py +0 -0
  43. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  44. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  45. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  46. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/version_a01_apis/__init__.py +0 -0
  47. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  48. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  49. {python_roborock-2.28.0 → python_roborock-2.29.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 2.28.0
3
+ Version: 2.29.0
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.28.0"
3
+ version = "2.29.0"
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"
@@ -117,8 +117,11 @@ async def session(ctx, duration: int):
117
117
 
118
118
  click.echo("MQTT session started. Querying devices...")
119
119
  for device in devices:
120
+ if not (status_trait := device.traits.get("status")):
121
+ click.echo(f"Device {device.name} does not have a status trait")
122
+ continue
120
123
  try:
121
- status = await device.get_status()
124
+ status = await status_trait.get_status()
122
125
  except RoborockException as e:
123
126
  click.echo(f"Failed to get status for {device.name}: {e}")
124
127
  else:
@@ -0,0 +1,43 @@
1
+ """Thin wrapper around the MQTT channel for Roborock A01 devices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, overload
7
+
8
+ from roborock.protocols.a01_protocol import (
9
+ decode_rpc_response,
10
+ encode_mqtt_payload,
11
+ )
12
+ from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
13
+
14
+ from .mqtt_channel import MqttChannel
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ @overload
20
+ async def send_decoded_command(
21
+ mqtt_channel: MqttChannel,
22
+ params: dict[RoborockDyadDataProtocol, Any],
23
+ ) -> dict[RoborockDyadDataProtocol, Any]:
24
+ ...
25
+
26
+
27
+ @overload
28
+ async def send_decoded_command(
29
+ mqtt_channel: MqttChannel,
30
+ params: dict[RoborockZeoProtocol, Any],
31
+ ) -> dict[RoborockZeoProtocol, Any]:
32
+ ...
33
+
34
+
35
+ async def send_decoded_command(
36
+ mqtt_channel: MqttChannel,
37
+ params: dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any],
38
+ ) -> dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any]:
39
+ """Send a command on the MQTT channel and get a decoded response."""
40
+ _LOGGER.debug("Sending MQTT command: %s", params)
41
+ roborock_message = encode_mqtt_payload(params)
42
+ response = await mqtt_channel.send_message(roborock_message)
43
+ return decode_rpc_response(response) # type: ignore[return-value]
@@ -0,0 +1,27 @@
1
+ """Low-level interface for connections to Roborock devices."""
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from typing import Protocol
6
+
7
+ from roborock.roborock_message import RoborockMessage
8
+
9
+ _LOGGER = logging.getLogger(__name__)
10
+
11
+
12
+ class Channel(Protocol):
13
+ """A generic channel for establishing a connection with a Roborock device.
14
+
15
+ Individual channel implementations have their own methods for speaking to
16
+ the device that hide some of the protocol specific complexity, but they
17
+ are still specialized for the device type and protocol.
18
+ """
19
+
20
+ @property
21
+ def is_connected(self) -> bool:
22
+ """Return true if the channel is connected."""
23
+ ...
24
+
25
+ async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
26
+ """Subscribe to messages from the device."""
27
+ ...
@@ -0,0 +1,88 @@
1
+ """Module for Roborock 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
+ from abc import ABC
9
+ from collections.abc import Callable, Mapping
10
+ from types import MappingProxyType
11
+
12
+ from roborock.containers import HomeDataDevice
13
+ from roborock.roborock_message import RoborockMessage
14
+
15
+ from .channel import Channel
16
+ from .traits.trait import Trait
17
+
18
+ _LOGGER = logging.getLogger(__name__)
19
+
20
+ __all__ = [
21
+ "RoborockDevice",
22
+ ]
23
+
24
+
25
+ class RoborockDevice(ABC):
26
+ """A generic channel for establishing a connection with a Roborock device.
27
+
28
+ Individual channel implementations have their own methods for speaking to
29
+ the device that hide some of the protocol specific complexity, but they
30
+ are still specialized for the device type and protocol.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ device_info: HomeDataDevice,
36
+ channel: Channel,
37
+ traits: list[Trait],
38
+ ) -> None:
39
+ """Initialize the RoborockDevice.
40
+
41
+ The device takes ownership of the channel for communication with the device.
42
+ Use `connect()` to establish the connection, which will set up the appropriate
43
+ protocol channel. Use `close()` to clean up all connections.
44
+ """
45
+ self._duid = device_info.duid
46
+ self._name = device_info.name
47
+ self._channel = channel
48
+ self._unsub: Callable[[], None] | None = None
49
+ self._trait_map = {trait.name: trait for trait in traits}
50
+ if len(self._trait_map) != len(traits):
51
+ raise ValueError("Duplicate trait names found in traits list")
52
+
53
+ @property
54
+ def duid(self) -> str:
55
+ """Return the device unique identifier (DUID)."""
56
+ return self._duid
57
+
58
+ @property
59
+ def name(self) -> str:
60
+ """Return the device name."""
61
+ return self._name
62
+
63
+ @property
64
+ def is_connected(self) -> bool:
65
+ """Return whether the device is connected."""
66
+ return self._channel.is_connected
67
+
68
+ async def connect(self) -> None:
69
+ """Connect to the device using the appropriate protocol channel."""
70
+ if self._unsub:
71
+ raise ValueError("Already connected to the device")
72
+ self._unsub = await self._channel.subscribe(self._on_message)
73
+ _LOGGER.info("Connected to V1 device %s", self.name)
74
+
75
+ async def close(self) -> None:
76
+ """Close all connections to the device."""
77
+ if self._unsub:
78
+ self._unsub()
79
+ self._unsub = None
80
+
81
+ def _on_message(self, message: RoborockMessage) -> None:
82
+ """Handle incoming messages from the device."""
83
+ _LOGGER.debug("Received message from device: %s", message)
84
+
85
+ @property
86
+ def traits(self) -> Mapping[str, Trait]:
87
+ """Return the traits of the device."""
88
+ return MappingProxyType(self._trait_map)
@@ -1,21 +1,29 @@
1
1
  """Module for discovering Roborock devices."""
2
2
 
3
3
  import asyncio
4
+ import enum
4
5
  import logging
5
6
  from collections.abc import Awaitable, Callable
6
7
 
8
+ from roborock.code_mappings import RoborockCategory
7
9
  from roborock.containers import (
8
10
  HomeData,
9
11
  HomeDataDevice,
10
12
  HomeDataProduct,
11
13
  UserData,
12
14
  )
13
- from roborock.devices.device import DeviceVersion, RoborockDevice
15
+ from roborock.devices.device import RoborockDevice
14
16
  from roborock.mqtt.roborock_session import create_mqtt_session
15
17
  from roborock.mqtt.session import MqttSession
16
18
  from roborock.protocol import create_mqtt_params
17
19
  from roborock.web_api import RoborockApiClient
18
20
 
21
+ from .channel import Channel
22
+ from .mqtt_channel import create_mqtt_channel
23
+ from .traits.dyad import DyadApi
24
+ from .traits.status import StatusTrait
25
+ from .traits.trait import Trait
26
+ from .traits.zeo import ZeoApi
19
27
  from .v1_channel import create_v1_channel
20
28
 
21
29
  _LOGGER = logging.getLogger(__name__)
@@ -33,6 +41,14 @@ HomeDataApi = Callable[[], Awaitable[HomeData]]
33
41
  DeviceCreator = Callable[[HomeDataDevice, HomeDataProduct], RoborockDevice]
34
42
 
35
43
 
44
+ class DeviceVersion(enum.StrEnum):
45
+ """Enum for device versions."""
46
+
47
+ V1 = "1.0"
48
+ A01 = "A01"
49
+ UNKNOWN = "unknown"
50
+
51
+
36
52
  class DeviceManager:
37
53
  """Central manager for Roborock device discovery and connections."""
38
54
 
@@ -114,15 +130,25 @@ async def create_device_manager(user_data: UserData, home_data_api: HomeDataApi)
114
130
  mqtt_session = await create_mqtt_session(mqtt_params)
115
131
 
116
132
  def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
117
- # Check device version and only support V1 for now
118
- if device.pv != DeviceVersion.V1.value:
119
- raise NotImplementedError(
120
- f"Device {device.name} has version {device.pv}, but only V1 devices "
121
- f"are supported through the unified interface."
122
- )
123
- # Create V1 channel that handles both MQTT and local connections
124
- v1_channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device)
125
- return RoborockDevice(user_data, device, product, v1_channel)
133
+ channel: Channel
134
+ traits: list[Trait] = []
135
+ # TODO: Define a registration mechanism/factory for v1 traits
136
+ match device.pv:
137
+ case DeviceVersion.V1:
138
+ channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device)
139
+ traits.append(StatusTrait(product, channel.rpc_channel))
140
+ case DeviceVersion.A01:
141
+ mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
142
+ match product.category:
143
+ case RoborockCategory.WET_DRY_VAC:
144
+ traits.append(DyadApi(mqtt_channel))
145
+ case RoborockCategory.WASHING_MACHINE:
146
+ traits.append(ZeoApi(mqtt_channel))
147
+ case _:
148
+ raise NotImplementedError(f"Device {device.name} has unsupported category {product.category}")
149
+ case _:
150
+ raise NotImplementedError(f"Device {device.name} has unsupported version {device.pv}")
151
+ return RoborockDevice(device, channel, traits)
126
152
 
127
153
  manager = DeviceManager(home_data_api, device_creator, mqtt_session=mqtt_session)
128
154
  await manager.discover_devices()
@@ -10,6 +10,8 @@ from roborock.exceptions import RoborockConnectionException, RoborockException
10
10
  from roborock.protocol import Decoder, Encoder, create_local_decoder, create_local_encoder
11
11
  from roborock.roborock_message import RoborockMessage
12
12
 
13
+ from .channel import Channel
14
+
13
15
  _LOGGER = logging.getLogger(__name__)
14
16
  _PORT = 58867
15
17
 
@@ -30,7 +32,7 @@ class _LocalProtocol(asyncio.Protocol):
30
32
  self.connection_lost_cb(exc)
31
33
 
32
34
 
33
- class LocalChannel:
35
+ class LocalChannel(Channel):
34
36
  """Simple RPC-style channel for communicating with a device over a local network.
35
37
 
36
38
  Handles request/response correlation and timeouts, but leaves message
@@ -5,16 +5,18 @@ import logging
5
5
  from collections.abc import Callable
6
6
  from json import JSONDecodeError
7
7
 
8
- from roborock.containers import RRiot
8
+ from roborock.containers import HomeDataDevice, RRiot, UserData
9
9
  from roborock.exceptions import RoborockException
10
10
  from roborock.mqtt.session import MqttParams, MqttSession
11
11
  from roborock.protocol import create_mqtt_decoder, create_mqtt_encoder
12
12
  from roborock.roborock_message import RoborockMessage
13
13
 
14
+ from .channel import Channel
15
+
14
16
  _LOGGER = logging.getLogger(__name__)
15
17
 
16
18
 
17
- class MqttChannel:
19
+ class MqttChannel(Channel):
18
20
  """Simple RPC-style channel for communicating with a device over MQTT.
19
21
 
20
22
  Handles request/response correlation and timeouts, but leaves message
@@ -33,6 +35,12 @@ class MqttChannel:
33
35
  self._decoder = create_mqtt_decoder(local_key)
34
36
  self._encoder = create_mqtt_encoder(local_key)
35
37
  self._queue_lock = asyncio.Lock()
38
+ self._mqtt_unsub: Callable[[], None] | None = None
39
+
40
+ @property
41
+ def is_connected(self) -> bool:
42
+ """Return true if the channel is connected."""
43
+ return (self._mqtt_unsub is not None) and self._mqtt_session.connected
36
44
 
37
45
  @property
38
46
  def _publish_topic(self) -> str:
@@ -67,7 +75,14 @@ class MqttChannel:
67
75
  except Exception as e:
68
76
  _LOGGER.exception("Uncaught error in message handler callback: %s", e)
69
77
 
70
- return await self._mqtt_session.subscribe(self._subscribe_topic, message_handler)
78
+ self._mqtt_unsub = await self._mqtt_session.subscribe(self._subscribe_topic, message_handler)
79
+
80
+ def unsub_wrapper() -> None:
81
+ if self._mqtt_unsub is not None:
82
+ self._mqtt_unsub()
83
+ self._mqtt_unsub = None
84
+
85
+ return unsub_wrapper
71
86
 
72
87
  async def _resolve_future_with_lock(self, message: RoborockMessage) -> None:
73
88
  """Resolve waiting future with proper locking."""
@@ -113,3 +128,10 @@ class MqttChannel:
113
128
  async with self._queue_lock:
114
129
  self._waiting_queue.pop(request_id, None)
115
130
  raise
131
+
132
+
133
+ def create_mqtt_channel(
134
+ user_data: UserData, mqtt_params: MqttParams, mqtt_session: MqttSession, device: HomeDataDevice
135
+ ) -> MqttChannel:
136
+ """Create a V1Channel for the given device."""
137
+ return MqttChannel(mqtt_session, device.duid, device.local_key, user_data.rriot, mqtt_params)
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from roborock.roborock_message import RoborockDyadDataProtocol
7
+
8
+ from ..a01_channel import send_decoded_command
9
+ from ..mqtt_channel import MqttChannel
10
+ from .trait import Trait
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+ __all__ = [
15
+ "DyadApi",
16
+ ]
17
+
18
+
19
+ class DyadApi(Trait):
20
+ """API for interacting with Dyad devices."""
21
+
22
+ name = "dyad"
23
+
24
+ def __init__(self, channel: MqttChannel) -> None:
25
+ """Initialize the Dyad API."""
26
+ self._channel = channel
27
+
28
+ async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]:
29
+ """Query the device for the values of the given Dyad protocols."""
30
+ params = {RoborockDyadDataProtocol.ID_QUERY: [int(p) for p in protocols]}
31
+ return await send_decoded_command(self._channel, params)
32
+
33
+ async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]:
34
+ """Set a value for a specific protocol on the device."""
35
+ params = {protocol: value}
36
+ return await send_decoded_command(self._channel, params)
@@ -0,0 +1,43 @@
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 (
10
+ HomeDataProduct,
11
+ ModelStatus,
12
+ S7MaxVStatus,
13
+ Status,
14
+ )
15
+ from roborock.roborock_typing import RoborockCommand
16
+
17
+ from ..v1_rpc_channel import V1RpcChannel
18
+ from .trait import Trait
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+ __all__ = [
23
+ "Status",
24
+ ]
25
+
26
+
27
+ class StatusTrait(Trait):
28
+ """Unified Roborock device class with automatic connection setup."""
29
+
30
+ name = "status"
31
+
32
+ def __init__(self, product_info: HomeDataProduct, rpc_channel: V1RpcChannel) -> None:
33
+ """Initialize the StatusTrait."""
34
+ self._product_info = product_info
35
+ self._rpc_channel = rpc_channel
36
+
37
+ async def get_status(self) -> Status:
38
+ """Get the current status of the device.
39
+
40
+ This is a placeholder command and will likely be changed/moved in the future.
41
+ """
42
+ status_type: type[Status] = ModelStatus.get(self._product_info.model, S7MaxVStatus)
43
+ return await self._rpc_channel.send_command(RoborockCommand.GET_STATUS, response_type=status_type)
@@ -0,0 +1,10 @@
1
+ """Trait module for Roborock devices."""
2
+
3
+ from abc import ABC
4
+
5
+
6
+ class Trait(ABC):
7
+ """API for interacting with Roborock devices."""
8
+
9
+ name: str
10
+ """Name of the API."""
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from roborock.roborock_message import RoborockZeoProtocol
7
+
8
+ from ..a01_channel import send_decoded_command
9
+ from ..mqtt_channel import MqttChannel
10
+ from .trait import Trait
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+ __all__ = [
15
+ "ZeoApi",
16
+ ]
17
+
18
+
19
+ class ZeoApi(Trait):
20
+ """API for interacting with Zeo devices."""
21
+
22
+ name = "zeo"
23
+
24
+ def __init__(self, channel: MqttChannel) -> None:
25
+ """Initialize the Zeo API."""
26
+ self._channel = channel
27
+
28
+ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]:
29
+ """Query the device for the values of the given protocols."""
30
+ params = {RoborockZeoProtocol.ID_QUERY: [int(p) for p in protocols]}
31
+ return await send_decoded_command(self._channel, params)
32
+
33
+ async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]:
34
+ """Set a value for a specific protocol on the device."""
35
+ params = {protocol: value}
36
+ return await send_decoded_command(self._channel, params)
@@ -18,6 +18,7 @@ from roborock.protocols.v1_protocol import (
18
18
  from roborock.roborock_message import RoborockMessage
19
19
  from roborock.roborock_typing import RoborockCommand
20
20
 
21
+ from .channel import Channel
21
22
  from .local_channel import LocalChannel, LocalSession, create_local_session
22
23
  from .mqtt_channel import MqttChannel
23
24
  from .v1_rpc_channel import V1RpcChannel, create_combined_rpc_channel, create_mqtt_rpc_channel
@@ -31,7 +32,7 @@ __all__ = [
31
32
  _T = TypeVar("_T", bound=RoborockBase)
32
33
 
33
34
 
34
- class V1Channel:
35
+ class V1Channel(Channel):
35
36
  """Unified V1 protocol channel with automatic MQTT/local connection handling.
36
37
 
37
38
  This channel abstracts away the complexity of choosing between MQTT and local
@@ -63,6 +64,11 @@ class V1Channel:
63
64
  self._callback: Callable[[RoborockMessage], None] | None = None
64
65
  self._networking_info: NetworkInfo | None = None
65
66
 
67
+ @property
68
+ def is_connected(self) -> bool:
69
+ """Return whether any connection is available."""
70
+ return self.is_mqtt_connected or self.is_local_connected
71
+
66
72
  @property
67
73
  def is_local_connected(self) -> bool:
68
74
  """Return whether local connection is available."""
@@ -20,7 +20,11 @@ _LOGGER = logging.getLogger(__name__)
20
20
  A01_VERSION = b"A01"
21
21
 
22
22
 
23
- def encode_mqtt_payload(data: dict[RoborockDyadDataProtocol | RoborockZeoProtocol, Any]) -> RoborockMessage:
23
+ def encode_mqtt_payload(
24
+ data: dict[RoborockDyadDataProtocol, Any]
25
+ | dict[RoborockZeoProtocol, Any]
26
+ | dict[RoborockDyadDataProtocol | RoborockZeoProtocol, Any],
27
+ ) -> RoborockMessage:
24
28
  """Encode payload for A01 commands over MQTT."""
25
29
  dps_data = {"dps": data}
26
30
  payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size)
@@ -1,120 +0,0 @@
1
- """Module for Roborock devices.
2
-
3
- This interface is experimental and subject to breaking changes without notice
4
- until the API is stable.
5
- """
6
-
7
- import enum
8
- import logging
9
- from collections.abc import Callable
10
- from functools import cached_property
11
-
12
- from roborock.containers import (
13
- HomeDataDevice,
14
- HomeDataProduct,
15
- ModelStatus,
16
- S7MaxVStatus,
17
- Status,
18
- UserData,
19
- )
20
- from roborock.roborock_message import RoborockMessage
21
- from roborock.roborock_typing import RoborockCommand
22
-
23
- from .v1_channel import V1Channel
24
-
25
- _LOGGER = logging.getLogger(__name__)
26
-
27
- __all__ = [
28
- "RoborockDevice",
29
- "DeviceVersion",
30
- ]
31
-
32
-
33
- class DeviceVersion(enum.StrEnum):
34
- """Enum for device versions."""
35
-
36
- V1 = "1.0"
37
- A01 = "A01"
38
- UNKNOWN = "unknown"
39
-
40
-
41
- class RoborockDevice:
42
- """Unified Roborock device class with automatic connection setup."""
43
-
44
- def __init__(
45
- self,
46
- user_data: UserData,
47
- device_info: HomeDataDevice,
48
- product_info: HomeDataProduct,
49
- v1_channel: V1Channel,
50
- ) -> None:
51
- """Initialize the RoborockDevice.
52
-
53
- The device takes ownership of the V1 channel for communication with the device.
54
- Use `connect()` to establish the connection, which will set up the appropriate
55
- protocol channel. Use `close()` to clean up all connections.
56
- """
57
- self._user_data = user_data
58
- self._device_info = device_info
59
- self._product_info = product_info
60
- self._v1_channel = v1_channel
61
- self._unsub: Callable[[], None] | None = None
62
-
63
- @property
64
- def duid(self) -> str:
65
- """Return the device unique identifier (DUID)."""
66
- return self._device_info.duid
67
-
68
- @property
69
- def name(self) -> str:
70
- """Return the device name."""
71
- return self._device_info.name
72
-
73
- @cached_property
74
- def device_version(self) -> str:
75
- """Return the device version.
76
-
77
- At the moment this is a simple check against the product version (pv) of the device
78
- and used as a placeholder for upcoming functionality for devices that will behave
79
- differently based on the version and capabilities.
80
- """
81
- if self._device_info.pv == DeviceVersion.V1.value:
82
- return DeviceVersion.V1
83
- elif self._device_info.pv == DeviceVersion.A01.value:
84
- return DeviceVersion.A01
85
- _LOGGER.warning(
86
- "Unknown device version %s for device %s, using default UNKNOWN",
87
- self._device_info.pv,
88
- self._device_info.name,
89
- )
90
- return DeviceVersion.UNKNOWN
91
-
92
- @property
93
- def is_connected(self) -> bool:
94
- """Return whether the device is connected."""
95
- return self._v1_channel.is_mqtt_connected or self._v1_channel.is_local_connected
96
-
97
- async def connect(self) -> None:
98
- """Connect to the device using the appropriate protocol channel."""
99
- if self._unsub:
100
- raise ValueError("Already connected to the device")
101
- self._unsub = await self._v1_channel.subscribe(self._on_message)
102
- _LOGGER.info("Connected to V1 device %s", self.name)
103
-
104
- async def close(self) -> None:
105
- """Close all connections to the device."""
106
- if self._unsub:
107
- self._unsub()
108
- self._unsub = None
109
-
110
- def _on_message(self, message: RoborockMessage) -> None:
111
- """Handle incoming messages from the device."""
112
- _LOGGER.debug("Received message from device: %s", message)
113
-
114
- async def get_status(self) -> Status:
115
- """Get the current status of the device.
116
-
117
- This is a placeholder command and will likely be changed/moved in the future.
118
- """
119
- status_type: type[Status] = ModelStatus.get(self._product_info.model, S7MaxVStatus)
120
- return await self._v1_channel.rpc_channel.send_command(RoborockCommand.GET_STATUS, response_type=status_type)