python-roborock 4.18.0__tar.gz → 4.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. {python_roborock-4.18.0 → python_roborock-4.19.0}/PKG-INFO +1 -1
  2. {python_roborock-4.18.0 → python_roborock-4.19.0}/pyproject.toml +1 -1
  3. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/__init__.py +1 -1
  4. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/home.py +7 -6
  5. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/rooms.py +46 -14
  6. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/mqtt/roborock_session.py +17 -4
  7. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/web_api.py +6 -2
  8. {python_roborock-4.18.0 → python_roborock-4.19.0}/.gitignore +0 -0
  9. {python_roborock-4.18.0 → python_roborock-4.19.0}/LICENSE +0 -0
  10. {python_roborock-4.18.0 → python_roborock-4.19.0}/README.md +0 -0
  11. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/__init__.py +0 -0
  12. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/broadcast_protocol.py +0 -0
  13. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/callbacks.py +0 -0
  14. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/cli.py +0 -0
  15. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/const.py +0 -0
  16. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/__init__.py +0 -0
  17. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/b01_q10/__init__.py +0 -0
  18. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  19. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  20. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/b01_q7/__init__.py +0 -0
  21. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  22. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  23. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/code_mappings.py +0 -0
  24. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/containers.py +0 -0
  25. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/dyad/__init__.py +0 -0
  26. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  27. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/dyad/dyad_containers.py +0 -0
  28. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/v1/__init__.py +0 -0
  29. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  30. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  31. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/v1/v1_containers.py +0 -0
  32. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/zeo/__init__.py +0 -0
  33. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  34. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/data/zeo/zeo_containers.py +0 -0
  35. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/device_features.py +0 -0
  36. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/README.md +0 -0
  37. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/__init__.py +0 -0
  38. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/cache.py +0 -0
  39. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/device.py +0 -0
  40. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/device_manager.py +0 -0
  41. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/file_cache.py +0 -0
  42. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/rpc/__init__.py +0 -0
  43. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/rpc/a01_channel.py +0 -0
  44. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  45. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  46. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/rpc/v1_channel.py +0 -0
  47. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/__init__.py +0 -0
  48. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/a01/__init__.py +0 -0
  49. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/b01/__init__.py +0 -0
  50. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  51. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  52. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  53. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  54. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  55. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  56. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  57. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/traits_mixin.py +0 -0
  58. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  59. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  60. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/command.py +0 -0
  61. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/common.py +0 -0
  62. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  63. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/device_features.py +0 -0
  64. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  65. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  66. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  67. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/led_status.py +0 -0
  68. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/map_content.py +0 -0
  69. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/maps.py +0 -0
  70. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/network_info.py +0 -0
  71. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/routines.py +0 -0
  72. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  73. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/status.py +0 -0
  74. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  75. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/volume.py +0 -0
  76. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  77. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/transport/__init__.py +0 -0
  78. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/transport/channel.py +0 -0
  79. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/transport/local_channel.py +0 -0
  80. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  81. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/diagnostics.py +0 -0
  82. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/exceptions.py +0 -0
  83. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/map/__init__.py +0 -0
  84. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/map/map_parser.py +0 -0
  85. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/mqtt/__init__.py +0 -0
  86. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/mqtt/health_manager.py +0 -0
  87. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/mqtt/session.py +0 -0
  88. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/protocol.py +0 -0
  89. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/protocols/__init__.py +0 -0
  90. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/protocols/a01_protocol.py +0 -0
  91. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  92. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  93. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/protocols/v1_protocol.py +0 -0
  94. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/py.typed +0 -0
  95. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/roborock_message.py +0 -0
  96. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/roborock_typing.py +0 -0
  97. {python_roborock-4.18.0 → python_roborock-4.19.0}/roborock/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 4.18.0
3
+ Version: 4.19.0
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 = "4.18.0"
3
+ version = "4.19.0"
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"
@@ -193,7 +193,7 @@ class PropertiesApi(Trait):
193
193
  self.device_features = DeviceFeaturesTrait(product, self._device_cache)
194
194
  self.status = StatusTrait(self.device_features, region=self._region)
195
195
  self.consumables = ConsumableTrait()
196
- self.rooms = RoomsTrait(home_data)
196
+ self.rooms = RoomsTrait(home_data, web_api)
197
197
  self.maps = MapsTrait(self.status)
198
198
  self.map_content = MapContentTrait(map_parser_config)
199
199
  self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache)
@@ -120,22 +120,23 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
120
120
 
121
121
  rooms: dict[int, NamedRoomMapping] = {}
122
122
  if map_info.rooms:
123
- # Not all vacuums resopnd with rooms inside map_info.
123
+ # Not all vacuums respond with rooms inside map_info.
124
+ # If we can determine if all vacuums will return everything with get_rooms, we could remove this step.
124
125
  for room in map_info.rooms:
125
126
  if room.id is not None and room.iot_name_id is not None:
126
127
  rooms[room.id] = NamedRoomMapping(
127
128
  segment_id=room.id,
128
129
  iot_id=room.iot_name_id,
129
- name=room.iot_name or "Unknown",
130
+ name=room.iot_name or f"Room {room.id}",
130
131
  )
131
132
 
132
- # Add rooms from rooms_trait. If room already exists and rooms_trait has "Unknown", don't override.
133
+ # Add rooms from rooms_trait.
134
+ # Keep existing names from map_info unless they are fallback names.
133
135
  if self._rooms_trait.rooms:
134
136
  for room in self._rooms_trait.rooms:
135
137
  if room.segment_id is not None and room.name:
136
- if room.segment_id not in rooms or room.name != "Unknown":
137
- # Add the room to rooms if the room segment is not already in it
138
- # or if the room name isn't unknown.
138
+ existing_room = rooms.get(room.segment_id)
139
+ if existing_room is None or existing_room.name == f"Room {room.segment_id}":
139
140
  rooms[room.segment_id] = room
140
141
 
141
142
  return CombinedMapInfo(
@@ -6,11 +6,10 @@ from dataclasses import dataclass
6
6
  from roborock.data import HomeData, NamedRoomMapping, RoborockBase
7
7
  from roborock.devices.traits.v1 import common
8
8
  from roborock.roborock_typing import RoborockCommand
9
+ from roborock.web_api import UserWebApiClient
9
10
 
10
11
  _LOGGER = logging.getLogger(__name__)
11
12
 
12
- _DEFAULT_NAME = "Unknown"
13
-
14
13
 
15
14
  @dataclass
16
15
  class Rooms(RoborockBase):
@@ -32,36 +31,69 @@ class RoomsTrait(Rooms, common.V1TraitMixin):
32
31
 
33
32
  command = RoborockCommand.GET_ROOM_MAPPING
34
33
 
35
- def __init__(self, home_data: HomeData) -> None:
34
+ def __init__(self, home_data: HomeData, web_api: UserWebApiClient) -> None:
36
35
  """Initialize the RoomsTrait."""
37
36
  super().__init__()
38
37
  self._home_data = home_data
38
+ self._web_api = web_api
39
+ self._seen_unknown_room_iot_ids: set[str] = set()
40
+
41
+ async def refresh(self) -> None:
42
+ """Refresh room mappings and backfill unknown room names from the web API."""
43
+ response = await self.rpc_channel.send_command(self.command)
44
+ if not isinstance(response, list):
45
+ raise ValueError(f"Unexpected RoomsTrait response format: {response!r}")
46
+
47
+ segment_map = _extract_segment_map(response)
48
+ await self._populate_missing_home_data_rooms(segment_map)
49
+
50
+ new_data = self._parse_response(response, segment_map)
51
+ self._update_trait_values(new_data)
52
+ _LOGGER.debug("Refreshed %s: %s", self.__class__.__name__, new_data)
39
53
 
40
54
  @property
41
55
  def _iot_id_room_name_map(self) -> dict[str, str]:
42
56
  """Returns a dictionary of Room IOT IDs to room names."""
43
57
  return {str(room.id): room.name for room in self._home_data.rooms or ()}
44
58
 
45
- def _parse_response(self, response: common.V1ResponseData) -> Rooms:
59
+ def _parse_response(self, response: common.V1ResponseData, segment_map: dict[int, str] | None = None) -> Rooms:
46
60
  """Parse the response from the device into a list of NamedRoomMapping."""
47
61
  if not isinstance(response, list):
48
62
  raise ValueError(f"Unexpected RoomsTrait response format: {response!r}")
63
+ if segment_map is None:
64
+ segment_map = _extract_segment_map(response)
49
65
  name_map = self._iot_id_room_name_map
50
- segment_pairs = _extract_segment_pairs(response)
51
66
  return Rooms(
52
67
  rooms=[
53
- NamedRoomMapping(segment_id=segment_id, iot_id=iot_id, name=name_map.get(iot_id, _DEFAULT_NAME))
54
- for segment_id, iot_id in segment_pairs
68
+ NamedRoomMapping(segment_id=segment_id, iot_id=iot_id, name=name_map.get(iot_id, f"Room {segment_id}"))
69
+ for segment_id, iot_id in segment_map.items()
55
70
  ]
56
71
  )
57
72
 
73
+ async def _populate_missing_home_data_rooms(self, segment_map: dict[int, str]) -> None:
74
+ """Load missing room names into home data for newly-seen unknown room ids."""
75
+ missing_room_iot_ids = set(segment_map.values()) - set(self._iot_id_room_name_map.keys())
76
+ new_missing_room_iot_ids = missing_room_iot_ids - self._seen_unknown_room_iot_ids
77
+ if not new_missing_room_iot_ids:
78
+ return
79
+
80
+ try:
81
+ web_rooms = await self._web_api.get_rooms()
82
+ except Exception:
83
+ _LOGGER.debug("Failed to fetch rooms from web API", exc_info=True)
84
+ else:
85
+ if isinstance(web_rooms, list) and web_rooms:
86
+ self._home_data.rooms = web_rooms
87
+
88
+ self._seen_unknown_room_iot_ids.update(missing_room_iot_ids)
89
+
58
90
 
59
- def _extract_segment_pairs(response: list) -> list[tuple[int, str]]:
60
- """Extract segment_id and iot_id pairs from the response.
91
+ def _extract_segment_map(response: list) -> dict[int, str]:
92
+ """Extract a segment_id -> iot_id mapping from the response.
61
93
 
62
94
  The response format can be either a flat list of [segment_id, iot_id] or a
63
95
  list of lists, where each inner list is a pair of [segment_id, iot_id]. This
64
- function normalizes the response into a list of (segment_id, iot_id) tuples
96
+ function normalizes the response into a dict of segment_id to iot_id.
65
97
 
66
98
  NOTE: We currently only partial samples of the room mapping formats, so
67
99
  improving test coverage with samples from a real device with this format
@@ -69,13 +101,13 @@ def _extract_segment_pairs(response: list) -> list[tuple[int, str]]:
69
101
  """
70
102
  if len(response) == 2 and not isinstance(response[0], list):
71
103
  segment_id, iot_id = response[0], response[1]
72
- return [(segment_id, iot_id)]
104
+ return {segment_id: str(iot_id)}
73
105
 
74
- segment_pairs: list[tuple[int, str]] = []
106
+ segment_map: dict[int, str] = {}
75
107
  for part in response:
76
108
  if not isinstance(part, list) or len(part) < 2:
77
109
  _LOGGER.warning("Unexpected room mapping entry format: %r", part)
78
110
  continue
79
111
  segment_id, iot_id = part[0], part[1]
80
- segment_pairs.append((segment_id, iot_id))
81
- return segment_pairs
112
+ segment_map[segment_id] = str(iot_id)
113
+ return segment_map
@@ -27,7 +27,7 @@ from .session import MqttParams, MqttSession, MqttSessionException, MqttSessionU
27
27
  _LOGGER = logging.getLogger(__name__)
28
28
  _MQTT_LOGGER = logging.getLogger(f"{__name__}.aiomqtt")
29
29
 
30
- CLIENT_KEEPALIVE = datetime.timedelta(seconds=60)
30
+ CLIENT_KEEPALIVE = datetime.timedelta(seconds=45)
31
31
  TOPIC_KEEPALIVE = datetime.timedelta(seconds=60)
32
32
 
33
33
  # Exponential backoff parameters
@@ -56,9 +56,12 @@ class RoborockMqttSession(MqttSession):
56
56
  The client is run as a background task that will run until shutdown. Once
57
57
  connected, the client will wait for messages to be received in a loop. If
58
58
  the connection is lost, the client will be re-created and reconnected. There
59
- is backoff to avoid spamming the broker with connection attempts. The client
60
- will automatically re-establish any subscriptions when the connection is
61
- re-established.
59
+ is backoff to avoid spamming the broker with connection attempts.
60
+
61
+ Reconnect attempts are deferred while there are no active subscriptions,
62
+ which avoids unnecessary reconnect churn for idle sessions. Reconnects
63
+ resume as soon as a subscription is added again. The client automatically
64
+ re-establishes any existing subscriptions when the connection returns.
62
65
  """
63
66
 
64
67
  def __init__(
@@ -175,6 +178,16 @@ class RoborockMqttSession(MqttSession):
175
178
  if self._stop:
176
179
  _LOGGER.debug("MQTT session closed, stopping retry loop")
177
180
  return
181
+ if not self._client_subscribed_topics and not self._listeners.keys():
182
+ _LOGGER.debug("MQTT session disconnected with no active subscriptions, deferring reconnect")
183
+ self._diagnostics.increment("reconnect_deferred")
184
+ while not self._stop and not self._client_subscribed_topics and not self._listeners.keys():
185
+ await asyncio.sleep(0.1)
186
+ if self._stop:
187
+ _LOGGER.debug("MQTT session closed while waiting for active subscriptions")
188
+ return
189
+ self._backoff = MIN_BACKOFF_INTERVAL
190
+ continue
178
191
  _LOGGER.info("MQTT session disconnected, retrying in %s seconds", self._backoff.total_seconds())
179
192
  self._diagnostics.increment("reconnect_wait")
180
193
  await asyncio.sleep(self._backoff.total_seconds())
@@ -544,10 +544,10 @@ class RoborockApiClient:
544
544
  rriot.r.a,
545
545
  self.session,
546
546
  {
547
- "Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)),
547
+ "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{home_id}/rooms"),
548
548
  },
549
549
  )
550
- room_response = await room_request.request("get", f"/user/homes/{str(home_id)}/rooms" + str(home_id))
550
+ room_response = await room_request.request("get", f"/user/homes/{home_id}/rooms")
551
551
  if not room_response.get("success"):
552
552
  raise RoborockException(room_response)
553
553
  rooms = room_response.get("result")
@@ -752,6 +752,10 @@ class UserWebApiClient:
752
752
  """Fetch routines (scenes) for a specific device."""
753
753
  return await self._web_api.get_scenes(self._user_data, device_id)
754
754
 
755
+ async def get_rooms(self) -> list[HomeDataRoom]:
756
+ """Fetch rooms using the API client."""
757
+ return await self._web_api.get_rooms(self._user_data)
758
+
755
759
  async def execute_routine(self, scene_id: int) -> None:
756
760
  """Execute a specific routine (scene) by its ID."""
757
761
  await self._web_api.execute_scene(self._user_data, scene_id)