python-roborock 3.3.2__tar.gz → 3.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {python_roborock-3.3.2 → python_roborock-3.7.1}/PKG-INFO +1 -1
  2. {python_roborock-3.3.2 → python_roborock-3.7.1}/pyproject.toml +1 -1
  3. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/cli.py +11 -8
  4. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/cache.py +5 -2
  5. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/device_manager.py +52 -27
  6. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/__init__.py +10 -1
  7. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/clean_summary.py +2 -0
  8. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/command.py +2 -1
  9. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/home.py +76 -29
  10. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/map_content.py +24 -0
  11. python_roborock-3.7.1/roborock/devices/traits/v1/routines.py +26 -0
  12. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/valley_electricity_timer.py +1 -1
  13. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/web_api.py +26 -0
  14. {python_roborock-3.3.2 → python_roborock-3.7.1}/.gitignore +0 -0
  15. {python_roborock-3.3.2 → python_roborock-3.7.1}/LICENSE +0 -0
  16. {python_roborock-3.3.2 → python_roborock-3.7.1}/README.md +0 -0
  17. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/__init__.py +0 -0
  18. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/api.py +0 -0
  19. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/broadcast_protocol.py +0 -0
  20. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/callbacks.py +0 -0
  21. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/cloud_api.py +0 -0
  22. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/command_cache.py +0 -0
  23. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/const.py +0 -0
  24. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/__init__.py +0 -0
  25. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/b01_q10/__init__.py +0 -0
  26. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  27. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  28. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/b01_q7/__init__.py +0 -0
  29. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  30. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  31. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/code_mappings.py +0 -0
  32. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/containers.py +0 -0
  33. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/dyad/__init__.py +0 -0
  34. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  35. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/dyad/dyad_containers.py +0 -0
  36. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/v1/__init__.py +0 -0
  37. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/v1/v1_clean_modes.py +0 -0
  38. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/v1/v1_code_mappings.py +0 -0
  39. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/v1/v1_containers.py +0 -0
  40. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/zeo/__init__.py +0 -0
  41. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  42. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/data/zeo/zeo_containers.py +0 -0
  43. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/device_features.py +0 -0
  44. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/README.md +0 -0
  45. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/__init__.py +0 -0
  46. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/a01_channel.py +0 -0
  47. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/b01_channel.py +0 -0
  48. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/channel.py +0 -0
  49. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/device.py +0 -0
  50. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/local_channel.py +0 -0
  51. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/mqtt_channel.py +0 -0
  52. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/__init__.py +0 -0
  53. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/a01/__init__.py +0 -0
  54. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/b01/__init__.py +0 -0
  55. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/traits_mixin.py +0 -0
  56. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/child_lock.py +0 -0
  57. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/common.py +0 -0
  58. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/consumeable.py +0 -0
  59. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/device_features.py +0 -0
  60. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  61. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  62. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  63. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/led_status.py +0 -0
  64. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/maps.py +0 -0
  65. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/network_info.py +0 -0
  66. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/rooms.py +0 -0
  67. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  68. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/status.py +0 -0
  69. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/volume.py +0 -0
  70. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  71. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/v1_channel.py +0 -0
  72. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/devices/v1_rpc_channel.py +0 -0
  73. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/exceptions.py +0 -0
  74. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/map/__init__.py +0 -0
  75. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/map/map_parser.py +0 -0
  76. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/mqtt/__init__.py +0 -0
  77. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/mqtt/roborock_session.py +0 -0
  78. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/mqtt/session.py +0 -0
  79. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/protocol.py +0 -0
  80. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/protocols/a01_protocol.py +0 -0
  81. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/protocols/b01_protocol.py +0 -0
  82. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/protocols/v1_protocol.py +0 -0
  83. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/py.typed +0 -0
  84. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/roborock_future.py +0 -0
  85. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/roborock_message.py +0 -0
  86. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/roborock_typing.py +0 -0
  87. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/util.py +0 -0
  88. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/version_1_apis/__init__.py +0 -0
  89. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  90. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/version_1_apis/roborock_local_client_v1.py +1 -1
  91. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  92. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/version_a01_apis/__init__.py +0 -0
  93. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  94. {python_roborock-3.3.2 → python_roborock-3.7.1}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 3.3.2
3
+ Version: 3.7.1
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/humbertogontijo/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-roborock"
3
- version = "3.3.2"
3
+ version = "3.7.1"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
6
6
  requires-python = ">=3.11, <4"
@@ -46,7 +46,7 @@ from roborock.data import CombinedMapInfo, DeviceData, HomeData, NetworkInfo, Ro
46
46
  from roborock.device_features import DeviceFeatures
47
47
  from roborock.devices.cache import Cache, CacheData
48
48
  from roborock.devices.device import RoborockDevice
49
- from roborock.devices.device_manager import DeviceManager, create_device_manager, create_home_data_api
49
+ from roborock.devices.device_manager import DeviceManager, UserParams, create_device_manager
50
50
  from roborock.devices.traits import Trait
51
51
  from roborock.devices.traits.v1 import V1TraitMixin
52
52
  from roborock.devices.traits.v1.consumeable import ConsumableAttribute
@@ -118,7 +118,7 @@ class ConnectionCache(RoborockBase):
118
118
  email: str
119
119
  home_data: HomeData | None = None
120
120
  network_info: dict[str, NetworkInfo] | None = None
121
- home_cache: dict[int, CombinedMapInfo] | None = None
121
+ home_map_info: dict[int, CombinedMapInfo] | None = None
122
122
  trait_data: dict[str, Any] | None = None
123
123
 
124
124
 
@@ -135,8 +135,11 @@ class DeviceConnectionManager:
135
135
  """Ensure device manager is initialized."""
136
136
  if self.device_manager is None:
137
137
  cache_data = self.context.cache_data()
138
- home_data_api = create_home_data_api(cache_data.email, cache_data.user_data)
139
- self.device_manager = await create_device_manager(cache_data.user_data, home_data_api, self.context)
138
+ user_params = UserParams(
139
+ username=cache_data.email,
140
+ user_data=cache_data.user_data,
141
+ )
142
+ self.device_manager = await create_device_manager(user_params, cache=self.context)
140
143
  # Cache devices for quick lookup
141
144
  devices = await self.device_manager.get_devices()
142
145
  self._devices = {device.duid: device for device in devices}
@@ -267,7 +270,7 @@ class RoborockContext(Cache):
267
270
  return CacheData(
268
271
  home_data=connection_cache.home_data,
269
272
  network_info=connection_cache.network_info or {},
270
- home_cache=connection_cache.home_cache,
273
+ home_map_info=connection_cache.home_map_info,
271
274
  trait_data=connection_cache.trait_data or {},
272
275
  )
273
276
 
@@ -277,7 +280,7 @@ class RoborockContext(Cache):
277
280
  connection_cache = self.cache_data()
278
281
  connection_cache.home_data = value.home_data
279
282
  connection_cache.network_info = value.network_info
280
- connection_cache.home_cache = value.home_cache
283
+ connection_cache.home_map_info = value.home_map_info
281
284
  connection_cache.trait_data = value.trait_data
282
285
  self.update(connection_cache)
283
286
 
@@ -717,14 +720,14 @@ async def home(ctx, device_id: str, refresh: bool):
717
720
  await home_trait.refresh()
718
721
 
719
722
  # Display the discovered home cache
720
- if home_trait.home_cache:
723
+ if home_trait.home_map_info:
721
724
  cache_summary = {
722
725
  map_flag: {
723
726
  "name": map_data.name,
724
727
  "room_count": len(map_data.rooms),
725
728
  "rooms": [{"segment_id": room.segment_id, "name": room.name} for room in map_data.rooms],
726
729
  }
727
- for map_flag, map_data in home_trait.home_cache.items()
730
+ for map_flag, map_data in home_trait.home_map_info.items()
728
731
  }
729
732
  click.echo(dump_json(cache_summary))
730
733
  else:
@@ -22,8 +22,11 @@ class CacheData:
22
22
  network_info: dict[str, NetworkInfo] = field(default_factory=dict)
23
23
  """Network information indexed by device DUID."""
24
24
 
25
- home_cache: dict[int, CombinedMapInfo] = field(default_factory=dict)
26
- """Home cache information indexed by map_flag."""
25
+ home_map_info: dict[int, CombinedMapInfo] = field(default_factory=dict)
26
+ """Home map information indexed by map_flag."""
27
+
28
+ home_map_content: dict[int, bytes] = field(default_factory=dict)
29
+ """Home cache content for each map data indexed by map_flag."""
27
30
 
28
31
  device_features: DeviceFeatures | None = None
29
32
  """Device features information."""
@@ -3,7 +3,8 @@
3
3
  import asyncio
4
4
  import enum
5
5
  import logging
6
- from collections.abc import Awaitable, Callable
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
7
8
 
8
9
  import aiohttp
9
10
 
@@ -18,7 +19,7 @@ from roborock.map.map_parser import MapParserConfig
18
19
  from roborock.mqtt.roborock_session import create_lazy_mqtt_session
19
20
  from roborock.mqtt.session import MqttSession
20
21
  from roborock.protocol import create_mqtt_params
21
- from roborock.web_api import RoborockApiClient
22
+ from roborock.web_api import RoborockApiClient, UserWebApiClient
22
23
 
23
24
  from .cache import Cache, NoCache
24
25
  from .channel import Channel
@@ -30,12 +31,11 @@ _LOGGER = logging.getLogger(__name__)
30
31
 
31
32
  __all__ = [
32
33
  "create_device_manager",
33
- "create_home_data_api",
34
+ "UserParams",
34
35
  "DeviceManager",
35
36
  ]
36
37
 
37
38
 
38
- HomeDataApi = Callable[[], Awaitable[HomeData]]
39
39
  DeviceCreator = Callable[[HomeData, HomeDataDevice, HomeDataProduct], RoborockDevice]
40
40
 
41
41
 
@@ -53,7 +53,7 @@ class DeviceManager:
53
53
 
54
54
  def __init__(
55
55
  self,
56
- home_data_api: HomeDataApi,
56
+ web_api: UserWebApiClient,
57
57
  device_creator: DeviceCreator,
58
58
  mqtt_session: MqttSession,
59
59
  cache: Cache,
@@ -62,7 +62,7 @@ class DeviceManager:
62
62
 
63
63
  This takes ownership of the MQTT session and will close it when the manager is closed.
64
64
  """
65
- self._home_data_api = home_data_api
65
+ self._web_api = web_api
66
66
  self._cache = cache
67
67
  self._device_creator = device_creator
68
68
  self._devices: dict[str, RoborockDevice] = {}
@@ -73,7 +73,7 @@ class DeviceManager:
73
73
  cache_data = await self._cache.get()
74
74
  if not cache_data.home_data:
75
75
  _LOGGER.debug("No cached home data found, fetching from API")
76
- cache_data.home_data = await self._home_data_api()
76
+ cache_data.home_data = await self._web_api.get_home_data()
77
77
  await self._cache.set(cache_data)
78
78
  home_data = cache_data.home_data
79
79
 
@@ -108,45 +108,69 @@ class DeviceManager:
108
108
  await asyncio.gather(*tasks)
109
109
 
110
110
 
111
- def create_home_data_api(
112
- email: str, user_data: UserData, base_url: str | None = None, session: aiohttp.ClientSession | None = None
113
- ) -> HomeDataApi:
114
- """Create a home data API wrapper.
111
+ @dataclass
112
+ class UserParams:
113
+ """Parameters for creating a new session with Roborock devices.
115
114
 
116
- This function creates a wrapper around the Roborock API client to fetch
117
- home data for the user.
115
+ These parameters include the username, user data for authentication,
116
+ and an optional base URL for the Roborock API. The `user_data` and `base_url`
117
+ parameters are obtained from `RoborockApiClient` during the login process.
118
118
  """
119
- # Note: This will auto discover the API base URL. This can be improved
120
- # by caching this next to `UserData` if needed to avoid unnecessary API calls.
121
- client = RoborockApiClient(username=email, base_url=base_url, session=session)
122
119
 
123
- return create_home_data_from_api_client(client, user_data)
120
+ username: str
121
+ """The username (email) used for logging in."""
122
+
123
+ user_data: UserData
124
+ """This is the user data containing authentication information."""
125
+
126
+ base_url: str | None = None
127
+ """Optional base URL for the Roborock API.
128
+
129
+ This is used to speed up connection times by avoiding the need to
130
+ discover the API base URL each time. If not provided, the API client
131
+ will attempt to discover it automatically which may take multiple requests.
132
+ """
124
133
 
125
134
 
126
- def create_home_data_from_api_client(client: RoborockApiClient, user_data: UserData) -> HomeDataApi:
135
+ def create_web_api_wrapper(
136
+ user_params: UserParams,
137
+ *,
138
+ cache: Cache | None = None,
139
+ session: aiohttp.ClientSession | None = None,
140
+ ) -> UserWebApiClient:
127
141
  """Create a home data API wrapper from an existing API client."""
128
142
 
129
- async def home_data_api() -> HomeData:
130
- return await client.get_home_data_v3(user_data)
143
+ # Note: This will auto discover the API base URL. This can be improved
144
+ # by caching this next to `UserData` if needed to avoid unnecessary API calls.
145
+ client = RoborockApiClient(username=user_params.username, base_url=user_params.base_url, session=session)
131
146
 
132
- return home_data_api
147
+ return UserWebApiClient(client, user_params.user_data)
133
148
 
134
149
 
135
150
  async def create_device_manager(
136
- user_data: UserData,
137
- home_data_api: HomeDataApi,
151
+ user_params: UserParams,
152
+ *,
138
153
  cache: Cache | None = None,
139
154
  map_parser_config: MapParserConfig | None = None,
155
+ session: aiohttp.ClientSession | None = None,
140
156
  ) -> DeviceManager:
141
157
  """Convenience function to create and initialize a DeviceManager.
142
158
 
143
- The Home Data is fetched using the provided home_data_api callable which
144
- is exposed this way to allow for swapping out other implementations to
145
- include caching or other optimizations.
159
+ Args:
160
+ user_params: Parameters for creating the user session.
161
+ cache: Optional cache implementation to use for caching device data.
162
+ map_parser_config: Optional configuration for parsing maps.
163
+ session: Optional aiohttp ClientSession to use for HTTP requests.
164
+
165
+ Returns:
166
+ An initialized DeviceManager with discovered devices.
146
167
  """
147
168
  if cache is None:
148
169
  cache = NoCache()
149
170
 
171
+ web_api = create_web_api_wrapper(user_params, session=session, cache=cache)
172
+ user_data = user_params.user_data
173
+
150
174
  mqtt_params = create_mqtt_params(user_data.rriot)
151
175
  mqtt_session = await create_lazy_mqtt_session(mqtt_params)
152
176
 
@@ -163,6 +187,7 @@ async def create_device_manager(
163
187
  channel.rpc_channel,
164
188
  channel.mqtt_rpc_channel,
165
189
  channel.map_rpc_channel,
190
+ web_api,
166
191
  cache,
167
192
  map_parser_config=map_parser_config,
168
193
  )
@@ -176,6 +201,6 @@ async def create_device_manager(
176
201
  raise NotImplementedError(f"Device {device.name} has unsupported version {device.pv}")
177
202
  return RoborockDevice(device, product, channel, trait)
178
203
 
179
- manager = DeviceManager(home_data_api, device_creator, mqtt_session=mqtt_session, cache=cache)
204
+ manager = DeviceManager(web_api, device_creator, mqtt_session=mqtt_session, cache=cache)
180
205
  await manager.discover_devices()
181
206
  return manager
@@ -40,6 +40,7 @@ from roborock.devices.cache import Cache
40
40
  from roborock.devices.traits import Trait
41
41
  from roborock.devices.v1_rpc_channel import V1RpcChannel
42
42
  from roborock.map.map_parser import MapParserConfig
43
+ from roborock.web_api import UserWebApiClient
43
44
 
44
45
  from .child_lock import ChildLockTrait
45
46
  from .clean_summary import CleanSummaryTrait
@@ -56,6 +57,7 @@ from .map_content import MapContentTrait
56
57
  from .maps import MapsTrait
57
58
  from .network_info import NetworkInfoTrait
58
59
  from .rooms import RoomsTrait
60
+ from .routines import RoutinesTrait
59
61
  from .smart_wash_params import SmartWashParamsTrait
60
62
  from .status import StatusTrait
61
63
  from .valley_electricity_timer import ValleyElectricityTimerTrait
@@ -85,6 +87,7 @@ __all__ = [
85
87
  "WashTowelModeTrait",
86
88
  "SmartWashParamsTrait",
87
89
  "NetworkInfoTrait",
90
+ "RoutinesTrait",
88
91
  ]
89
92
 
90
93
 
@@ -108,6 +111,7 @@ class PropertiesApi(Trait):
108
111
  home: HomeTrait
109
112
  device_features: DeviceFeaturesTrait
110
113
  network_info: NetworkInfoTrait
114
+ routines: RoutinesTrait
111
115
 
112
116
  # Optional features that may not be supported on all devices
113
117
  child_lock: ChildLockTrait | None = None
@@ -126,6 +130,7 @@ class PropertiesApi(Trait):
126
130
  rpc_channel: V1RpcChannel,
127
131
  mqtt_rpc_channel: V1RpcChannel,
128
132
  map_rpc_channel: V1RpcChannel,
133
+ web_api: UserWebApiClient,
129
134
  cache: Cache,
130
135
  map_parser_config: MapParserConfig | None = None,
131
136
  ) -> None:
@@ -134,6 +139,7 @@ class PropertiesApi(Trait):
134
139
  self._rpc_channel = rpc_channel
135
140
  self._mqtt_rpc_channel = mqtt_rpc_channel
136
141
  self._map_rpc_channel = map_rpc_channel
142
+ self._web_api = web_api
137
143
  self._cache = cache
138
144
 
139
145
  self.status = StatusTrait(product)
@@ -141,9 +147,10 @@ class PropertiesApi(Trait):
141
147
  self.rooms = RoomsTrait(home_data)
142
148
  self.maps = MapsTrait(self.status)
143
149
  self.map_content = MapContentTrait(map_parser_config)
144
- self.home = HomeTrait(self.status, self.maps, self.rooms, cache)
150
+ self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, cache)
145
151
  self.device_features = DeviceFeaturesTrait(product.product_nickname, cache)
146
152
  self.network_info = NetworkInfoTrait(device_uid, cache)
153
+ self.routines = RoutinesTrait(device_uid, web_api)
147
154
 
148
155
  # Dynamically create any traits that need to be populated
149
156
  for item in fields(self):
@@ -267,6 +274,7 @@ def create(
267
274
  rpc_channel: V1RpcChannel,
268
275
  mqtt_rpc_channel: V1RpcChannel,
269
276
  map_rpc_channel: V1RpcChannel,
277
+ web_api: UserWebApiClient,
270
278
  cache: Cache,
271
279
  map_parser_config: MapParserConfig | None = None,
272
280
  ) -> PropertiesApi:
@@ -278,6 +286,7 @@ def create(
278
286
  rpc_channel,
279
287
  mqtt_rpc_channel,
280
288
  map_rpc_channel,
289
+ web_api,
281
290
  cache,
282
291
  map_parser_config,
283
292
  )
@@ -53,6 +53,8 @@ class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin):
53
53
  @classmethod
54
54
  def _parse_clean_record_response(cls, response: common.V1ResponseData) -> CleanRecord:
55
55
  """Parse the response from the device into a CleanRecord."""
56
+ if isinstance(response, list) and len(response) == 1:
57
+ response = response[0]
56
58
  if isinstance(response, dict):
57
59
  return CleanRecord.from_dict(response)
58
60
  if isinstance(response, list):
@@ -1,6 +1,7 @@
1
1
  from typing import Any
2
2
 
3
3
  from roborock import RoborockCommand
4
+ from roborock.protocols.v1_protocol import ParamsType
4
5
 
5
6
 
6
7
  class CommandTrait:
@@ -14,7 +15,7 @@ class CommandTrait:
14
15
  """
15
16
  self._rpc_channel = None
16
17
 
17
- async def send(self, command: RoborockCommand | str, params: dict[str, Any] | None = None) -> Any:
18
+ async def send(self, command: RoborockCommand | str, params: ParamsType = None) -> Any:
18
19
  """Send a command to the device."""
19
20
  if not self._rpc_channel:
20
21
  raise ValueError("Device trait in invalid state")
@@ -20,6 +20,7 @@ from roborock.devices.traits.v1 import common
20
20
  from roborock.exceptions import RoborockDeviceBusy, RoborockException
21
21
  from roborock.roborock_typing import RoborockCommand
22
22
 
23
+ from .map_content import MapContent, MapContentTrait
23
24
  from .maps import MapsTrait
24
25
  from .rooms import RoomsTrait
25
26
  from .status import StatusTrait
@@ -38,6 +39,7 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
38
39
  self,
39
40
  status_trait: StatusTrait,
40
41
  maps_trait: MapsTrait,
42
+ map_content: MapContentTrait,
41
43
  rooms_trait: RoomsTrait,
42
44
  cache: Cache,
43
45
  ) -> None:
@@ -59,9 +61,11 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
59
61
  super().__init__()
60
62
  self._status_trait = status_trait
61
63
  self._maps_trait = maps_trait
64
+ self._map_content = map_content
62
65
  self._rooms_trait = rooms_trait
63
66
  self._cache = cache
64
- self._home_cache: dict[int, CombinedMapInfo] | None = None
67
+ self._home_map_info: dict[int, CombinedMapInfo] | None = None
68
+ self._home_map_content: dict[int, MapContent] | None = None
65
69
 
66
70
  async def discover_home(self) -> None:
67
71
  """Iterate through all maps to discover rooms and cache them.
@@ -72,13 +76,21 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
72
76
  cleaning process. This will raise `RoborockDeviceBusy` if the device is
73
77
  currently cleaning.
74
78
 
75
- After discovery, the home cache will be populated and can be accessed via the `home_cache` property.
79
+ After discovery, the home cache will be populated and can be accessed via the `home_map_info` property.
76
80
  """
77
81
  cache_data = await self._cache.get()
78
- if cache_data.home_cache:
82
+ if cache_data.home_map_info and cache_data.home_map_content:
79
83
  _LOGGER.debug("Home cache already populated, skipping discovery")
80
- self._home_cache = cache_data.home_cache
81
- return
84
+ self._home_map_info = cache_data.home_map_info
85
+ try:
86
+ self._home_map_content = {
87
+ k: self._map_content.parse_map_content(v) for k, v in cache_data.home_map_content.items()
88
+ }
89
+ except (ValueError, RoborockException) as ex:
90
+ _LOGGER.warning("Failed to parse cached home map content, will re-discover: %s", ex)
91
+ self._home_map_content = {}
92
+ else:
93
+ return
82
94
 
83
95
  if self._status_trait.state == RoborockStateCode.cleaning:
84
96
  raise RoborockDeviceBusy("Cannot perform home discovery while the device is cleaning")
@@ -87,11 +99,11 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
87
99
  if self._maps_trait.current_map_info is None:
88
100
  raise RoborockException("Cannot perform home discovery without current map info")
89
101
 
90
- home_cache = await self._build_home_cache()
91
- _LOGGER.debug("Home discovery complete, caching data for %d maps", len(home_cache))
92
- await self._update_home_cache(home_cache)
102
+ home_map_info, home_map_content = await self._build_home_map_info()
103
+ _LOGGER.debug("Home discovery complete, caching data for %d maps", len(home_map_info))
104
+ await self._update_home_cache(home_map_info, home_map_content)
93
105
 
94
- async def _refresh_map_data(self, map_info) -> CombinedMapInfo:
106
+ async def _refresh_map_info(self, map_info) -> CombinedMapInfo:
95
107
  """Collect room data for a specific map and return CombinedMapInfo."""
96
108
  await self._rooms_trait.refresh()
97
109
  return CombinedMapInfo(
@@ -100,9 +112,19 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
100
112
  rooms=self._rooms_trait.rooms or [],
101
113
  )
102
114
 
103
- async def _build_home_cache(self) -> dict[int, CombinedMapInfo]:
104
- """Perform the actual discovery and caching of home data."""
105
- home_cache: dict[int, CombinedMapInfo] = {}
115
+ async def _refresh_map_content(self) -> MapContent:
116
+ """Refresh the map content trait to get the latest map data."""
117
+ await self._map_content.refresh()
118
+ return MapContent(
119
+ image_content=self._map_content.image_content,
120
+ map_data=self._map_content.map_data,
121
+ raw_api_response=self._map_content.raw_api_response,
122
+ )
123
+
124
+ async def _build_home_map_info(self) -> tuple[dict[int, CombinedMapInfo], dict[int, MapContent]]:
125
+ """Perform the actual discovery and caching of home map info and content."""
126
+ home_map_info: dict[int, CombinedMapInfo] = {}
127
+ home_map_content: dict[int, MapContent] = {}
106
128
 
107
129
  # Sort map_info to process the current map last, reducing map switching.
108
130
  # False (non-original maps) sorts before True (original map). We ensure
@@ -120,9 +142,12 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
120
142
  await self._maps_trait.set_current_map(map_info.map_flag)
121
143
  await asyncio.sleep(MAP_SLEEP)
122
144
 
123
- map_data = await self._refresh_map_data(map_info)
124
- home_cache[map_info.map_flag] = map_data
125
- return home_cache
145
+ map_content = await self._refresh_map_content()
146
+ home_map_content[map_info.map_flag] = map_content
147
+
148
+ combined_map_info = await self._refresh_map_info(map_info)
149
+ home_map_info[map_info.map_flag] = combined_map_info
150
+ return home_map_info, home_map_content
126
151
 
127
152
  async def refresh(self) -> Self:
128
153
  """Refresh current map's underlying map and room data, updating cache as needed.
@@ -131,7 +156,7 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
131
156
  active maps or re-discover the home. It is expected that this will keep
132
157
  information up to date for the current map as users switch to that map.
133
158
  """
134
- if self._home_cache is None:
159
+ if self._home_map_info is None:
135
160
  raise RoborockException("Cannot refresh home data without home cache, did you call discover_home()?")
136
161
 
137
162
  # Refresh the list of map names/info
@@ -141,34 +166,56 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
141
166
  ) is None:
142
167
  raise RoborockException("Cannot refresh home data without current map info")
143
168
 
169
+ # Refresh the map content to ensure we have the latest image and object positions
170
+ new_map_content = await self._refresh_map_content()
144
171
  # Refresh the current map's room data
145
- current_map_data = self._home_cache.get(map_flag)
146
- if current_map_data:
147
- map_data = await self._refresh_map_data(current_map_info)
148
- if map_data != current_map_data:
149
- await self._update_home_cache({**self._home_cache, map_flag: map_data})
150
-
172
+ combined_map_info = await self._refresh_map_info(current_map_info)
173
+ await self._update_current_map_cache(map_flag, combined_map_info, new_map_content)
151
174
  return self
152
175
 
153
176
  @property
154
- def home_cache(self) -> dict[int, CombinedMapInfo] | None:
177
+ def home_map_info(self) -> dict[int, CombinedMapInfo] | None:
155
178
  """Returns the map information for all cached maps."""
156
- return self._home_cache
179
+ return self._home_map_info
157
180
 
158
181
  @property
159
182
  def current_map_data(self) -> CombinedMapInfo | None:
160
183
  """Returns the map data for the current map."""
161
184
  current_map_flag = self._maps_trait.current_map
162
- if current_map_flag is None or self._home_cache is None:
185
+ if current_map_flag is None or self._home_map_info is None:
163
186
  return None
164
- return self._home_cache.get(current_map_flag)
187
+ return self._home_map_info.get(current_map_flag)
188
+
189
+ @property
190
+ def home_map_content(self) -> dict[int, MapContent] | None:
191
+ """Returns the map content for all cached maps."""
192
+ return self._home_map_content
165
193
 
166
194
  def _parse_response(self, response: common.V1ResponseData) -> Self:
167
195
  """This trait does not parse responses directly."""
168
196
  raise NotImplementedError("HomeTrait does not support direct command responses")
169
197
 
170
- async def _update_home_cache(self, home_cache: dict[int, CombinedMapInfo]) -> None:
198
+ async def _update_home_cache(
199
+ self, home_map_info: dict[int, CombinedMapInfo], home_map_content: dict[int, MapContent]
200
+ ) -> None:
201
+ """Update the entire home cache with new map info and content."""
202
+ cache_data = await self._cache.get()
203
+ cache_data.home_map_info = home_map_info
204
+ cache_data.home_map_content = {k: v.raw_api_response for k, v in home_map_content.items() if v.raw_api_response}
205
+ await self._cache.set(cache_data)
206
+ self._home_map_info = home_map_info
207
+ self._home_map_content = home_map_content
208
+
209
+ async def _update_current_map_cache(
210
+ self, map_flag: int, map_info: CombinedMapInfo, map_content: MapContent
211
+ ) -> None:
212
+ """Update the cache for the current map only."""
171
213
  cache_data = await self._cache.get()
172
- cache_data.home_cache = home_cache
214
+ cache_data.home_map_info[map_flag] = map_info
215
+ if map_content.raw_api_response:
216
+ cache_data.home_map_content[map_flag] = map_content.raw_api_response
173
217
  await self._cache.set(cache_data)
174
- self._home_cache = home_cache
218
+ if self._home_map_info is None or self._home_map_content is None:
219
+ raise RoborockException("Home cache is not initialized, cannot update current map cache")
220
+ self._home_map_info[map_flag] = map_info
221
+ self._home_map_content[map_flag] = map_content
@@ -25,6 +25,13 @@ class MapContent(RoborockBase):
25
25
  map_data: MapData | None = None
26
26
  """The parsed map data which contains metadata for points on the map."""
27
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
+
28
35
  def __repr__(self) -> str:
29
36
  """Return a string representation of the MapContent."""
30
37
  img = self.image_content
@@ -48,7 +55,23 @@ class MapContentTrait(MapContent, common.V1TraitMixin):
48
55
  """Parse the response from the device into a MapContentTrait instance."""
49
56
  if not isinstance(response, bytes):
50
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.
51
71
 
72
+ Raises:
73
+ RoborockException: If the map data cannot be parsed.
74
+ """
52
75
  parsed_data = self._map_parser.parse(response)
53
76
  if parsed_data is None:
54
77
  raise ValueError("Failed to parse map data")
@@ -56,4 +79,5 @@ class MapContentTrait(MapContent, common.V1TraitMixin):
56
79
  return MapContent(
57
80
  image_content=parsed_data.image_content,
58
81
  map_data=parsed_data.map_data,
82
+ raw_api_response=response,
59
83
  )
@@ -0,0 +1,26 @@
1
+ """Routines trait for V1 devices."""
2
+
3
+ from roborock.data.containers import HomeDataScene
4
+ from roborock.web_api import UserWebApiClient
5
+
6
+
7
+ class RoutinesTrait:
8
+ """Trait for interacting with routines."""
9
+
10
+ def __init__(self, device_id: str, web_api: UserWebApiClient) -> None:
11
+ """Initialize the routines trait."""
12
+ self._device_id = device_id
13
+ self._web_api = web_api
14
+
15
+ async def get_routines(self) -> list[HomeDataScene]:
16
+ """Get available routines."""
17
+ return await self._web_api.get_routines(self._device_id)
18
+
19
+ async def execute_routine(self, routine_id: int) -> None:
20
+ """Execute a routine by its ID.
21
+
22
+ Technically, routines are per-device, but the API does not
23
+ require the device ID to execute them. This can execute a
24
+ routine for any device but it is exposed here for convenience.
25
+ """
26
+ await self._web_api.execute_routine(routine_id)
@@ -5,7 +5,7 @@ from roborock.roborock_typing import RoborockCommand
5
5
  _ENABLED_PARAM = "enabled"
6
6
 
7
7
 
8
- class ValleyElectricityTimerTrait(ValleyElectricityTimer, common.V1TraitMixin):
8
+ class ValleyElectricityTimerTrait(ValleyElectricityTimer, common.V1TraitMixin, common.RoborockSwitchBase):
9
9
  """Trait for managing Valley Electricity Timer settings on Roborock devices."""
10
10
 
11
11
  command = RoborockCommand.GET_VALLEY_ELECTRICITY_TIMER
@@ -707,3 +707,29 @@ def _get_hawk_authentication(rriot: RRiot, url: str, formdata: dict | None = Non
707
707
  )
708
708
  mac = base64.b64encode(hmac.new(rriot.h.encode(), prestr.encode(), hashlib.sha256).digest()).decode()
709
709
  return f'Hawk id="{rriot.u}",s="{rriot.s}",ts="{timestamp}",nonce="{nonce}",mac="{mac}"'
710
+
711
+
712
+ class UserWebApiClient:
713
+ """Wrapper around RoborockApiClient to provide information for a specific user.
714
+
715
+ This binds a RoborockApiClient to a specific user context with the
716
+ provided UserData. This allows for easier access to user-specific data,
717
+ to avoid needing to pass UserData around and mock out the web API.
718
+ """
719
+
720
+ def __init__(self, web_api: RoborockApiClient, user_data: UserData) -> None:
721
+ """Initialize the wrapper with the API client and user data."""
722
+ self._web_api = web_api
723
+ self._user_data = user_data
724
+
725
+ async def get_home_data(self) -> HomeData:
726
+ """Fetch home data using the API client."""
727
+ return await self._web_api.get_home_data_v3(self._user_data)
728
+
729
+ async def get_routines(self, device_id: str) -> list[HomeDataScene]:
730
+ """Fetch routines (scenes) for a specific device."""
731
+ return await self._web_api.get_scenes(self._user_data, device_id)
732
+
733
+ async def execute_routine(self, scene_id: int) -> None:
734
+ """Execute a specific routine (scene) by its ID."""
735
+ await self._web_api.execute_scene(self._user_data, scene_id)
File without changes
@@ -210,6 +210,7 @@ class RoborockLocalClientV1(RoborockClientV1, RoborockClient):
210
210
  version=self.local_protocol_version,
211
211
  )
212
212
  self._logger.debug("Building message id %s for method %s", request_message.request_id, method)
213
+ await self._validate_connection()
213
214
  return await self._send_message(
214
215
  roborock_message,
215
216
  request_id=request_message.request_id,
@@ -226,7 +227,6 @@ class RoborockLocalClientV1(RoborockClientV1, RoborockClient):
226
227
  method: str | None = None,
227
228
  params: list | dict | int | None = None,
228
229
  ) -> RoborockMessage:
229
- await self._validate_connection()
230
230
  msg = self._encoder(roborock_message)
231
231
  if method:
232
232
  self._logger.debug(f"id={request_id} Requesting method {method} with {params}")