python-roborock 2.45.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.45.0 → python_roborock-2.47.0}/PKG-INFO +1 -1
- {python_roborock-2.45.0 → python_roborock-2.47.0}/pyproject.toml +1 -1
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/api.py +2 -11
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/cli.py +23 -38
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/cloud_api.py +92 -13
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/device.py +10 -13
- {python_roborock-2.45.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.45.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.45.0 → python_roborock-2.47.0}/roborock/exceptions.py +1 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/protocol.py +44 -11
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/protocols/v1_protocol.py +2 -3
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/util.py +1 -1
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_client_v1.py +7 -1
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_local_client_v1.py +89 -25
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +1 -1
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +1 -1
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/web_api.py +2 -2
- python_roborock-2.45.0/roborock/devices/traits/b01/__init__.py +0 -0
- python_roborock-2.45.0/roborock/devices/traits/clean_summary.py +0 -52
- python_roborock-2.45.0/roborock/devices/traits/dnd.py +0 -41
- python_roborock-2.45.0/roborock/devices/traits/dyad.py +0 -36
- python_roborock-2.45.0/roborock/devices/traits/sound_volume.py +0 -31
- python_roborock-2.45.0/roborock/devices/traits/status.py +0 -49
- python_roborock-2.45.0/roborock/devices/traits/trait.py +0 -10
- python_roborock-2.45.0/roborock/devices/traits/zeo.py +0 -36
- {python_roborock-2.45.0 → python_roborock-2.47.0}/LICENSE +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/README.md +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/__init__.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/b01_containers.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/broadcast_protocol.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/callbacks.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/clean_modes.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/code_mappings.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/command_cache.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/const.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/containers.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/device_features.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/README.md +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/__init__.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/a01_channel.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/b01_channel.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/cache.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/channel.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/local_channel.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/mqtt_channel.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/v1_channel.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/devices/v1_rpc_channel.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/protocols/b01_protocol.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/py.typed +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/roborock_future.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/roborock_message.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_1_apis/__init__.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_a01_apis/__init__.py +0 -0
- {python_roborock-2.45.0 → python_roborock-2.47.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
|
@@ -18,12 +18,11 @@ 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
|
|
|
25
24
|
_LOGGER = logging.getLogger(__name__)
|
|
26
|
-
KEEPALIVE =
|
|
25
|
+
KEEPALIVE = 70
|
|
27
26
|
|
|
28
27
|
|
|
29
28
|
class RoborockClient(ABC):
|
|
@@ -78,12 +77,6 @@ class RoborockClient(ABC):
|
|
|
78
77
|
return False
|
|
79
78
|
return True
|
|
80
79
|
|
|
81
|
-
async def validate_connection(self) -> None:
|
|
82
|
-
if not self.should_keepalive():
|
|
83
|
-
self._logger.info("Resetting Roborock connection due to keepalive timeout")
|
|
84
|
-
await self.async_disconnect()
|
|
85
|
-
await self.async_connect()
|
|
86
|
-
|
|
87
80
|
async def _wait_response(self, request_id: int, queue: RoborockFuture) -> Any:
|
|
88
81
|
try:
|
|
89
82
|
response = await queue.async_get(self.queue_timeout)
|
|
@@ -97,9 +90,7 @@ class RoborockClient(ABC):
|
|
|
97
90
|
|
|
98
91
|
def _async_response(self, request_id: int, protocol_id: int = 0) -> Any:
|
|
99
92
|
queue = RoborockFuture(protocol_id)
|
|
100
|
-
if request_id in self._waiting_queue
|
|
101
|
-
request_id == 2 and protocol_id == RoborockMessageProtocol.PING_REQUEST
|
|
102
|
-
):
|
|
93
|
+
if request_id in self._waiting_queue:
|
|
103
94
|
new_id = get_next_int(10000, 32767)
|
|
104
95
|
self._logger.warning(
|
|
105
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
|
|
|
@@ -8,6 +8,10 @@ from asyncio import Lock
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
10
|
import paho.mqtt.client as mqtt
|
|
11
|
+
from paho.mqtt.enums import MQTTErrorCode
|
|
12
|
+
|
|
13
|
+
# Mypy is not seeing this for some reason. It wants me to use the depreciated ReasonCodes
|
|
14
|
+
from paho.mqtt.reasoncodes import ReasonCode # type: ignore
|
|
11
15
|
|
|
12
16
|
from .api import KEEPALIVE, RoborockClient
|
|
13
17
|
from .containers import DeviceData, UserData
|
|
@@ -67,7 +71,8 @@ class RoborockMqttClient(RoborockClient, ABC):
|
|
|
67
71
|
self._mqtt_client = _Mqtt()
|
|
68
72
|
self._mqtt_client.on_connect = self._mqtt_on_connect
|
|
69
73
|
self._mqtt_client.on_message = self._mqtt_on_message
|
|
70
|
-
|
|
74
|
+
# Due to the incorrect ReasonCode, it is confused by typing
|
|
75
|
+
self._mqtt_client.on_disconnect = self._mqtt_on_disconnect # type: ignore
|
|
71
76
|
if mqtt_params.tls:
|
|
72
77
|
self._mqtt_client.tls_set()
|
|
73
78
|
|
|
@@ -76,12 +81,20 @@ class RoborockMqttClient(RoborockClient, ABC):
|
|
|
76
81
|
self._mutex = Lock()
|
|
77
82
|
self._decoder: Decoder = create_mqtt_decoder(device_info.device.local_key)
|
|
78
83
|
self._encoder: Encoder = create_mqtt_encoder(device_info.device.local_key)
|
|
84
|
+
self.received_message_since_last_disconnect = False
|
|
85
|
+
self._topic = f"rr/m/o/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}"
|
|
79
86
|
|
|
80
|
-
def _mqtt_on_connect(
|
|
81
|
-
|
|
87
|
+
def _mqtt_on_connect(
|
|
88
|
+
self,
|
|
89
|
+
client: mqtt.Client,
|
|
90
|
+
userdata: object,
|
|
91
|
+
flags: dict[str, int],
|
|
92
|
+
rc: ReasonCode,
|
|
93
|
+
properties: mqtt.Properties | None = None,
|
|
94
|
+
):
|
|
82
95
|
connection_queue = self._waiting_queue.get(CONNECT_REQUEST_ID)
|
|
83
|
-
if rc
|
|
84
|
-
message = f"Failed to connect ({
|
|
96
|
+
if rc.is_failure:
|
|
97
|
+
message = f"Failed to connect ({rc})"
|
|
85
98
|
self._logger.error(message)
|
|
86
99
|
if connection_queue:
|
|
87
100
|
connection_queue.set_exception(VacuumError(message))
|
|
@@ -89,19 +102,19 @@ class RoborockMqttClient(RoborockClient, ABC):
|
|
|
89
102
|
self._logger.debug("Failed to notify connect future, not in queue")
|
|
90
103
|
return
|
|
91
104
|
self._logger.info(f"Connected to mqtt {self._mqtt_host}:{self._mqtt_port}")
|
|
92
|
-
|
|
93
|
-
(result, mid) = self._mqtt_client.subscribe(topic)
|
|
105
|
+
(result, mid) = self._mqtt_client.subscribe(self._topic)
|
|
94
106
|
if result != 0:
|
|
95
|
-
message = f"Failed to subscribe ({
|
|
107
|
+
message = f"Failed to subscribe ({str(rc)})"
|
|
96
108
|
self._logger.error(message)
|
|
97
109
|
if connection_queue:
|
|
98
110
|
connection_queue.set_exception(VacuumError(message))
|
|
99
111
|
return
|
|
100
|
-
self._logger.info(f"Subscribed to topic {
|
|
112
|
+
self._logger.info(f"Subscribed to topic {self._topic}")
|
|
101
113
|
if connection_queue:
|
|
102
114
|
connection_queue.set_result(True)
|
|
103
115
|
|
|
104
116
|
def _mqtt_on_message(self, *args, **kwargs):
|
|
117
|
+
self.received_message_since_last_disconnect = True
|
|
105
118
|
client, __, msg = args
|
|
106
119
|
try:
|
|
107
120
|
messages = self._decoder(msg.payload)
|
|
@@ -109,10 +122,16 @@ class RoborockMqttClient(RoborockClient, ABC):
|
|
|
109
122
|
except Exception as ex:
|
|
110
123
|
self._logger.exception(ex)
|
|
111
124
|
|
|
112
|
-
def _mqtt_on_disconnect(
|
|
113
|
-
|
|
125
|
+
def _mqtt_on_disconnect(
|
|
126
|
+
self,
|
|
127
|
+
client: mqtt.Client,
|
|
128
|
+
data: object,
|
|
129
|
+
flags: dict[str, int],
|
|
130
|
+
rc: ReasonCode | None,
|
|
131
|
+
properties: mqtt.Properties | None = None,
|
|
132
|
+
):
|
|
114
133
|
try:
|
|
115
|
-
exc = RoborockException(
|
|
134
|
+
exc = RoborockException(str(rc)) if rc is not None and rc.is_failure else None
|
|
116
135
|
super().on_connection_lost(exc)
|
|
117
136
|
connection_queue = self._waiting_queue.get(DISCONNECT_REQUEST_ID)
|
|
118
137
|
if connection_queue:
|
|
@@ -138,7 +157,7 @@ class RoborockMqttClient(RoborockClient, ABC):
|
|
|
138
157
|
|
|
139
158
|
if rc != mqtt.MQTT_ERR_SUCCESS:
|
|
140
159
|
disconnected_future.cancel()
|
|
141
|
-
raise RoborockException(f"Failed to disconnect ({
|
|
160
|
+
raise RoborockException(f"Failed to disconnect ({str(rc)})")
|
|
142
161
|
|
|
143
162
|
return disconnected_future
|
|
144
163
|
|
|
@@ -178,3 +197,63 @@ class RoborockMqttClient(RoborockClient, ABC):
|
|
|
178
197
|
)
|
|
179
198
|
if info.rc != mqtt.MQTT_ERR_SUCCESS:
|
|
180
199
|
raise RoborockException(f"Failed to publish ({mqtt.error_string(info.rc)})")
|
|
200
|
+
|
|
201
|
+
async def _unsubscribe(self) -> MQTTErrorCode:
|
|
202
|
+
"""Unsubscribe from the topic."""
|
|
203
|
+
loop = asyncio.get_running_loop()
|
|
204
|
+
(result, mid) = await loop.run_in_executor(None, self._mqtt_client.unsubscribe, self._topic)
|
|
205
|
+
|
|
206
|
+
if result != 0:
|
|
207
|
+
message = f"Failed to unsubscribe ({mqtt.error_string(result)})"
|
|
208
|
+
self._logger.error(message)
|
|
209
|
+
else:
|
|
210
|
+
self._logger.info(f"Unsubscribed from topic {self._topic}")
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
async def _subscribe(self) -> MQTTErrorCode:
|
|
214
|
+
"""Subscribe to the topic."""
|
|
215
|
+
loop = asyncio.get_running_loop()
|
|
216
|
+
(result, mid) = await loop.run_in_executor(None, self._mqtt_client.subscribe, self._topic)
|
|
217
|
+
|
|
218
|
+
if result != 0:
|
|
219
|
+
message = f"Failed to subscribe ({mqtt.error_string(result)})"
|
|
220
|
+
self._logger.error(message)
|
|
221
|
+
else:
|
|
222
|
+
self._logger.info(f"Subscribed to topic {self._topic}")
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
async def _reconnect(self) -> None:
|
|
226
|
+
"""Reconnect to the MQTT broker."""
|
|
227
|
+
await self.async_disconnect()
|
|
228
|
+
await self.async_connect()
|
|
229
|
+
|
|
230
|
+
async def _validate_connection(self) -> None:
|
|
231
|
+
"""Override the default validate connection to try to re-subscribe rather than disconnect.
|
|
232
|
+
When something seems to be wrong with our connection, we should follow the following steps:
|
|
233
|
+
1. Try to unsubscribe and resubscribe from the topic.
|
|
234
|
+
2. If we don't end up getting a message, we should completely disconnect and reconnect to the MQTT broker.
|
|
235
|
+
3. We will continue to try to disconnect and reconnect until we get a message.
|
|
236
|
+
4. If we get a message, the next time connection is lost, We will go back to step 1.
|
|
237
|
+
"""
|
|
238
|
+
# If we should no longer keep the current connection alive...
|
|
239
|
+
if not self.should_keepalive():
|
|
240
|
+
self._logger.info("Resetting Roborock connection due to keepalive timeout")
|
|
241
|
+
if not self.received_message_since_last_disconnect:
|
|
242
|
+
# If we have already tried to unsub and resub, and we are still in this state,
|
|
243
|
+
# we should try to reconnect.
|
|
244
|
+
return await self._reconnect()
|
|
245
|
+
try:
|
|
246
|
+
# Mark that we have tried to unsubscribe and resubscribe
|
|
247
|
+
self.received_message_since_last_disconnect = False
|
|
248
|
+
if await self._unsubscribe() != 0:
|
|
249
|
+
# If we fail to unsubscribe, reconnect to the broker
|
|
250
|
+
return await self._reconnect()
|
|
251
|
+
if await self._subscribe() != 0:
|
|
252
|
+
# If we fail to subscribe, reconnected to the broker.
|
|
253
|
+
return await self._reconnect()
|
|
254
|
+
|
|
255
|
+
except Exception: # noqa
|
|
256
|
+
# If we get any errors at all, we should just reconnect.
|
|
257
|
+
return await self._reconnect()
|
|
258
|
+
# Call connect to make sure everything is still in a good state.
|
|
259
|
+
await self.async_connect()
|
|
@@ -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
|