roborock-cli 0.1.1__py3-none-any.whl
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.
- roborock_cli/__init__.py +3 -0
- roborock_cli/__main__.py +76 -0
- roborock_cli/_vendor/VERSION +6 -0
- roborock_cli/_vendor/__init__.py +0 -0
- roborock_cli/_vendor/roborock/__init__.py +27 -0
- roborock_cli/_vendor/roborock/broadcast_protocol.py +114 -0
- roborock_cli/_vendor/roborock/callbacks.py +130 -0
- roborock_cli/_vendor/roborock/cli.py +1338 -0
- roborock_cli/_vendor/roborock/const.py +84 -0
- roborock_cli/_vendor/roborock/data/__init__.py +9 -0
- roborock_cli/_vendor/roborock/data/b01_q10/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_code_mappings.py +213 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_containers.py +102 -0
- roborock_cli/_vendor/roborock/data/b01_q7/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_code_mappings.py +303 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_containers.py +302 -0
- roborock_cli/_vendor/roborock/data/code_mappings.py +198 -0
- roborock_cli/_vendor/roborock/data/containers.py +530 -0
- roborock_cli/_vendor/roborock/data/dyad/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_code_mappings.py +102 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_containers.py +28 -0
- roborock_cli/_vendor/roborock/data/v1/__init__.py +3 -0
- roborock_cli/_vendor/roborock/data/v1/v1_clean_modes.py +192 -0
- roborock_cli/_vendor/roborock/data/v1/v1_code_mappings.py +644 -0
- roborock_cli/_vendor/roborock/data/v1/v1_containers.py +800 -0
- roborock_cli/_vendor/roborock/data/zeo/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_code_mappings.py +138 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_containers.py +0 -0
- roborock_cli/_vendor/roborock/device_features.py +668 -0
- roborock_cli/_vendor/roborock/devices/README.md +41 -0
- roborock_cli/_vendor/roborock/devices/__init__.py +11 -0
- roborock_cli/_vendor/roborock/devices/cache.py +143 -0
- roborock_cli/_vendor/roborock/devices/device.py +240 -0
- roborock_cli/_vendor/roborock/devices/device_manager.py +269 -0
- roborock_cli/_vendor/roborock/devices/file_cache.py +79 -0
- roborock_cli/_vendor/roborock/devices/rpc/__init__.py +14 -0
- roborock_cli/_vendor/roborock/devices/rpc/a01_channel.py +94 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q10_channel.py +57 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q7_channel.py +101 -0
- roborock_cli/_vendor/roborock/devices/rpc/v1_channel.py +457 -0
- roborock_cli/_vendor/roborock/devices/traits/__init__.py +28 -0
- roborock_cli/_vendor/roborock/devices/traits/a01/__init__.py +191 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/__init__.py +12 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/__init__.py +76 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/command.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/common.py +115 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/status.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/vacuum.py +81 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/__init__.py +136 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/clean_summary.py +75 -0
- roborock_cli/_vendor/roborock/devices/traits/traits_mixin.py +64 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/__init__.py +344 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/child_lock.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/clean_summary.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/command.py +38 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/common.py +172 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/consumeable.py +48 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/device_features.py +74 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/do_not_disturb.py +41 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/dust_collection_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/flow_led_status.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/home.py +285 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/led_status.py +43 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/map_content.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/maps.py +80 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/network_info.py +55 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/rooms.py +105 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/routines.py +26 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/smart_wash_params.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/status.py +101 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/valley_electricity_timer.py +44 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/volume.py +27 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/wash_towel_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/transport/__init__.py +8 -0
- roborock_cli/_vendor/roborock/devices/transport/channel.py +32 -0
- roborock_cli/_vendor/roborock/devices/transport/local_channel.py +295 -0
- roborock_cli/_vendor/roborock/devices/transport/mqtt_channel.py +118 -0
- roborock_cli/_vendor/roborock/diagnostics.py +166 -0
- roborock_cli/_vendor/roborock/exceptions.py +95 -0
- roborock_cli/_vendor/roborock/map/__init__.py +7 -0
- roborock_cli/_vendor/roborock/map/map_parser.py +123 -0
- roborock_cli/_vendor/roborock/mqtt/__init__.py +10 -0
- roborock_cli/_vendor/roborock/mqtt/health_manager.py +60 -0
- roborock_cli/_vendor/roborock/mqtt/roborock_session.py +463 -0
- roborock_cli/_vendor/roborock/mqtt/session.py +108 -0
- roborock_cli/_vendor/roborock/protocol.py +558 -0
- roborock_cli/_vendor/roborock/protocols/__init__.py +3 -0
- roborock_cli/_vendor/roborock/protocols/a01_protocol.py +74 -0
- roborock_cli/_vendor/roborock/protocols/b01_q10_protocol.py +87 -0
- roborock_cli/_vendor/roborock/protocols/b01_q7_protocol.py +81 -0
- roborock_cli/_vendor/roborock/protocols/v1_protocol.py +271 -0
- roborock_cli/_vendor/roborock/py.typed +0 -0
- roborock_cli/_vendor/roborock/roborock_message.py +246 -0
- roborock_cli/_vendor/roborock/roborock_typing.py +382 -0
- roborock_cli/_vendor/roborock/util.py +54 -0
- roborock_cli/_vendor/roborock/web_api.py +761 -0
- roborock_cli/cli.py +715 -0
- roborock_cli/connection.py +202 -0
- roborock_cli/helpers.py +71 -0
- roborock_cli/server.py +759 -0
- roborock_cli/setup_auth.py +92 -0
- roborock_cli-0.1.1.dist-info/METADATA +172 -0
- roborock_cli-0.1.1.dist-info/RECORD +106 -0
- roborock_cli-0.1.1.dist-info/WHEEL +4 -0
- roborock_cli-0.1.1.dist-info/entry_points.txt +2 -0
- roborock_cli-0.1.1.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Trait for managing consumable attributes.
|
|
2
|
+
|
|
3
|
+
A consumable attribute is one that is expected to be replaced or refilled
|
|
4
|
+
periodically, such as filters, brushes, etc.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Self
|
|
9
|
+
|
|
10
|
+
from roborock_cli._vendor.roborock.data import Consumable
|
|
11
|
+
from roborock_cli._vendor.roborock.devices.traits.v1 import common
|
|
12
|
+
from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ConsumableTrait",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConsumableAttribute(StrEnum):
|
|
20
|
+
"""Enum for consumable attributes."""
|
|
21
|
+
|
|
22
|
+
SENSOR_DIRTY_TIME = "sensor_dirty_time"
|
|
23
|
+
FILTER_WORK_TIME = "filter_work_time"
|
|
24
|
+
SIDE_BRUSH_WORK_TIME = "side_brush_work_time"
|
|
25
|
+
MAIN_BRUSH_WORK_TIME = "main_brush_work_time"
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_str(cls, value: str) -> Self:
|
|
29
|
+
"""Create a ConsumableAttribute from a string value."""
|
|
30
|
+
for member in cls:
|
|
31
|
+
if member.value == value:
|
|
32
|
+
return member
|
|
33
|
+
raise ValueError(f"Unknown ConsumableAttribute: {value}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConsumableTrait(Consumable, common.V1TraitMixin):
|
|
37
|
+
"""Trait for managing consumable attributes on Roborock devices.
|
|
38
|
+
|
|
39
|
+
After the first refresh, you can tell what consumables are supported by
|
|
40
|
+
checking which attributes are not None.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
command = RoborockCommand.GET_CONSUMABLE
|
|
44
|
+
|
|
45
|
+
async def reset_consumable(self, consumable: ConsumableAttribute) -> None:
|
|
46
|
+
"""Reset a specific consumable attribute on the device."""
|
|
47
|
+
await self.rpc_channel.send_command(RoborockCommand.RESET_CONSUMABLE, params=[consumable.value])
|
|
48
|
+
await self.refresh()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from dataclasses import Field, fields
|
|
2
|
+
|
|
3
|
+
from roborock_cli._vendor.roborock.data import AppInitStatus, HomeDataProduct, RoborockBase
|
|
4
|
+
from roborock_cli._vendor.roborock.data.v1.v1_containers import FieldNameBase
|
|
5
|
+
from roborock_cli._vendor.roborock.device_features import DeviceFeatures
|
|
6
|
+
from roborock_cli._vendor.roborock.devices.cache import DeviceCache
|
|
7
|
+
from roborock_cli._vendor.roborock.devices.traits.v1 import common
|
|
8
|
+
from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DeviceFeaturesTrait(DeviceFeatures, common.V1TraitMixin):
|
|
12
|
+
"""Trait for managing supported features on Roborock devices."""
|
|
13
|
+
|
|
14
|
+
command = RoborockCommand.APP_GET_INIT_STATUS
|
|
15
|
+
|
|
16
|
+
def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None: # pylint: disable=super-init-not-called
|
|
17
|
+
"""Initialize DeviceFeaturesTrait."""
|
|
18
|
+
self._product = product
|
|
19
|
+
self._nickname = product.product_nickname
|
|
20
|
+
self._device_cache = device_cache
|
|
21
|
+
# All fields of DeviceFeatures are required. Initialize them to False
|
|
22
|
+
# so we have some known state.
|
|
23
|
+
for field in fields(self):
|
|
24
|
+
setattr(self, field.name, False)
|
|
25
|
+
|
|
26
|
+
def is_field_supported(self, cls: type[RoborockBase], field_name: FieldNameBase) -> bool:
|
|
27
|
+
"""Determines if the specified field is supported by this device.
|
|
28
|
+
|
|
29
|
+
We use dataclass attributes on the field to specify the schema code that is required
|
|
30
|
+
for the field to be supported and it is compared against the list of
|
|
31
|
+
supported schema codes for the device returned in the product information.
|
|
32
|
+
"""
|
|
33
|
+
dataclass_field: Field | None = None
|
|
34
|
+
for field in fields(cls):
|
|
35
|
+
if field.name == field_name:
|
|
36
|
+
dataclass_field = field
|
|
37
|
+
break
|
|
38
|
+
if dataclass_field is None:
|
|
39
|
+
raise ValueError(f"Field {field_name} not found in {cls}")
|
|
40
|
+
|
|
41
|
+
requires_schema_code = dataclass_field.metadata.get("requires_schema_code", None)
|
|
42
|
+
if requires_schema_code is None:
|
|
43
|
+
# We assume the field is supported
|
|
44
|
+
return True
|
|
45
|
+
# If the field requires a protocol that is not supported, we return False
|
|
46
|
+
return requires_schema_code in self._product.supported_schema_codes
|
|
47
|
+
|
|
48
|
+
async def refresh(self) -> None:
|
|
49
|
+
"""Refresh the contents of this trait.
|
|
50
|
+
|
|
51
|
+
This will use cached device features if available since they do not
|
|
52
|
+
change often and this avoids unnecessary RPC calls. This would only
|
|
53
|
+
ever change with a firmware update, so caching is appropriate.
|
|
54
|
+
"""
|
|
55
|
+
cache_data = await self._device_cache.get()
|
|
56
|
+
if cache_data.device_features is not None:
|
|
57
|
+
self._update_trait_values(cache_data.device_features)
|
|
58
|
+
return
|
|
59
|
+
# Save cached device features
|
|
60
|
+
await super().refresh()
|
|
61
|
+
cache_data.device_features = self
|
|
62
|
+
await self._device_cache.set(cache_data)
|
|
63
|
+
|
|
64
|
+
def _parse_response(self, response: common.V1ResponseData) -> DeviceFeatures:
|
|
65
|
+
"""Parse the response from the device into a MapContentTrait instance."""
|
|
66
|
+
if not isinstance(response, list):
|
|
67
|
+
raise ValueError(f"Unexpected AppInitStatus response format: {type(response)}")
|
|
68
|
+
app_status = AppInitStatus.from_dict(response[0])
|
|
69
|
+
return DeviceFeatures.from_feature_flags(
|
|
70
|
+
new_feature_info=app_status.new_feature_info,
|
|
71
|
+
new_feature_info_str=app_status.new_feature_info_str,
|
|
72
|
+
feature_info=app_status.feature_info,
|
|
73
|
+
product_nickname=self._nickname,
|
|
74
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from roborock_cli._vendor.roborock.data import DnDTimer
|
|
2
|
+
from roborock_cli._vendor.roborock.devices.traits.v1 import common
|
|
3
|
+
from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
|
|
4
|
+
|
|
5
|
+
_ENABLED_PARAM = "enabled"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DoNotDisturbTrait(DnDTimer, common.V1TraitMixin, common.RoborockSwitchBase):
|
|
9
|
+
"""Trait for managing Do Not Disturb (DND) settings on Roborock devices."""
|
|
10
|
+
|
|
11
|
+
command = RoborockCommand.GET_DND_TIMER
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def is_on(self) -> bool:
|
|
15
|
+
"""Return whether the Do Not Disturb (DND) timer is enabled."""
|
|
16
|
+
return self.enabled == 1
|
|
17
|
+
|
|
18
|
+
async def set_dnd_timer(self, dnd_timer: DnDTimer) -> None:
|
|
19
|
+
"""Set the Do Not Disturb (DND) timer settings of the device."""
|
|
20
|
+
await self.rpc_channel.send_command(RoborockCommand.SET_DND_TIMER, params=dnd_timer.as_list())
|
|
21
|
+
await self.refresh()
|
|
22
|
+
|
|
23
|
+
async def clear_dnd_timer(self) -> None:
|
|
24
|
+
"""Clear the Do Not Disturb (DND) timer settings of the device."""
|
|
25
|
+
await self.rpc_channel.send_command(RoborockCommand.CLOSE_DND_TIMER)
|
|
26
|
+
await self.refresh()
|
|
27
|
+
|
|
28
|
+
async def enable(self) -> None:
|
|
29
|
+
"""Set the Do Not Disturb (DND) timer settings of the device."""
|
|
30
|
+
await self.rpc_channel.send_command(
|
|
31
|
+
RoborockCommand.SET_DND_TIMER,
|
|
32
|
+
params=self.as_list(),
|
|
33
|
+
)
|
|
34
|
+
# Optimistic update to avoid an extra refresh
|
|
35
|
+
self.enabled = 1
|
|
36
|
+
|
|
37
|
+
async def disable(self) -> None:
|
|
38
|
+
"""Disable the Do Not Disturb (DND) timer settings of the device."""
|
|
39
|
+
await self.rpc_channel.send_command(RoborockCommand.CLOSE_DND_TIMER)
|
|
40
|
+
# Optimistic update to avoid an extra refresh
|
|
41
|
+
self.enabled = 0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Trait for dust collection mode."""
|
|
2
|
+
|
|
3
|
+
from roborock_cli._vendor.roborock.data import DustCollectionMode
|
|
4
|
+
from roborock_cli._vendor.roborock.device_features import is_valid_dock
|
|
5
|
+
from roborock_cli._vendor.roborock.devices.traits.v1 import common
|
|
6
|
+
from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DustCollectionModeTrait(DustCollectionMode, common.V1TraitMixin):
|
|
10
|
+
"""Trait for dust collection mode."""
|
|
11
|
+
|
|
12
|
+
command = RoborockCommand.GET_DUST_COLLECTION_MODE
|
|
13
|
+
requires_dock_type = is_valid_dock
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from roborock_cli._vendor.roborock.data import FlowLedStatus
|
|
2
|
+
from roborock_cli._vendor.roborock.devices.traits.v1 import common
|
|
3
|
+
from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
|
|
4
|
+
|
|
5
|
+
_STATUS_PARAM = "status"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FlowLedStatusTrait(FlowLedStatus, common.V1TraitMixin, common.RoborockSwitchBase):
|
|
9
|
+
"""Trait for controlling the Flow LED status of a Roborock device."""
|
|
10
|
+
|
|
11
|
+
command = RoborockCommand.GET_FLOW_LED_STATUS
|
|
12
|
+
requires_feature = "is_flow_led_setting_supported"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def is_on(self) -> bool:
|
|
16
|
+
"""Return whether the Flow LED status is enabled."""
|
|
17
|
+
return self.status == 1
|
|
18
|
+
|
|
19
|
+
async def enable(self) -> None:
|
|
20
|
+
"""Enable the Flow LED status."""
|
|
21
|
+
await self.rpc_channel.send_command(RoborockCommand.SET_FLOW_LED_STATUS, params={_STATUS_PARAM: 1})
|
|
22
|
+
# Optimistic update to avoid an extra refresh
|
|
23
|
+
self.status = 1
|
|
24
|
+
|
|
25
|
+
async def disable(self) -> None:
|
|
26
|
+
"""Disable the Flow LED status."""
|
|
27
|
+
await self.rpc_channel.send_command(RoborockCommand.SET_FLOW_LED_STATUS, params={_STATUS_PARAM: 0})
|
|
28
|
+
# Optimistic update to avoid an extra refresh
|
|
29
|
+
self.status = 0
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Trait that represents a full view of the home layout.
|
|
2
|
+
|
|
3
|
+
This trait combines information about maps and rooms to provide a comprehensive
|
|
4
|
+
view of the home layout, including room names and their corresponding segment
|
|
5
|
+
on the map. It also makes it straight forward to fetch the map image and data.
|
|
6
|
+
|
|
7
|
+
This trait depends on the MapsTrait and RoomsTrait to gather the necessary
|
|
8
|
+
information. It provides properties to access the current map, the list of
|
|
9
|
+
rooms with names, and the map image and data.
|
|
10
|
+
|
|
11
|
+
Callers may first call `discover_home()` to populate the home layout cache by
|
|
12
|
+
iterating through all available maps on the device. This will cache the map
|
|
13
|
+
information and room names for all maps to minimize map switching and improve
|
|
14
|
+
performance. After the initial discovery, callers can call `refresh()` to update
|
|
15
|
+
the current map's information and room names as needed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import base64
|
|
20
|
+
import logging
|
|
21
|
+
from typing import Self
|
|
22
|
+
|
|
23
|
+
from roborock_cli._vendor.roborock.data import CombinedMapInfo, MultiMapsListMapInfo, NamedRoomMapping, RoborockBase
|
|
24
|
+
from roborock_cli._vendor.roborock.data.v1.v1_code_mappings import RoborockStateCode
|
|
25
|
+
from roborock_cli._vendor.roborock.devices.cache import DeviceCache
|
|
26
|
+
from roborock_cli._vendor.roborock.devices.traits.v1 import common
|
|
27
|
+
from roborock_cli._vendor.roborock.exceptions import RoborockDeviceBusy, RoborockException, RoborockInvalidStatus
|
|
28
|
+
from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
|
|
29
|
+
|
|
30
|
+
from .map_content import MapContent, MapContentTrait
|
|
31
|
+
from .maps import MapsTrait
|
|
32
|
+
from .rooms import RoomsTrait
|
|
33
|
+
from .status import StatusTrait
|
|
34
|
+
|
|
35
|
+
_LOGGER = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
MAP_SLEEP = 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HomeTrait(RoborockBase, common.V1TraitMixin):
|
|
41
|
+
"""Trait that represents a full view of the home layout."""
|
|
42
|
+
|
|
43
|
+
command = RoborockCommand.GET_MAP_V1 # This is not used
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
status_trait: StatusTrait,
|
|
48
|
+
maps_trait: MapsTrait,
|
|
49
|
+
map_content: MapContentTrait,
|
|
50
|
+
rooms_trait: RoomsTrait,
|
|
51
|
+
device_cache: DeviceCache,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Initialize the HomeTrait.
|
|
54
|
+
|
|
55
|
+
We keep track of the MapsTrait and RoomsTrait to provide a comprehensive
|
|
56
|
+
view of the home layout. This also depends on the StatusTrait to determine
|
|
57
|
+
the current map. See comments in MapsTrait for details on that dependency.
|
|
58
|
+
|
|
59
|
+
The cache is used to store discovered home data to minimize map switching
|
|
60
|
+
and improve performance. The cache should be persisted by the caller to
|
|
61
|
+
ensure data is retained across restarts.
|
|
62
|
+
|
|
63
|
+
After initial discovery, only information for the current map is refreshed
|
|
64
|
+
to keep data up to date without excessive map switching. However, as
|
|
65
|
+
users switch rooms, the current map's data will be updated to ensure
|
|
66
|
+
accuracy.
|
|
67
|
+
"""
|
|
68
|
+
super().__init__()
|
|
69
|
+
self._status_trait = status_trait
|
|
70
|
+
self._maps_trait = maps_trait
|
|
71
|
+
self._map_content = map_content
|
|
72
|
+
self._rooms_trait = rooms_trait
|
|
73
|
+
self._device_cache = device_cache
|
|
74
|
+
self._discovery_completed = False
|
|
75
|
+
self._home_map_info: dict[int, CombinedMapInfo] | None = None
|
|
76
|
+
self._home_map_content: dict[int, MapContent] | None = None
|
|
77
|
+
|
|
78
|
+
async def discover_home(self) -> None:
|
|
79
|
+
"""Iterate through all maps to discover rooms and cache them.
|
|
80
|
+
|
|
81
|
+
This will be a no-op if the home cache is already populated.
|
|
82
|
+
|
|
83
|
+
This cannot be called while the device is cleaning, as that would interrupt the
|
|
84
|
+
cleaning process. This will raise `RoborockDeviceBusy` if the device is
|
|
85
|
+
currently cleaning.
|
|
86
|
+
|
|
87
|
+
After discovery, the home cache will be populated and can be accessed via the `home_map_info` property.
|
|
88
|
+
"""
|
|
89
|
+
device_cache_data = await self._device_cache.get()
|
|
90
|
+
if device_cache_data and device_cache_data.home_map_info:
|
|
91
|
+
_LOGGER.debug("Home cache already populated, skipping discovery")
|
|
92
|
+
self._home_map_info = device_cache_data.home_map_info
|
|
93
|
+
self._discovery_completed = True
|
|
94
|
+
try:
|
|
95
|
+
self._home_map_content = {
|
|
96
|
+
k: self._map_content.parse_map_content(base64.b64decode(v))
|
|
97
|
+
for k, v in (device_cache_data.home_map_content_base64 or {}).items()
|
|
98
|
+
}
|
|
99
|
+
except (ValueError, RoborockException) as ex:
|
|
100
|
+
_LOGGER.warning("Failed to parse cached home map content, will re-discover: %s", ex)
|
|
101
|
+
self._home_map_content = {}
|
|
102
|
+
else:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if self._status_trait.state == RoborockStateCode.cleaning:
|
|
106
|
+
raise RoborockDeviceBusy("Cannot perform home discovery while the device is cleaning")
|
|
107
|
+
|
|
108
|
+
await self._maps_trait.refresh()
|
|
109
|
+
if self._maps_trait.current_map_info is None:
|
|
110
|
+
raise RoborockException("Cannot perform home discovery without current map info")
|
|
111
|
+
|
|
112
|
+
home_map_info, home_map_content = await self._build_home_map_info()
|
|
113
|
+
_LOGGER.debug("Home discovery complete, caching data for %d maps", len(home_map_info))
|
|
114
|
+
self._discovery_completed = True
|
|
115
|
+
await self._update_home_cache(home_map_info, home_map_content)
|
|
116
|
+
|
|
117
|
+
async def _refresh_map_info(self, map_info: MultiMapsListMapInfo) -> CombinedMapInfo:
|
|
118
|
+
"""Collect room data for a specific map and return CombinedMapInfo."""
|
|
119
|
+
await self._rooms_trait.refresh()
|
|
120
|
+
|
|
121
|
+
# We have room names from multiple sources:
|
|
122
|
+
# - The map_info.rooms which we just received from the MultiMapsList
|
|
123
|
+
# - RoomsTrait rooms come from the GET_ROOM_MAPPING command for the current device (only)
|
|
124
|
+
# - RoomsTrait rooms that are pulled from the cloud API
|
|
125
|
+
# We always prefer the RoomsTrait room names since they are always newer and
|
|
126
|
+
# just refreshed above.
|
|
127
|
+
rooms_map: dict[int, NamedRoomMapping] = {
|
|
128
|
+
**map_info.rooms_map,
|
|
129
|
+
**{room.segment_id: room for room in self._rooms_trait.rooms or ()},
|
|
130
|
+
}
|
|
131
|
+
return CombinedMapInfo(
|
|
132
|
+
map_flag=map_info.map_flag,
|
|
133
|
+
name=map_info.name,
|
|
134
|
+
rooms=list(rooms_map.values()),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def _refresh_map_content(self) -> MapContent:
|
|
138
|
+
"""Refresh the map content trait to get the latest map data."""
|
|
139
|
+
await self._map_content.refresh()
|
|
140
|
+
return MapContent(
|
|
141
|
+
image_content=self._map_content.image_content,
|
|
142
|
+
map_data=self._map_content.map_data,
|
|
143
|
+
raw_api_response=self._map_content.raw_api_response,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
async def _build_home_map_info(self) -> tuple[dict[int, CombinedMapInfo], dict[int, MapContent]]:
|
|
147
|
+
"""Perform the actual discovery and caching of home map info and content."""
|
|
148
|
+
home_map_info: dict[int, CombinedMapInfo] = {}
|
|
149
|
+
home_map_content: dict[int, MapContent] = {}
|
|
150
|
+
|
|
151
|
+
# Sort map_info to process the current map last, reducing map switching.
|
|
152
|
+
# False (non-original maps) sorts before True (original map). We ensure
|
|
153
|
+
# we load the original map last.
|
|
154
|
+
sorted_map_infos = sorted(
|
|
155
|
+
self._maps_trait.map_info or [],
|
|
156
|
+
key=lambda mi: mi.map_flag == self._maps_trait.current_map,
|
|
157
|
+
reverse=False,
|
|
158
|
+
)
|
|
159
|
+
_LOGGER.debug("Building home cache for maps: %s", [mi.map_flag for mi in sorted_map_infos])
|
|
160
|
+
for map_info in sorted_map_infos:
|
|
161
|
+
# We need to load each map to get its room data
|
|
162
|
+
if len(sorted_map_infos) > 1:
|
|
163
|
+
_LOGGER.debug("Loading map %s", map_info.map_flag)
|
|
164
|
+
try:
|
|
165
|
+
await self._maps_trait.set_current_map(map_info.map_flag)
|
|
166
|
+
except RoborockInvalidStatus as ex:
|
|
167
|
+
# Device is in a state that forbids map switching. Translate to
|
|
168
|
+
# "busy" so callers can fall back to refreshing the current map only.
|
|
169
|
+
raise RoborockDeviceBusy("Cannot switch maps right now (device action locked)") from ex
|
|
170
|
+
await asyncio.sleep(MAP_SLEEP)
|
|
171
|
+
|
|
172
|
+
map_content = await self._refresh_map_content()
|
|
173
|
+
home_map_content[map_info.map_flag] = map_content
|
|
174
|
+
|
|
175
|
+
combined_map_info = await self._refresh_map_info(map_info)
|
|
176
|
+
home_map_info[map_info.map_flag] = combined_map_info
|
|
177
|
+
return home_map_info, home_map_content
|
|
178
|
+
|
|
179
|
+
async def refresh(self) -> None:
|
|
180
|
+
"""Refresh current map's underlying map and room data, updating cache as needed.
|
|
181
|
+
|
|
182
|
+
This will only refresh the current map's data and will not populate non
|
|
183
|
+
active maps or re-discover the home. It is expected that this will keep
|
|
184
|
+
information up to date for the current map as users switch to that map.
|
|
185
|
+
"""
|
|
186
|
+
if not self._discovery_completed:
|
|
187
|
+
# Running initial discovery also populates all of the same information
|
|
188
|
+
# as below so we can just call that method. If the device is busy
|
|
189
|
+
# then we'll fall through below to refresh the current map only.
|
|
190
|
+
try:
|
|
191
|
+
await self.discover_home()
|
|
192
|
+
return
|
|
193
|
+
except RoborockDeviceBusy:
|
|
194
|
+
_LOGGER.debug("Cannot refresh home data while device is busy cleaning")
|
|
195
|
+
|
|
196
|
+
# Refresh the list of map names/info
|
|
197
|
+
await self._maps_trait.refresh()
|
|
198
|
+
if (current_map_info := self._maps_trait.current_map_info) is None or (
|
|
199
|
+
map_flag := self._maps_trait.current_map
|
|
200
|
+
) is None:
|
|
201
|
+
raise RoborockException("Cannot refresh home data without current map info")
|
|
202
|
+
|
|
203
|
+
# Refresh the map content to ensure we have the latest image and object positions
|
|
204
|
+
new_map_content = await self._refresh_map_content()
|
|
205
|
+
# Refresh the current map's room data
|
|
206
|
+
combined_map_info = await self._refresh_map_info(current_map_info)
|
|
207
|
+
await self._update_current_map(
|
|
208
|
+
map_flag, combined_map_info, new_map_content, update_cache=self._discovery_completed
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def home_map_info(self) -> dict[int, CombinedMapInfo] | None:
|
|
213
|
+
"""Returns the map information for all cached maps."""
|
|
214
|
+
return self._home_map_info
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def current_map_data(self) -> CombinedMapInfo | None:
|
|
218
|
+
"""Returns the map data for the current map."""
|
|
219
|
+
current_map_flag = self._maps_trait.current_map
|
|
220
|
+
if current_map_flag is None or self._home_map_info is None:
|
|
221
|
+
return None
|
|
222
|
+
return self._home_map_info.get(current_map_flag)
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def current_rooms(self) -> list[NamedRoomMapping]:
|
|
226
|
+
"""Returns the room names for the current map."""
|
|
227
|
+
if self.current_map_data is None:
|
|
228
|
+
return []
|
|
229
|
+
return self.current_map_data.rooms
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def home_map_content(self) -> dict[int, MapContent] | None:
|
|
233
|
+
"""Returns the map content for all cached maps."""
|
|
234
|
+
return self._home_map_content
|
|
235
|
+
|
|
236
|
+
def _parse_response(self, response: common.V1ResponseData) -> Self:
|
|
237
|
+
"""This trait does not parse responses directly."""
|
|
238
|
+
raise NotImplementedError("HomeTrait does not support direct command responses")
|
|
239
|
+
|
|
240
|
+
async def _update_home_cache(
|
|
241
|
+
self, home_map_info: dict[int, CombinedMapInfo], home_map_content: dict[int, MapContent]
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Update the entire home cache with new map info and content."""
|
|
244
|
+
device_cache_data = await self._device_cache.get()
|
|
245
|
+
device_cache_data.home_map_info = home_map_info
|
|
246
|
+
device_cache_data.home_map_content_base64 = {
|
|
247
|
+
k: base64.b64encode(v.raw_api_response).decode("utf-8")
|
|
248
|
+
for k, v in home_map_content.items()
|
|
249
|
+
if v.raw_api_response
|
|
250
|
+
}
|
|
251
|
+
await self._device_cache.set(device_cache_data)
|
|
252
|
+
self._home_map_info = home_map_info
|
|
253
|
+
self._home_map_content = home_map_content
|
|
254
|
+
|
|
255
|
+
async def _update_current_map(
|
|
256
|
+
self,
|
|
257
|
+
map_flag: int,
|
|
258
|
+
map_info: CombinedMapInfo,
|
|
259
|
+
map_content: MapContent,
|
|
260
|
+
update_cache: bool,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Update the cache for the current map only."""
|
|
263
|
+
# Update the persistent cache if requested e.g. home discovery has
|
|
264
|
+
# completed and we want to keep it fresh. Otherwise just update the
|
|
265
|
+
# in memory map below.
|
|
266
|
+
if update_cache:
|
|
267
|
+
device_cache_data = await self._device_cache.get()
|
|
268
|
+
if device_cache_data.home_map_info is None:
|
|
269
|
+
device_cache_data.home_map_info = {}
|
|
270
|
+
device_cache_data.home_map_info[map_flag] = map_info
|
|
271
|
+
if map_content.raw_api_response:
|
|
272
|
+
if device_cache_data.home_map_content_base64 is None:
|
|
273
|
+
device_cache_data.home_map_content_base64 = {}
|
|
274
|
+
device_cache_data.home_map_content_base64[map_flag] = base64.b64encode(
|
|
275
|
+
map_content.raw_api_response
|
|
276
|
+
).decode("utf-8")
|
|
277
|
+
await self._device_cache.set(device_cache_data)
|
|
278
|
+
|
|
279
|
+
if self._home_map_info is None:
|
|
280
|
+
self._home_map_info = {}
|
|
281
|
+
self._home_map_info[map_flag] = map_info
|
|
282
|
+
|
|
283
|
+
if self._home_map_content is None:
|
|
284
|
+
self._home_map_content = {}
|
|
285
|
+
self._home_map_content[map_flag] = map_content
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from roborock_cli._vendor.roborock.data import LedStatus
|
|
2
|
+
from roborock_cli._vendor.roborock.devices.traits.v1 import common
|
|
3
|
+
from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
|
|
4
|
+
|
|
5
|
+
from .common import V1ResponseData
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LedStatusTrait(LedStatus, common.V1TraitMixin, common.RoborockSwitchBase):
|
|
9
|
+
"""Trait for controlling the LED status of a Roborock device."""
|
|
10
|
+
|
|
11
|
+
command = RoborockCommand.GET_LED_STATUS
|
|
12
|
+
requires_feature = "is_led_status_switch_supported"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def is_on(self) -> bool:
|
|
16
|
+
"""Return whether the LED status is enabled."""
|
|
17
|
+
return self.status == 1
|
|
18
|
+
|
|
19
|
+
async def enable(self) -> None:
|
|
20
|
+
"""Enable the LED status."""
|
|
21
|
+
await self.rpc_channel.send_command(RoborockCommand.SET_LED_STATUS, params=[1])
|
|
22
|
+
# Optimistic update to avoid an extra refresh
|
|
23
|
+
self.status = 1
|
|
24
|
+
|
|
25
|
+
async def disable(self) -> None:
|
|
26
|
+
"""Disable the LED status."""
|
|
27
|
+
await self.rpc_channel.send_command(RoborockCommand.SET_LED_STATUS, params=[0])
|
|
28
|
+
# Optimistic update to avoid an extra refresh
|
|
29
|
+
self.status = 0
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def _parse_type_response(cls, response: V1ResponseData) -> LedStatus:
|
|
33
|
+
"""Parse the response from the device into a a RoborockBase.
|
|
34
|
+
|
|
35
|
+
Subclasses should override this method to implement custom parsing
|
|
36
|
+
logic as needed.
|
|
37
|
+
"""
|
|
38
|
+
if not isinstance(response, list):
|
|
39
|
+
raise ValueError(f"Unexpected {cls} response format: {response!r}")
|
|
40
|
+
response = response[0]
|
|
41
|
+
if not isinstance(response, int):
|
|
42
|
+
raise ValueError(f"Unexpected {cls} response format: {response!r}")
|
|
43
|
+
return cls.from_dict({"status": response})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Trait for fetching the map content from Roborock devices."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from vacuum_map_parser_base.map_data import MapData
|
|
7
|
+
|
|
8
|
+
from roborock_cli._vendor.roborock.data import RoborockBase
|
|
9
|
+
from roborock_cli._vendor.roborock.devices.traits.v1 import common
|
|
10
|
+
from roborock_cli._vendor.roborock.map.map_parser import MapParser, MapParserConfig
|
|
11
|
+
from roborock_cli._vendor.roborock.roborock_typing import RoborockCommand
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_TRUNCATE_LENGTH = 20
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class MapContent(RoborockBase):
|
|
20
|
+
"""Dataclass representing map content."""
|
|
21
|
+
|
|
22
|
+
image_content: bytes | None = None
|
|
23
|
+
"""The rendered image of the map in PNG format."""
|
|
24
|
+
|
|
25
|
+
map_data: MapData | None = None
|
|
26
|
+
"""The parsed map data which contains metadata for points on the map."""
|
|
27
|
+
|
|
28
|
+
raw_api_response: bytes | None = None
|
|
29
|
+
"""The raw bytes of the map data from the API for caching for future use.
|
|
30
|
+
|
|
31
|
+
This should be treated as an opaque blob used only internally by this library
|
|
32
|
+
to re-parse the map data when needed.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
"""Return a string representation of the MapContent."""
|
|
37
|
+
img = self.image_content
|
|
38
|
+
if self.image_content and len(self.image_content) > _TRUNCATE_LENGTH:
|
|
39
|
+
img = self.image_content[: _TRUNCATE_LENGTH - 3] + b"..."
|
|
40
|
+
return f"MapContent(image_content={img!r}, map_data={self.map_data!r})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@common.map_rpc_channel
|
|
44
|
+
class MapContentTrait(MapContent, common.V1TraitMixin):
|
|
45
|
+
"""Trait for fetching the map content."""
|
|
46
|
+
|
|
47
|
+
command = RoborockCommand.GET_MAP_V1
|
|
48
|
+
|
|
49
|
+
def __init__(self, map_parser_config: MapParserConfig | None = None) -> None:
|
|
50
|
+
"""Initialize MapContentTrait."""
|
|
51
|
+
super().__init__()
|
|
52
|
+
self._map_parser = MapParser(map_parser_config or MapParserConfig())
|
|
53
|
+
|
|
54
|
+
def _parse_response(self, response: common.V1ResponseData) -> MapContent:
|
|
55
|
+
"""Parse the response from the device into a MapContentTrait instance."""
|
|
56
|
+
if not isinstance(response, bytes):
|
|
57
|
+
raise ValueError(f"Unexpected MapContentTrait response format: {type(response)}")
|
|
58
|
+
return self.parse_map_content(response)
|
|
59
|
+
|
|
60
|
+
def parse_map_content(self, response: bytes) -> MapContent:
|
|
61
|
+
"""Parse the map content from raw bytes.
|
|
62
|
+
|
|
63
|
+
This method is exposed so that cached map data can be parsed without
|
|
64
|
+
needing to go through the RPC channel.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
response: The raw bytes of the map data from the API.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
MapContent: The parsed map content.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
RoborockException: If the map data cannot be parsed.
|
|
74
|
+
"""
|
|
75
|
+
parsed_data = self._map_parser.parse(response)
|
|
76
|
+
if parsed_data is None:
|
|
77
|
+
raise ValueError("Failed to parse map data")
|
|
78
|
+
|
|
79
|
+
return MapContent(
|
|
80
|
+
image_content=parsed_data.image_content,
|
|
81
|
+
map_data=parsed_data.map_data,
|
|
82
|
+
raw_api_response=response,
|
|
83
|
+
)
|