python-roborock 2.46.0__tar.gz → 2.47.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.
- {python_roborock-2.46.0 → python_roborock-2.47.0}/PKG-INFO +1 -1
- {python_roborock-2.46.0 → python_roborock-2.47.0}/pyproject.toml +1 -1
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/api.py +1 -4
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/cli.py +23 -38
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/device.py +10 -13
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/device_manager.py +7 -25
- python_roborock-2.47.0/roborock/devices/traits/__init__.py +15 -0
- python_roborock-2.47.0/roborock/devices/traits/a01/__init__.py +61 -0
- python_roborock-2.46.0/roborock/devices/traits/b01/props.py → python_roborock-2.47.0/roborock/devices/traits/b01/__init__.py +13 -14
- python_roborock-2.47.0/roborock/devices/traits/traits_mixin.py +61 -0
- python_roborock-2.47.0/roborock/devices/traits/v1/__init__.py +57 -0
- python_roborock-2.47.0/roborock/devices/traits/v1/clean_summary.py +29 -0
- python_roborock-2.47.0/roborock/devices/traits/v1/common.py +115 -0
- python_roborock-2.47.0/roborock/devices/traits/v1/do_not_disturb.py +17 -0
- python_roborock-2.47.0/roborock/devices/traits/v1/status.py +24 -0
- python_roborock-2.47.0/roborock/devices/traits/v1/volume.py +26 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/protocol.py +44 -11
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/protocols/v1_protocol.py +2 -3
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/util.py +1 -1
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_client_v1.py +7 -1
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_local_client_v1.py +82 -24
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/web_api.py +2 -2
- python_roborock-2.46.0/roborock/devices/traits/b01/__init__.py +0 -0
- python_roborock-2.46.0/roborock/devices/traits/clean_summary.py +0 -52
- python_roborock-2.46.0/roborock/devices/traits/dnd.py +0 -41
- python_roborock-2.46.0/roborock/devices/traits/dyad.py +0 -36
- python_roborock-2.46.0/roborock/devices/traits/sound_volume.py +0 -31
- python_roborock-2.46.0/roborock/devices/traits/status.py +0 -49
- python_roborock-2.46.0/roborock/devices/traits/trait.py +0 -10
- python_roborock-2.46.0/roborock/devices/traits/zeo.py +0 -36
- {python_roborock-2.46.0 → python_roborock-2.47.0}/LICENSE +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/README.md +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/__init__.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/b01_containers.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/broadcast_protocol.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/callbacks.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/clean_modes.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/cloud_api.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/code_mappings.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/command_cache.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/const.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/containers.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/device_features.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/README.md +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/__init__.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/a01_channel.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/b01_channel.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/cache.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/channel.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/local_channel.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/mqtt_channel.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/v1_channel.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/devices/v1_rpc_channel.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/exceptions.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/protocols/b01_protocol.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/py.typed +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/roborock_future.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/roborock_message.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_1_apis/__init__.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_a01_apis/__init__.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
- {python_roborock-2.46.0 → python_roborock-2.47.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
|
@@ -18,7 +18,6 @@ from .exceptions import (
|
|
|
18
18
|
from .roborock_future import RoborockFuture
|
|
19
19
|
from .roborock_message import (
|
|
20
20
|
RoborockMessage,
|
|
21
|
-
RoborockMessageProtocol,
|
|
22
21
|
)
|
|
23
22
|
from .util import get_next_int
|
|
24
23
|
|
|
@@ -91,9 +90,7 @@ class RoborockClient(ABC):
|
|
|
91
90
|
|
|
92
91
|
def _async_response(self, request_id: int, protocol_id: int = 0) -> Any:
|
|
93
92
|
queue = RoborockFuture(protocol_id)
|
|
94
|
-
if request_id in self._waiting_queue
|
|
95
|
-
request_id == 2 and protocol_id == RoborockMessageProtocol.PING_REQUEST
|
|
96
|
-
):
|
|
93
|
+
if request_id in self._waiting_queue:
|
|
97
94
|
new_id = get_next_int(10000, 32767)
|
|
98
95
|
self._logger.warning(
|
|
99
96
|
"Attempting to create a future with an existing id %s (%s)... New id is %s. "
|
|
@@ -27,6 +27,7 @@ import functools
|
|
|
27
27
|
import json
|
|
28
28
|
import logging
|
|
29
29
|
import threading
|
|
30
|
+
from collections.abc import Callable
|
|
30
31
|
from dataclasses import asdict, dataclass
|
|
31
32
|
from pathlib import Path
|
|
32
33
|
from typing import Any, cast
|
|
@@ -43,6 +44,8 @@ from roborock.containers import DeviceData, HomeData, NetworkInfo, RoborockBase,
|
|
|
43
44
|
from roborock.devices.cache import Cache, CacheData
|
|
44
45
|
from roborock.devices.device import RoborockDevice
|
|
45
46
|
from roborock.devices.device_manager import DeviceManager, create_device_manager, create_home_data_api
|
|
47
|
+
from roborock.devices.traits import Trait
|
|
48
|
+
from roborock.devices.traits.v1 import V1TraitMixin
|
|
46
49
|
from roborock.protocol import MessageParser
|
|
47
50
|
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
|
|
48
51
|
from roborock.web_api import RoborockApiClient
|
|
@@ -377,6 +380,22 @@ async def execute_scene(ctx, scene_id):
|
|
|
377
380
|
await client.execute_scene(cache_data.user_data, scene_id)
|
|
378
381
|
|
|
379
382
|
|
|
383
|
+
async def _v1_trait(context: RoborockContext, device_id: str, display_func: Callable[[], V1TraitMixin]) -> Trait:
|
|
384
|
+
device_manager = await context.get_device_manager()
|
|
385
|
+
device = await device_manager.get_device(device_id)
|
|
386
|
+
if device.v1_properties is None:
|
|
387
|
+
raise RoborockException(f"Device {device.name} does not support V1 protocol")
|
|
388
|
+
|
|
389
|
+
trait = display_func(device.v1_properties)
|
|
390
|
+
await trait.refresh()
|
|
391
|
+
return trait
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def _display_v1_trait(context: RoborockContext, device_id: str, display_func: Callable[[], Trait]) -> None:
|
|
395
|
+
trait = await _v1_trait(context, device_id, display_func)
|
|
396
|
+
click.echo(dump_json(trait.as_dict()))
|
|
397
|
+
|
|
398
|
+
|
|
380
399
|
@session.command()
|
|
381
400
|
@click.option("--device_id", required=True)
|
|
382
401
|
@click.pass_context
|
|
@@ -384,16 +403,7 @@ async def execute_scene(ctx, scene_id):
|
|
|
384
403
|
async def status(ctx, device_id: str):
|
|
385
404
|
"""Get device status."""
|
|
386
405
|
context: RoborockContext = ctx.obj
|
|
387
|
-
|
|
388
|
-
device_manager = await context.get_device_manager()
|
|
389
|
-
device = await device_manager.get_device(device_id)
|
|
390
|
-
|
|
391
|
-
if not (status_trait := device.traits.get("status")):
|
|
392
|
-
click.echo(f"Device {device.name} does not have a status trait")
|
|
393
|
-
return
|
|
394
|
-
|
|
395
|
-
status_result = await status_trait.get_status()
|
|
396
|
-
click.echo(dump_json(status_result.as_dict()))
|
|
406
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.status)
|
|
397
407
|
|
|
398
408
|
|
|
399
409
|
@session.command()
|
|
@@ -403,15 +413,7 @@ async def status(ctx, device_id: str):
|
|
|
403
413
|
async def clean_summary(ctx, device_id: str):
|
|
404
414
|
"""Get device clean summary."""
|
|
405
415
|
context: RoborockContext = ctx.obj
|
|
406
|
-
|
|
407
|
-
device_manager = await context.get_device_manager()
|
|
408
|
-
device = await device_manager.get_device(device_id)
|
|
409
|
-
if not (clean_summary_trait := device.traits.get("clean_summary")):
|
|
410
|
-
click.echo(f"Device {device.name} does not have a clean summary trait")
|
|
411
|
-
return
|
|
412
|
-
|
|
413
|
-
clean_summary_result = await clean_summary_trait.get_clean_summary()
|
|
414
|
-
click.echo(dump_json(clean_summary_result.as_dict()))
|
|
416
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.clean_summary)
|
|
415
417
|
|
|
416
418
|
|
|
417
419
|
@session.command()
|
|
@@ -421,17 +423,7 @@ async def clean_summary(ctx, device_id: str):
|
|
|
421
423
|
async def volume(ctx, device_id: str):
|
|
422
424
|
"""Get device volume."""
|
|
423
425
|
context: RoborockContext = ctx.obj
|
|
424
|
-
|
|
425
|
-
device_manager = await context.get_device_manager()
|
|
426
|
-
device = await device_manager.get_device(device_id)
|
|
427
|
-
|
|
428
|
-
if not (volume_trait := device.traits.get("sound_volume")):
|
|
429
|
-
click.echo(f"Device {device.name} does not have a volume trait")
|
|
430
|
-
return
|
|
431
|
-
|
|
432
|
-
volume_result = await volume_trait.get_volume()
|
|
433
|
-
click.echo(f"Device {device_id} volume:")
|
|
434
|
-
click.echo(volume_result)
|
|
426
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.sound_volume)
|
|
435
427
|
|
|
436
428
|
|
|
437
429
|
@session.command()
|
|
@@ -442,14 +434,7 @@ async def volume(ctx, device_id: str):
|
|
|
442
434
|
async def set_volume(ctx, device_id: str, volume: int):
|
|
443
435
|
"""Set the devicevolume."""
|
|
444
436
|
context: RoborockContext = ctx.obj
|
|
445
|
-
|
|
446
|
-
device_manager = await context.get_device_manager()
|
|
447
|
-
device = await device_manager.get_device(device_id)
|
|
448
|
-
|
|
449
|
-
if not (volume_trait := device.traits.get("sound_volume")):
|
|
450
|
-
click.echo(f"Device {device.name} does not have a volume trait")
|
|
451
|
-
return
|
|
452
|
-
|
|
437
|
+
volume_trait = await _v1_trait(context, device_id, lambda v1: v1.sound_volume)
|
|
453
438
|
await volume_trait.set_volume(volume)
|
|
454
439
|
click.echo(f"Set Device {device_id} volume to {volume}")
|
|
455
440
|
|
|
@@ -6,14 +6,14 @@ until the API is stable.
|
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
8
|
from abc import ABC
|
|
9
|
-
from collections.abc import Callable
|
|
10
|
-
from types import MappingProxyType
|
|
9
|
+
from collections.abc import Callable
|
|
11
10
|
|
|
12
11
|
from roborock.containers import HomeDataDevice
|
|
13
12
|
from roborock.roborock_message import RoborockMessage
|
|
14
13
|
|
|
15
14
|
from .channel import Channel
|
|
16
|
-
from .traits
|
|
15
|
+
from .traits import Trait
|
|
16
|
+
from .traits.traits_mixin import TraitsMixin
|
|
17
17
|
|
|
18
18
|
_LOGGER = logging.getLogger(__name__)
|
|
19
19
|
|
|
@@ -22,19 +22,23 @@ __all__ = [
|
|
|
22
22
|
]
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
class RoborockDevice(ABC):
|
|
25
|
+
class RoborockDevice(ABC, TraitsMixin):
|
|
26
26
|
"""A generic channel for establishing a connection with a Roborock device.
|
|
27
27
|
|
|
28
28
|
Individual channel implementations have their own methods for speaking to
|
|
29
29
|
the device that hide some of the protocol specific complexity, but they
|
|
30
30
|
are still specialized for the device type and protocol.
|
|
31
|
+
|
|
32
|
+
Attributes of the device are exposed through traits, which are mixed in
|
|
33
|
+
through the TraitsMixin class. Traits are optional and may not be present
|
|
34
|
+
on all devices.
|
|
31
35
|
"""
|
|
32
36
|
|
|
33
37
|
def __init__(
|
|
34
38
|
self,
|
|
35
39
|
device_info: HomeDataDevice,
|
|
36
40
|
channel: Channel,
|
|
37
|
-
|
|
41
|
+
trait: Trait,
|
|
38
42
|
) -> None:
|
|
39
43
|
"""Initialize the RoborockDevice.
|
|
40
44
|
|
|
@@ -42,13 +46,11 @@ class RoborockDevice(ABC):
|
|
|
42
46
|
Use `connect()` to establish the connection, which will set up the appropriate
|
|
43
47
|
protocol channel. Use `close()` to clean up all connections.
|
|
44
48
|
"""
|
|
49
|
+
TraitsMixin.__init__(self, trait)
|
|
45
50
|
self._duid = device_info.duid
|
|
46
51
|
self._name = device_info.name
|
|
47
52
|
self._channel = channel
|
|
48
53
|
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
54
|
|
|
53
55
|
@property
|
|
54
56
|
def duid(self) -> str:
|
|
@@ -81,8 +83,3 @@ class RoborockDevice(ABC):
|
|
|
81
83
|
def _on_message(self, message: RoborockMessage) -> None:
|
|
82
84
|
"""Handle incoming messages from the device."""
|
|
83
85
|
_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)
|
|
@@ -7,7 +7,6 @@ from collections.abc import Awaitable, Callable
|
|
|
7
7
|
|
|
8
8
|
import aiohttp
|
|
9
9
|
|
|
10
|
-
from roborock.code_mappings import RoborockCategory
|
|
11
10
|
from roborock.containers import (
|
|
12
11
|
HomeData,
|
|
13
12
|
HomeDataDevice,
|
|
@@ -23,14 +22,7 @@ from roborock.web_api import RoborockApiClient
|
|
|
23
22
|
from .cache import Cache, NoCache
|
|
24
23
|
from .channel import Channel
|
|
25
24
|
from .mqtt_channel import create_mqtt_channel
|
|
26
|
-
from .traits
|
|
27
|
-
from .traits.clean_summary import CleanSummaryTrait
|
|
28
|
-
from .traits.dnd import DoNotDisturbTrait
|
|
29
|
-
from .traits.dyad import DyadApi
|
|
30
|
-
from .traits.sound_volume import SoundVolumeTrait
|
|
31
|
-
from .traits.status import StatusTrait
|
|
32
|
-
from .traits.trait import Trait
|
|
33
|
-
from .traits.zeo import ZeoApi
|
|
25
|
+
from .traits import Trait, a01, b01, v1
|
|
34
26
|
from .v1_channel import create_v1_channel
|
|
35
27
|
|
|
36
28
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -153,30 +145,20 @@ async def create_device_manager(
|
|
|
153
145
|
|
|
154
146
|
def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
|
|
155
147
|
channel: Channel
|
|
156
|
-
|
|
157
|
-
# TODO: Define a registration mechanism/factory for v1 traits
|
|
148
|
+
trait: Trait
|
|
158
149
|
match device.pv:
|
|
159
150
|
case DeviceVersion.V1:
|
|
160
151
|
channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, cache)
|
|
161
|
-
|
|
162
|
-
traits.append(DoNotDisturbTrait(channel.rpc_channel))
|
|
163
|
-
traits.append(CleanSummaryTrait(channel.rpc_channel))
|
|
164
|
-
traits.append(SoundVolumeTrait(channel.rpc_channel))
|
|
152
|
+
trait = v1.create(product, channel.rpc_channel)
|
|
165
153
|
case DeviceVersion.A01:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
case RoborockCategory.WET_DRY_VAC:
|
|
169
|
-
traits.append(DyadApi(mqtt_channel))
|
|
170
|
-
case RoborockCategory.WASHING_MACHINE:
|
|
171
|
-
traits.append(ZeoApi(mqtt_channel))
|
|
172
|
-
case _:
|
|
173
|
-
raise NotImplementedError(f"Device {device.name} has unsupported category {product.category}")
|
|
154
|
+
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
|
|
155
|
+
trait = a01.create(product, channel)
|
|
174
156
|
case DeviceVersion.B01:
|
|
175
157
|
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
|
|
176
|
-
|
|
158
|
+
trait = b01.create(channel)
|
|
177
159
|
case _:
|
|
178
160
|
raise NotImplementedError(f"Device {device.name} has unsupported version {device.pv}")
|
|
179
|
-
return RoborockDevice(device, channel,
|
|
161
|
+
return RoborockDevice(device, channel, trait)
|
|
180
162
|
|
|
181
163
|
manager = DeviceManager(home_data_api, device_creator, mqtt_session=mqtt_session, cache=cache)
|
|
182
164
|
await manager.discover_devices()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from roborock.containers import HomeDataProduct, RoborockCategory
|
|
4
|
+
from roborock.devices.a01_channel import send_decoded_command
|
|
5
|
+
from roborock.devices.mqtt_channel import MqttChannel
|
|
6
|
+
from roborock.devices.traits import Trait
|
|
7
|
+
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
|
|
8
|
+
|
|
9
|
+
__init__ = [
|
|
10
|
+
"DyadApi",
|
|
11
|
+
"ZeoApi",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DyadApi(Trait):
|
|
16
|
+
"""API for interacting with Dyad devices."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, channel: MqttChannel) -> None:
|
|
19
|
+
"""Initialize the Dyad API."""
|
|
20
|
+
self._channel = channel
|
|
21
|
+
|
|
22
|
+
async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]:
|
|
23
|
+
"""Query the device for the values of the given Dyad protocols."""
|
|
24
|
+
params = {RoborockDyadDataProtocol.ID_QUERY: [int(p) for p in protocols]}
|
|
25
|
+
return await send_decoded_command(self._channel, params)
|
|
26
|
+
|
|
27
|
+
async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]:
|
|
28
|
+
"""Set a value for a specific protocol on the device."""
|
|
29
|
+
params = {protocol: value}
|
|
30
|
+
return await send_decoded_command(self._channel, params)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ZeoApi(Trait):
|
|
34
|
+
"""API for interacting with Zeo devices."""
|
|
35
|
+
|
|
36
|
+
name = "zeo"
|
|
37
|
+
|
|
38
|
+
def __init__(self, channel: MqttChannel) -> None:
|
|
39
|
+
"""Initialize the Zeo API."""
|
|
40
|
+
self._channel = channel
|
|
41
|
+
|
|
42
|
+
async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]:
|
|
43
|
+
"""Query the device for the values of the given protocols."""
|
|
44
|
+
params = {RoborockZeoProtocol.ID_QUERY: [int(p) for p in protocols]}
|
|
45
|
+
return await send_decoded_command(self._channel, params)
|
|
46
|
+
|
|
47
|
+
async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]:
|
|
48
|
+
"""Set a value for a specific protocol on the device."""
|
|
49
|
+
params = {protocol: value}
|
|
50
|
+
return await send_decoded_command(self._channel, params)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | ZeoApi:
|
|
54
|
+
"""Create traits for A01 devices."""
|
|
55
|
+
match product.category:
|
|
56
|
+
case RoborockCategory.WET_DRY_VAC:
|
|
57
|
+
return DyadApi(mqtt_channel)
|
|
58
|
+
case RoborockCategory.WASHING_MACHINE:
|
|
59
|
+
return ZeoApi(mqtt_channel)
|
|
60
|
+
case _:
|
|
61
|
+
raise NotImplementedError(f"Unsupported category {product.category}")
|
|
@@ -1,26 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import logging
|
|
1
|
+
"""Traits for B01 devices."""
|
|
4
2
|
|
|
5
3
|
from roborock import RoborockB01Methods
|
|
4
|
+
from roborock.devices.b01_channel import send_decoded_command
|
|
5
|
+
from roborock.devices.mqtt_channel import MqttChannel
|
|
6
|
+
from roborock.devices.traits import Trait
|
|
6
7
|
from roborock.roborock_message import RoborockB01Props
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
_LOGGER = logging.getLogger(__name__)
|
|
13
|
-
|
|
14
|
-
__all__ = [
|
|
15
|
-
"B01PropsApi",
|
|
9
|
+
__init__ = [
|
|
10
|
+
"create_b01_traits",
|
|
11
|
+
"PropertiesApi",
|
|
16
12
|
]
|
|
17
13
|
|
|
18
14
|
|
|
19
|
-
class
|
|
15
|
+
class PropertiesApi(Trait):
|
|
20
16
|
"""API for interacting with B01 devices."""
|
|
21
17
|
|
|
22
|
-
name = "B01_props"
|
|
23
|
-
|
|
24
18
|
def __init__(self, channel: MqttChannel) -> None:
|
|
25
19
|
"""Initialize the B01Props API."""
|
|
26
20
|
self._channel = channel
|
|
@@ -30,3 +24,8 @@ class B01PropsApi(Trait):
|
|
|
30
24
|
await send_decoded_command(
|
|
31
25
|
self._channel, dps=10000, command=RoborockB01Methods.GET_PROP, params={"property": props}
|
|
32
26
|
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create(channel: MqttChannel) -> PropertiesApi:
|
|
30
|
+
"""Create traits for B01 devices."""
|
|
31
|
+
return PropertiesApi(channel)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Holds device traits mixin and related code.
|
|
2
|
+
|
|
3
|
+
This holds the TraitsMixin class, which is used to provide accessors for
|
|
4
|
+
various device traits. Each trait is a class that encapsulates a specific
|
|
5
|
+
set of functionality for a device, such as controlling a vacuum or a mop.
|
|
6
|
+
|
|
7
|
+
The TraitsMixin holds traits across all protocol types. A trait is supported
|
|
8
|
+
if it is non-None.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, fields
|
|
12
|
+
from typing import get_args, get_origin
|
|
13
|
+
|
|
14
|
+
from . import Trait, a01, b01, v1
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"TraitsMixin",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(init=False)
|
|
22
|
+
class TraitsMixin:
|
|
23
|
+
"""Mixin to provide trait accessors."""
|
|
24
|
+
|
|
25
|
+
v1_properties: v1.PropertiesApi | None = None
|
|
26
|
+
"""V1 properties trait, if supported."""
|
|
27
|
+
|
|
28
|
+
dyad: a01.DyadApi | None = None
|
|
29
|
+
"""Dyad API, if supported."""
|
|
30
|
+
|
|
31
|
+
zeo: a01.ZeoApi | None = None
|
|
32
|
+
"""Zeo API, if supported."""
|
|
33
|
+
|
|
34
|
+
b01_properties: b01.PropertiesApi | None = None
|
|
35
|
+
"""B01 properties trait, if supported."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, trait: Trait) -> None:
|
|
38
|
+
"""Initialize the TraitsMixin with the given trait.
|
|
39
|
+
|
|
40
|
+
This will populate the appropriate trait attributes based on the types
|
|
41
|
+
of the traits provided.
|
|
42
|
+
"""
|
|
43
|
+
for item in fields(self):
|
|
44
|
+
trait_type = _get_trait_type(item)
|
|
45
|
+
if trait_type == type(trait):
|
|
46
|
+
setattr(self, item.name, trait)
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_trait_type(item) -> type[Trait]:
|
|
51
|
+
"""Get the trait type from a dataclass field."""
|
|
52
|
+
if get_origin(item.type) is None:
|
|
53
|
+
raise ValueError(f"Trait {item.name} is not an optional type")
|
|
54
|
+
if (args := get_args(item.type)) is None:
|
|
55
|
+
raise ValueError(f"Trait {item.name} is not an optional type")
|
|
56
|
+
if len(args) != 2 or args[1] is not type(None):
|
|
57
|
+
raise ValueError(f"Trait {item.name} is not an optional type")
|
|
58
|
+
trait_type = args[0]
|
|
59
|
+
if not issubclass(trait_type, Trait):
|
|
60
|
+
raise ValueError(f"Trait {item.name} is not a Trait subclass")
|
|
61
|
+
return trait_type
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Create traits for V1 devices."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field, fields
|
|
4
|
+
|
|
5
|
+
from roborock.containers import HomeDataProduct
|
|
6
|
+
from roborock.devices.traits import Trait
|
|
7
|
+
from roborock.devices.v1_rpc_channel import V1RpcChannel
|
|
8
|
+
|
|
9
|
+
from .clean_summary import CleanSummaryTrait
|
|
10
|
+
from .common import V1TraitMixin
|
|
11
|
+
from .do_not_disturb import DoNotDisturbTrait
|
|
12
|
+
from .status import StatusTrait
|
|
13
|
+
from .volume import SoundVolumeTrait
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"create",
|
|
17
|
+
"PropertiesApi",
|
|
18
|
+
"StatusTrait",
|
|
19
|
+
"DoNotDisturbTrait",
|
|
20
|
+
"CleanSummaryTrait",
|
|
21
|
+
"SoundVolumeTrait",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PropertiesApi(Trait):
|
|
27
|
+
"""Common properties for V1 devices.
|
|
28
|
+
|
|
29
|
+
This class holds all the traits that are common across all V1 devices.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# All v1 devices have these traits
|
|
33
|
+
status: StatusTrait
|
|
34
|
+
dnd: DoNotDisturbTrait
|
|
35
|
+
clean_summary: CleanSummaryTrait
|
|
36
|
+
sound_volume: SoundVolumeTrait
|
|
37
|
+
|
|
38
|
+
# In the future optional fields can be added below based on supported features
|
|
39
|
+
|
|
40
|
+
def __init__(self, product: HomeDataProduct, rpc_channel: V1RpcChannel) -> None:
|
|
41
|
+
"""Initialize the V1TraitProps with None values."""
|
|
42
|
+
self.status = StatusTrait(product)
|
|
43
|
+
|
|
44
|
+
# This is a hack to allow setting the rpc_channel on all traits. This is
|
|
45
|
+
# used so we can preserve the dataclass behavior when the values in the
|
|
46
|
+
# traits are updated, but still want to allow them to have a reference
|
|
47
|
+
# to the rpc channel for sending commands.
|
|
48
|
+
for item in fields(self):
|
|
49
|
+
if (trait := getattr(self, item.name, None)) is None:
|
|
50
|
+
trait = item.type()
|
|
51
|
+
setattr(self, item.name, trait)
|
|
52
|
+
trait._rpc_channel = rpc_channel
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def create(product: HomeDataProduct, rpc_channel: V1RpcChannel) -> PropertiesApi:
|
|
56
|
+
"""Create traits for V1 devices."""
|
|
57
|
+
return PropertiesApi(product, rpc_channel)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
|
|
3
|
+
from roborock.containers import CleanSummary
|
|
4
|
+
from roborock.devices.traits.v1 import common
|
|
5
|
+
from roborock.roborock_typing import RoborockCommand
|
|
6
|
+
from roborock.util import unpack_list
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CleanSummaryTrait(CleanSummary, common.V1TraitMixin):
|
|
10
|
+
"""Trait for managing the clean summary of Roborock devices."""
|
|
11
|
+
|
|
12
|
+
command = RoborockCommand.GET_CLEAN_SUMMARY
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def _parse_type_response(cls, response: common.V1ResponseData) -> Self:
|
|
16
|
+
"""Parse the response from the device into a CleanSummary."""
|
|
17
|
+
if isinstance(response, dict):
|
|
18
|
+
return cls.from_dict(response)
|
|
19
|
+
elif isinstance(response, list):
|
|
20
|
+
clean_time, clean_area, clean_count, records = unpack_list(response, 4)
|
|
21
|
+
return cls(
|
|
22
|
+
clean_time=clean_time,
|
|
23
|
+
clean_area=clean_area,
|
|
24
|
+
clean_count=clean_count,
|
|
25
|
+
records=records,
|
|
26
|
+
)
|
|
27
|
+
elif isinstance(response, int):
|
|
28
|
+
return cls(clean_time=response)
|
|
29
|
+
raise ValueError(f"Unexpected clean summary format: {response!r}")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Module for Roborock V1 devices common trait commands.
|
|
2
|
+
|
|
3
|
+
This is an internal library and should not be used directly by consumers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC
|
|
7
|
+
from dataclasses import asdict, dataclass, fields
|
|
8
|
+
from typing import ClassVar, Self
|
|
9
|
+
|
|
10
|
+
from roborock.containers import RoborockBase
|
|
11
|
+
from roborock.devices.v1_rpc_channel import V1RpcChannel
|
|
12
|
+
from roborock.roborock_typing import RoborockCommand
|
|
13
|
+
|
|
14
|
+
V1ResponseData = dict | list | int | str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class V1TraitMixin(ABC):
|
|
19
|
+
"""Base model that supports v1 traits.
|
|
20
|
+
|
|
21
|
+
This class provides functioanlity for parsing responses from V1 devices
|
|
22
|
+
into dataclass instances. It also provides a reference to the V1RpcChannel
|
|
23
|
+
used to communicate with the device to execute commands.
|
|
24
|
+
|
|
25
|
+
Each trait subclass must define a class variable `command` that specifies
|
|
26
|
+
the RoborockCommand used to fetch the trait data from the device. The
|
|
27
|
+
`refresh()` method can be called to update the contents of the trait data
|
|
28
|
+
from the device. A trait can also support additional commands for updating
|
|
29
|
+
state associated with the trait.
|
|
30
|
+
|
|
31
|
+
The traits typically subclass RoborockBase to provide serialization
|
|
32
|
+
and deserialization functionality, but this is not strictly required.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
command: ClassVar[RoborockCommand]
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def _parse_type_response(cls, response: V1ResponseData) -> Self:
|
|
39
|
+
"""Parse the response from the device into a a RoborockBase.
|
|
40
|
+
|
|
41
|
+
Subclasses should override this method to implement custom parsing
|
|
42
|
+
logic as needed.
|
|
43
|
+
"""
|
|
44
|
+
if not issubclass(cls, RoborockBase):
|
|
45
|
+
raise NotImplementedError(f"Trait {cls} does not implement RoborockBase")
|
|
46
|
+
# Subclasses can override to implement custom parsing logic
|
|
47
|
+
if isinstance(response, list):
|
|
48
|
+
response = response[0]
|
|
49
|
+
if not isinstance(response, dict):
|
|
50
|
+
raise ValueError(f"Unexpected {cls} response format: {response!r}")
|
|
51
|
+
return cls.from_dict(response)
|
|
52
|
+
|
|
53
|
+
def _parse_response(self, response: V1ResponseData) -> Self:
|
|
54
|
+
"""Parse the response from the device into a a RoborockBase.
|
|
55
|
+
|
|
56
|
+
This is used by subclasses that want to override the class
|
|
57
|
+
behavior with instance-specific data.
|
|
58
|
+
"""
|
|
59
|
+
return self._parse_type_response(response)
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
"""Post-initialization to set up the RPC channel.
|
|
63
|
+
|
|
64
|
+
This is called automatically after the dataclass is initialized by the
|
|
65
|
+
device setup code.
|
|
66
|
+
"""
|
|
67
|
+
self._rpc_channel = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def rpc_channel(self) -> V1RpcChannel:
|
|
71
|
+
"""Helper for executing commands, used internally by the trait"""
|
|
72
|
+
if not self._rpc_channel:
|
|
73
|
+
raise ValueError("Device trait in invalid state")
|
|
74
|
+
return self._rpc_channel
|
|
75
|
+
|
|
76
|
+
async def refresh(self) -> Self:
|
|
77
|
+
"""Refresh the contents of this trait."""
|
|
78
|
+
response = await self.rpc_channel.send_command(self.command)
|
|
79
|
+
new_data = self._parse_response(response)
|
|
80
|
+
for k, v in asdict(new_data).items():
|
|
81
|
+
if v is not None:
|
|
82
|
+
setattr(self, k, v)
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_value_field(clazz: type[V1TraitMixin]) -> str:
|
|
87
|
+
"""Get the name of the field marked as the main value of the RoborockValueBase."""
|
|
88
|
+
value_fields = [field.name for field in fields(clazz) if field.metadata.get("roborock_value", False)]
|
|
89
|
+
if len(value_fields) != 1:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"RoborockValueBase subclass {clazz} must have exactly one field marked as roborock_value, "
|
|
92
|
+
f" but found: {value_fields}"
|
|
93
|
+
)
|
|
94
|
+
return value_fields[0]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(init=False, kw_only=True)
|
|
98
|
+
class RoborockValueBase(V1TraitMixin, RoborockBase):
|
|
99
|
+
"""Base class for traits that represent a single value.
|
|
100
|
+
|
|
101
|
+
This class is intended to be subclassed by traits that represent a single
|
|
102
|
+
value, such as volume or brightness. The subclass should define a single
|
|
103
|
+
field with the metadata `roborock_value=True` to indicate which field
|
|
104
|
+
represents the main value of the trait.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def _parse_response(cls, response: V1ResponseData) -> Self:
|
|
109
|
+
"""Parse the response from the device into a RoborockValueBase."""
|
|
110
|
+
if isinstance(response, list):
|
|
111
|
+
response = response[0]
|
|
112
|
+
if not isinstance(response, int):
|
|
113
|
+
raise ValueError(f"Unexpected response format: {response!r}")
|
|
114
|
+
value_field = _get_value_field(cls)
|
|
115
|
+
return cls(**{value_field: response})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from roborock.containers import DnDTimer
|
|
2
|
+
from roborock.devices.traits.v1 import common
|
|
3
|
+
from roborock.roborock_typing import RoborockCommand
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DoNotDisturbTrait(DnDTimer, common.V1TraitMixin):
|
|
7
|
+
"""Trait for managing Do Not Disturb (DND) settings on Roborock devices."""
|
|
8
|
+
|
|
9
|
+
command = RoborockCommand.GET_DND_TIMER
|
|
10
|
+
|
|
11
|
+
async def set_dnd_timer(self, dnd_timer: DnDTimer) -> None:
|
|
12
|
+
"""Set the Do Not Disturb (DND) timer settings of the device."""
|
|
13
|
+
await self.rpc_channel.send_command(RoborockCommand.SET_DND_TIMER, params=dnd_timer.as_dict())
|
|
14
|
+
|
|
15
|
+
async def clear_dnd_timer(self) -> None:
|
|
16
|
+
"""Clear the Do Not Disturb (DND) timer settings of the device."""
|
|
17
|
+
await self.rpc_channel.send_command(RoborockCommand.CLOSE_DND_TIMER)
|