python-roborock 7.2.3__tar.gz → 7.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_roborock-7.2.3 → python_roborock-7.4.0}/PKG-INFO +1 -1
- {python_roborock-7.2.3 → python_roborock-7.4.0}/pyproject.toml +1 -1
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/containers.py +18 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/device.py +4 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/device_manager.py +1 -1
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/a01/__init__.py +168 -54
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/web_api.py +68 -1
- {python_roborock-7.2.3 → python_roborock-7.4.0}/.gitignore +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/LICENSE +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/README.md +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/broadcast_protocol.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/callbacks.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/cli.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/const.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/b01_q10/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/b01_q7/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/code_mappings.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/dyad/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/dyad/dyad_containers.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/mower/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/mower/mower_code_mappings.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/mower/mower_containers.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/v1/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/v1/v1_clean_modes.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/v1/v1_code_mappings.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/v1/v1_containers.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/zeo/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/zeo/zeo_containers.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/device_features.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/README.md +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/cache.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/file_cache.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/rpc/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/rpc/a01_channel.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/rpc/v1_channel.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/a01/device_feature.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/button_light.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/command.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/common.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/consumable.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/map.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/maps.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/network_info.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/status.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/volume.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q7/map.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/common.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/traits_mixin.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/child_lock.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/command.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/common.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/consumeable.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/device_features.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/home.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/led_status.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/map_content.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/maps.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/network_info.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/obstacle_photos.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/rooms.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/routines.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/status.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/volume.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/transport/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/transport/channel.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/transport/local_channel.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/transport/mqtt_channel.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/diagnostics.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/exceptions.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/map/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/map/b01_grid_layers.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/map/b01_map_parser.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/map/b01_q10_map_parser.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/map/b01_q10_overlays.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/map/b01_q10_render.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/map/map_parser.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/map/proto/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/map/proto/b01_scmap.proto +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/map/room_colors.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/mqtt/health_manager.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/protocol.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/protocols/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/protocols/b01_q10_protocol.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/protocols/b01_q7_protocol.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/protocols/v1_protocol.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/py.typed +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/roborock_message.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/testing/__init__.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/testing/a01_simulator.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/testing/b01_q10_simulator.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/testing/channel.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/testing/cloud.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/testing/simulator.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/testing/v1_simulator.py +0 -0
- {python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/util.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-roborock
|
|
3
|
-
Version: 7.
|
|
3
|
+
Version: 7.4.0
|
|
4
4
|
Summary: A package to control Roborock vacuums.
|
|
5
5
|
Project-URL: Repository, https://github.com/python-roborock/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 = "7.
|
|
3
|
+
version = "7.4.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"
|
|
@@ -348,6 +348,24 @@ class HomeDataScene(RoborockBase):
|
|
|
348
348
|
name: str
|
|
349
349
|
|
|
350
350
|
|
|
351
|
+
@dataclass
|
|
352
|
+
class FirmwareInfo(RoborockBase):
|
|
353
|
+
"""Firmware/OTA info from the cloud (`ota/firmware/{duid}/updatev2`)."""
|
|
354
|
+
|
|
355
|
+
version: str | None = None
|
|
356
|
+
"""Latest available firmware version."""
|
|
357
|
+
current_version: str | None = None
|
|
358
|
+
"""Currently installed firmware version."""
|
|
359
|
+
updatable: bool | None = None
|
|
360
|
+
"""Whether a newer firmware is available to install."""
|
|
361
|
+
desc: str | None = None
|
|
362
|
+
"""Release notes / description."""
|
|
363
|
+
release_time: str | None = None
|
|
364
|
+
"""Release date of the available firmware as an ISO-8601 date (``YYYY-MM-DD``)."""
|
|
365
|
+
force_update: bool | None = None
|
|
366
|
+
"""Whether the update is mandatory (cannot be skipped)."""
|
|
367
|
+
|
|
368
|
+
|
|
351
369
|
@dataclass
|
|
352
370
|
class HomeDataSchedule(RoborockBase):
|
|
353
371
|
id: int
|
|
@@ -206,6 +206,8 @@ class RoborockDevice(ABC, TraitsMixin):
|
|
|
206
206
|
await self.b01_q7_properties.start()
|
|
207
207
|
elif self.zeo is not None:
|
|
208
208
|
await self.zeo.start()
|
|
209
|
+
elif self.dyad is not None:
|
|
210
|
+
await self.dyad.start()
|
|
209
211
|
except RoborockException:
|
|
210
212
|
# Expected: start() can fail transiently. Unsubscribe before propagating
|
|
211
213
|
# so the retry by connect_loop() gets a clean channel.
|
|
@@ -238,6 +240,8 @@ class RoborockDevice(ABC, TraitsMixin):
|
|
|
238
240
|
await self.b01_q7_properties.close()
|
|
239
241
|
if self.zeo is not None:
|
|
240
242
|
self.zeo.close()
|
|
243
|
+
if self.dyad is not None:
|
|
244
|
+
self.dyad.close()
|
|
241
245
|
if self._unsub:
|
|
242
246
|
self._unsub()
|
|
243
247
|
self._unsub = None
|
|
@@ -255,7 +255,7 @@ async def create_device_manager(
|
|
|
255
255
|
)
|
|
256
256
|
case DeviceVersion.A01:
|
|
257
257
|
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
|
|
258
|
-
trait = a01.create(product, channel)
|
|
258
|
+
trait = a01.create(product, channel, device_status=device.device_status)
|
|
259
259
|
case DeviceVersion.B01:
|
|
260
260
|
mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
|
|
261
261
|
model_part = product.model.split(".")[-1]
|
|
@@ -6,7 +6,7 @@ Dyad (Wet/Dry Vacuums) and Zeo (Washing Machines).
|
|
|
6
6
|
Using A01 APIs
|
|
7
7
|
--------------
|
|
8
8
|
A01 devices expose a single API object that handles all device interactions. This API is
|
|
9
|
-
available on the device instance (
|
|
9
|
+
available on the device instance (`device.dyad` or `device.zeo`).
|
|
10
10
|
|
|
11
11
|
The API provides these methods:
|
|
12
12
|
1. **query_values(protocols)**: Fetches current state for specific data points.
|
|
@@ -14,18 +14,22 @@ The API provides these methods:
|
|
|
14
14
|
`RoborockZeoProtocol`) to request specific data.
|
|
15
15
|
2. **set_value(protocol, value)**: Sends a command to the device to change a setting
|
|
16
16
|
or perform an action.
|
|
17
|
-
3. **
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
3. **values**: The latest known state, merged from query responses and unsolicited
|
|
18
|
+
pushes in arrival order.
|
|
19
|
+
4. **add_update_listener(callback)**: Registers a callback invoked whenever `values`
|
|
20
|
+
changes; read `values` from the callback to get the updated state.
|
|
21
|
+
|
|
22
|
+
The device pushes only the data points that changed, so `values` is the merged view
|
|
23
|
+
of everything seen so far. State tracking is active once the device is connected
|
|
24
|
+
(the device calls `start()` on the API, which subscribes to the MQTT topic).
|
|
22
25
|
"""
|
|
23
26
|
|
|
24
27
|
import json
|
|
25
28
|
import logging
|
|
29
|
+
from abc import abstractmethod
|
|
26
30
|
from collections.abc import Callable
|
|
27
|
-
from datetime import time
|
|
28
|
-
from typing import Any
|
|
31
|
+
from datetime import UTC, datetime, time
|
|
32
|
+
from typing import Any, Generic, TypeVar
|
|
29
33
|
|
|
30
34
|
from roborock.data import DyadProductInfo, DyadSndState, HomeDataProduct, RoborockCategory
|
|
31
35
|
from roborock.data.dyad.dyad_code_mappings import (
|
|
@@ -73,6 +77,7 @@ from roborock.roborock_message import (
|
|
|
73
77
|
_LOGGER = logging.getLogger(__name__)
|
|
74
78
|
|
|
75
79
|
__all__ = [
|
|
80
|
+
"A01Api",
|
|
76
81
|
"DyadApi",
|
|
77
82
|
"ZeoApi",
|
|
78
83
|
]
|
|
@@ -155,14 +160,121 @@ def convert_zeo_value(protocol_value: RoborockZeoProtocol, value: Any) -> Any:
|
|
|
155
160
|
|
|
156
161
|
|
|
157
162
|
_DYAD_PROTOCOL_VALUES = frozenset(protocol.value for protocol in RoborockDyadDataProtocol)
|
|
163
|
+
_ZEO_PROTOCOL_VALUES = frozenset(protocol.value for protocol in RoborockZeoProtocol)
|
|
158
164
|
|
|
165
|
+
_P = TypeVar("_P", RoborockDyadDataProtocol, RoborockZeoProtocol)
|
|
159
166
|
|
|
160
|
-
class DyadApi(Trait):
|
|
161
|
-
"""API for interacting with Dyad devices."""
|
|
162
167
|
|
|
163
|
-
|
|
164
|
-
|
|
168
|
+
class A01Api(Trait, TraitUpdateListener, Generic[_P]):
|
|
169
|
+
"""Base class for A01 device APIs with device state tracking.
|
|
170
|
+
|
|
171
|
+
Query responses and unsolicited pushes both arrive on the same MQTT topic,
|
|
172
|
+
so a single subscription merges every decoded message into `values` in
|
|
173
|
+
arrival order. Update listeners are notified whenever a value changes.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(self, channel: MqttChannel, initial_status: dict[int, Any] | None = None) -> None:
|
|
177
|
+
"""Initialize the A01 API, optionally seeding `values` from a cloud status snapshot."""
|
|
178
|
+
TraitUpdateListener.__init__(self, _LOGGER)
|
|
165
179
|
self._channel = channel
|
|
180
|
+
self._values: dict[_P, Any] = {}
|
|
181
|
+
self._unsub: Callable[[], None] | None = None
|
|
182
|
+
self._last_message_time: datetime | None = None
|
|
183
|
+
if initial_status:
|
|
184
|
+
self._merge_values(self._decode_datapoints(initial_status))
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def values(self) -> dict[_P, Any]:
|
|
188
|
+
"""Latest known device state, merged from query responses and pushes.
|
|
189
|
+
|
|
190
|
+
The device pushes only the data points that changed, so this is the
|
|
191
|
+
merged view of everything seen so far. A protocol the device has not
|
|
192
|
+
reported yet is absent from the dictionary.
|
|
193
|
+
"""
|
|
194
|
+
return dict(self._values)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def last_message_time(self) -> datetime | None:
|
|
198
|
+
"""Time the last message was received from the device.
|
|
199
|
+
|
|
200
|
+
Updated on every decoded message, even when no value changed: idle
|
|
201
|
+
devices push an identical heartbeat, so this is the liveness signal
|
|
202
|
+
even when `values` stays the same and update listeners stay silent.
|
|
203
|
+
The initial cloud status snapshot does not count as a message.
|
|
204
|
+
"""
|
|
205
|
+
return self._last_message_time
|
|
206
|
+
|
|
207
|
+
async def start(self) -> None:
|
|
208
|
+
"""Subscribe to the device state topic and start tracking `values`."""
|
|
209
|
+
await self._ensure_subscribed()
|
|
210
|
+
|
|
211
|
+
def close(self) -> None:
|
|
212
|
+
"""Unsubscribe from MQTT push and release resources."""
|
|
213
|
+
if self._unsub is not None:
|
|
214
|
+
self._unsub()
|
|
215
|
+
self._unsub = None
|
|
216
|
+
|
|
217
|
+
async def _ensure_subscribed(self) -> None:
|
|
218
|
+
"""Subscribe to MQTT DPS push (idempotent)."""
|
|
219
|
+
if self._unsub is not None:
|
|
220
|
+
return
|
|
221
|
+
self._unsub = await self._channel.subscribe(self._on_message)
|
|
222
|
+
|
|
223
|
+
@abstractmethod
|
|
224
|
+
def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[_P, Any]:
|
|
225
|
+
"""Convert raw datapoints to typed values, skipping unknown codes."""
|
|
226
|
+
|
|
227
|
+
def _on_message(self, message: RoborockMessage) -> None:
|
|
228
|
+
"""Handle a message on the device topic (query response or push)."""
|
|
229
|
+
if message.protocol != RoborockMessageProtocol.RPC_RESPONSE:
|
|
230
|
+
return
|
|
231
|
+
try:
|
|
232
|
+
datapoints = decode_rpc_response(message)
|
|
233
|
+
except RoborockException:
|
|
234
|
+
_LOGGER.debug("Dropped malformed push message", exc_info=True)
|
|
235
|
+
return
|
|
236
|
+
self._last_message_time = datetime.now(UTC)
|
|
237
|
+
self._merge_values(self._decode_datapoints(datapoints))
|
|
238
|
+
|
|
239
|
+
def _merge_query_response(self, values: dict[_P, Any]) -> None:
|
|
240
|
+
"""Record a successful query response when there is no subscription.
|
|
241
|
+
|
|
242
|
+
When subscribed, the response was already merged in arrival order and
|
|
243
|
+
timestamped by `_on_message`; merging again here could overwrite a
|
|
244
|
+
push that arrived after it.
|
|
245
|
+
"""
|
|
246
|
+
if self._unsub is not None:
|
|
247
|
+
return
|
|
248
|
+
self._last_message_time = datetime.now(UTC)
|
|
249
|
+
self._merge_values(values)
|
|
250
|
+
|
|
251
|
+
def _merge_values(self, values: dict[_P, Any]) -> None:
|
|
252
|
+
"""Merge decoded values into the cache and notify on change."""
|
|
253
|
+
changed = False
|
|
254
|
+
for protocol, value in values.items():
|
|
255
|
+
if value is None:
|
|
256
|
+
continue
|
|
257
|
+
if protocol not in self._values or self._values[protocol] != value:
|
|
258
|
+
self._values[protocol] = value
|
|
259
|
+
changed = True
|
|
260
|
+
if changed:
|
|
261
|
+
self._notify_update()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class DyadApi(A01Api[RoborockDyadDataProtocol]):
|
|
265
|
+
"""API for interacting with Dyad devices."""
|
|
266
|
+
|
|
267
|
+
name = "dyad"
|
|
268
|
+
|
|
269
|
+
def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[RoborockDyadDataProtocol, Any]:
|
|
270
|
+
"""Convert raw datapoints to typed values, skipping unknown codes."""
|
|
271
|
+
values: dict[RoborockDyadDataProtocol, Any] = {}
|
|
272
|
+
for code, value in datapoints.items():
|
|
273
|
+
if code not in _DYAD_PROTOCOL_VALUES:
|
|
274
|
+
continue
|
|
275
|
+
protocol = RoborockDyadDataProtocol(code)
|
|
276
|
+
values[protocol] = convert_dyad_value(protocol, value)
|
|
277
|
+
return values
|
|
166
278
|
|
|
167
279
|
async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]:
|
|
168
280
|
"""Query the device for the values of the given Dyad protocols."""
|
|
@@ -171,7 +283,9 @@ class DyadApi(Trait):
|
|
|
171
283
|
{RoborockDyadDataProtocol.ID_QUERY: protocols},
|
|
172
284
|
value_encoder=json.dumps,
|
|
173
285
|
)
|
|
174
|
-
|
|
286
|
+
values = {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols}
|
|
287
|
+
self._merge_query_response(values)
|
|
288
|
+
return values
|
|
175
289
|
|
|
176
290
|
async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]:
|
|
177
291
|
"""Set a value for a specific protocol on the device."""
|
|
@@ -184,6 +298,9 @@ class DyadApi(Trait):
|
|
|
184
298
|
The callback is invoked with decoded values whenever the device sends a
|
|
185
299
|
message, including unsolicited pushes when its state changes. Only known
|
|
186
300
|
protocols are delivered. Returns a callable to remove the listener.
|
|
301
|
+
|
|
302
|
+
Prefer `add_update_listener` together with `values`, which handle the
|
|
303
|
+
merging of partial pushes for you.
|
|
187
304
|
"""
|
|
188
305
|
|
|
189
306
|
def on_message(message: RoborockMessage) -> None:
|
|
@@ -191,31 +308,24 @@ class DyadApi(Trait):
|
|
|
191
308
|
datapoints = decode_rpc_response(message)
|
|
192
309
|
except RoborockException:
|
|
193
310
|
return
|
|
194
|
-
values
|
|
195
|
-
for code, value in datapoints.items():
|
|
196
|
-
if code not in _DYAD_PROTOCOL_VALUES:
|
|
197
|
-
continue
|
|
198
|
-
protocol = RoborockDyadDataProtocol(code)
|
|
199
|
-
values[protocol] = convert_dyad_value(protocol, value)
|
|
200
|
-
if values:
|
|
311
|
+
if values := self._decode_datapoints(datapoints):
|
|
201
312
|
callback(values)
|
|
202
313
|
|
|
203
314
|
return await self._channel.subscribe(on_message)
|
|
204
315
|
|
|
205
316
|
|
|
206
|
-
class ZeoApi(
|
|
317
|
+
class ZeoApi(A01Api[RoborockZeoProtocol]):
|
|
207
318
|
"""API for interacting with Zeo devices."""
|
|
208
319
|
|
|
209
320
|
name = "zeo"
|
|
210
321
|
|
|
211
|
-
def __init__(
|
|
322
|
+
def __init__(
|
|
323
|
+
self, channel: MqttChannel, model: str | None = None, initial_status: dict[int, Any] | None = None
|
|
324
|
+
) -> None:
|
|
212
325
|
"""Initialize the Zeo API."""
|
|
213
|
-
TraitUpdateListener.__init__(self, _LOGGER)
|
|
214
|
-
self._channel = channel
|
|
215
|
-
self._dps_cache: dict[int, Any] = {}
|
|
216
|
-
self._dps_unsub: Callable[[], None] | None = None
|
|
217
326
|
self._feature_bits: int = 0
|
|
218
327
|
self._model = model
|
|
328
|
+
super().__init__(channel, initial_status)
|
|
219
329
|
|
|
220
330
|
async def start(self) -> None:
|
|
221
331
|
"""Subscribe to MQTT push and trigger a full state sync.
|
|
@@ -230,17 +340,15 @@ class ZeoApi(Trait, TraitUpdateListener):
|
|
|
230
340
|
await self._force_load()
|
|
231
341
|
await self._load_feature_dps()
|
|
232
342
|
|
|
233
|
-
def
|
|
234
|
-
"""
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return
|
|
243
|
-
self._dps_unsub = await self._channel.subscribe(self._on_dps_message)
|
|
343
|
+
def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[RoborockZeoProtocol, Any]:
|
|
344
|
+
"""Convert raw datapoints to typed values, skipping unknown codes."""
|
|
345
|
+
values: dict[RoborockZeoProtocol, Any] = {}
|
|
346
|
+
for code, value in datapoints.items():
|
|
347
|
+
if code not in _ZEO_PROTOCOL_VALUES:
|
|
348
|
+
continue
|
|
349
|
+
protocol = RoborockZeoProtocol(code)
|
|
350
|
+
values[protocol] = convert_zeo_value(protocol, value)
|
|
351
|
+
return values
|
|
244
352
|
|
|
245
353
|
async def _force_load(self) -> None:
|
|
246
354
|
"""Send ID_QUERY with the base DP list to trigger a full state push.
|
|
@@ -279,18 +387,6 @@ class ZeoApi(Trait, TraitUpdateListener):
|
|
|
279
387
|
"""Check whether the device supports a given feature bit."""
|
|
280
388
|
return bool(self._feature_bits & (1 << feature.value))
|
|
281
389
|
|
|
282
|
-
def _on_dps_message(self, message: RoborockMessage) -> None:
|
|
283
|
-
"""Handle unsolicited MQTT push (protocol 102 — RPC_RESPONSE)."""
|
|
284
|
-
if message.protocol != RoborockMessageProtocol.RPC_RESPONSE:
|
|
285
|
-
return
|
|
286
|
-
try:
|
|
287
|
-
decoded = decode_rpc_response(message)
|
|
288
|
-
except RoborockException:
|
|
289
|
-
_LOGGER.debug("Dropped malformed push message", exc_info=True)
|
|
290
|
-
return
|
|
291
|
-
self._dps_cache.update(decoded)
|
|
292
|
-
self._notify_update()
|
|
293
|
-
|
|
294
390
|
async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]:
|
|
295
391
|
"""Query the device for the values of the given protocols."""
|
|
296
392
|
response = await send_decoded_command(
|
|
@@ -298,7 +394,9 @@ class ZeoApi(Trait, TraitUpdateListener):
|
|
|
298
394
|
{RoborockZeoProtocol.ID_QUERY: protocols},
|
|
299
395
|
value_encoder=json.dumps,
|
|
300
396
|
)
|
|
301
|
-
|
|
397
|
+
values = {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols}
|
|
398
|
+
self._merge_query_response(values)
|
|
399
|
+
return values
|
|
302
400
|
|
|
303
401
|
async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]:
|
|
304
402
|
"""Set a value for a specific protocol on the device."""
|
|
@@ -306,12 +404,28 @@ class ZeoApi(Trait, TraitUpdateListener):
|
|
|
306
404
|
return await send_decoded_command(self._channel, params, value_encoder=lambda x: x)
|
|
307
405
|
|
|
308
406
|
|
|
309
|
-
def
|
|
310
|
-
"""
|
|
407
|
+
def _parse_device_status(device_status: dict | None) -> dict[int, Any] | None:
|
|
408
|
+
"""Normalize the cloud home data status snapshot to integer datapoint codes."""
|
|
409
|
+
if not device_status:
|
|
410
|
+
return None
|
|
411
|
+
try:
|
|
412
|
+
return {int(code): value for code, value in device_status.items()}
|
|
413
|
+
except (TypeError, ValueError):
|
|
414
|
+
_LOGGER.debug("Ignoring malformed device status snapshot: %s", device_status)
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def create(product: HomeDataProduct, mqtt_channel: MqttChannel, device_status: dict | None = None) -> DyadApi | ZeoApi:
|
|
419
|
+
"""Create traits for A01 devices.
|
|
420
|
+
|
|
421
|
+
The optional `device_status` is the cloud home data status snapshot, used
|
|
422
|
+
to seed `values` so state is available before the first device round trip.
|
|
423
|
+
"""
|
|
424
|
+
initial_status = _parse_device_status(device_status)
|
|
311
425
|
match product.category:
|
|
312
426
|
case RoborockCategory.WET_DRY_VAC:
|
|
313
|
-
return DyadApi(mqtt_channel)
|
|
427
|
+
return DyadApi(mqtt_channel, initial_status=initial_status)
|
|
314
428
|
case RoborockCategory.WASHING_MACHINE:
|
|
315
|
-
return ZeoApi(mqtt_channel, model=product.model)
|
|
429
|
+
return ZeoApi(mqtt_channel, model=product.model, initial_status=initial_status)
|
|
316
430
|
case _:
|
|
317
431
|
raise NotImplementedError(f"Unsupported category {product.category}")
|
|
@@ -15,7 +15,7 @@ from aiohttp import ContentTypeError, FormData
|
|
|
15
15
|
from pyrate_limiter import Duration, Limiter, Rate
|
|
16
16
|
|
|
17
17
|
from roborock import HomeDataSchedule
|
|
18
|
-
from roborock.data import HomeData, HomeDataRoom, HomeDataScene, ProductResponse, RRiot, UserData
|
|
18
|
+
from roborock.data import FirmwareInfo, HomeData, HomeDataRoom, HomeDataScene, ProductResponse, RRiot, UserData
|
|
19
19
|
from roborock.exceptions import (
|
|
20
20
|
RoborockAccountDoesNotExist,
|
|
21
21
|
RoborockException,
|
|
@@ -608,6 +608,73 @@ class RoborockApiClient:
|
|
|
608
608
|
else:
|
|
609
609
|
raise RoborockException("scene_response result was an unexpected type")
|
|
610
610
|
|
|
611
|
+
async def get_firmware_info(self, user_data: UserData, device_id: str) -> FirmwareInfo:
|
|
612
|
+
"""Get firmware/OTA info for a device (latest version + updatable flag)."""
|
|
613
|
+
rriot = user_data.rriot
|
|
614
|
+
if rriot is None:
|
|
615
|
+
raise RoborockException("rriot is none")
|
|
616
|
+
if rriot.r.a is None:
|
|
617
|
+
raise RoborockException("Missing field 'a' in rriot reference")
|
|
618
|
+
path = f"/ota/firmware/{device_id}/updatev2"
|
|
619
|
+
params = {"lang": "en"}
|
|
620
|
+
firmware_request = PreparedRequest(
|
|
621
|
+
rriot.r.a,
|
|
622
|
+
self.session,
|
|
623
|
+
{
|
|
624
|
+
"Authorization": _get_hawk_authentication(rriot, path, params=params),
|
|
625
|
+
},
|
|
626
|
+
)
|
|
627
|
+
firmware_response = await firmware_request.request("get", path, params=params)
|
|
628
|
+
if not firmware_response or not firmware_response.get("success"):
|
|
629
|
+
raise RoborockException(firmware_response)
|
|
630
|
+
return FirmwareInfo.from_dict(firmware_response.get("result") or {})
|
|
631
|
+
|
|
632
|
+
async def start_firmware_update(self, user_data: UserData, device_id: str) -> None:
|
|
633
|
+
"""Trigger the (irreversible) firmware update for a device.
|
|
634
|
+
|
|
635
|
+
The device downloads and flashes the latest firmware reported by
|
|
636
|
+
:meth:`get_firmware_info`.
|
|
637
|
+
"""
|
|
638
|
+
rriot = user_data.rriot
|
|
639
|
+
if rriot is None:
|
|
640
|
+
raise RoborockException("rriot is none")
|
|
641
|
+
if rriot.r.a is None:
|
|
642
|
+
raise RoborockException("Missing field 'a' in rriot reference")
|
|
643
|
+
path = f"/ota/device/{device_id}/upgrade"
|
|
644
|
+
upgrade_request = PreparedRequest(
|
|
645
|
+
rriot.r.a,
|
|
646
|
+
self.session,
|
|
647
|
+
{
|
|
648
|
+
"Authorization": _get_hawk_authentication(rriot, path),
|
|
649
|
+
},
|
|
650
|
+
)
|
|
651
|
+
upgrade_response = await upgrade_request.request("post", path)
|
|
652
|
+
if not upgrade_response or not upgrade_response.get("success"):
|
|
653
|
+
raise RoborockException(upgrade_response)
|
|
654
|
+
|
|
655
|
+
async def set_silent_ota(self, user_data: UserData, device_id: str, enabled: bool) -> None:
|
|
656
|
+
"""Enable/disable automatic (silent) firmware updates for a device.
|
|
657
|
+
|
|
658
|
+
The current value is reported as ``silent_ota_switch`` on the home-data device.
|
|
659
|
+
"""
|
|
660
|
+
rriot = user_data.rriot
|
|
661
|
+
if rriot is None:
|
|
662
|
+
raise RoborockException("rriot is none")
|
|
663
|
+
if rriot.r.a is None:
|
|
664
|
+
raise RoborockException("Missing field 'a' in rriot reference")
|
|
665
|
+
path = f"/user/devices/{device_id}"
|
|
666
|
+
formdata = {"silentOtaSwitch": "true" if enabled else "false"}
|
|
667
|
+
silent_ota_request = PreparedRequest(
|
|
668
|
+
rriot.r.a,
|
|
669
|
+
self.session,
|
|
670
|
+
{
|
|
671
|
+
"Authorization": _get_hawk_authentication(rriot, path, formdata=formdata),
|
|
672
|
+
},
|
|
673
|
+
)
|
|
674
|
+
silent_ota_response = await silent_ota_request.request("put", path, data=formdata)
|
|
675
|
+
if not silent_ota_response or not silent_ota_response.get("success"):
|
|
676
|
+
raise RoborockException(silent_ota_response)
|
|
677
|
+
|
|
611
678
|
async def execute_scene(self, user_data: UserData, scene_id: int) -> None:
|
|
612
679
|
rriot = user_data.rriot
|
|
613
680
|
if rriot is None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/b01_q10/b01_q10_code_mappings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/data/b01_q7/b01_q7_code_mappings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/a01/device_feature.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/button_light.py
RENAMED
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/child_lock.py
RENAMED
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/clean_history.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/consumable.py
RENAMED
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/do_not_disturb.py
RENAMED
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/dust_collection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q10/network_info.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q7/clean_summary.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/b01/q7/map_content.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/device_features.py
RENAMED
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/do_not_disturb.py
RENAMED
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/dust_collection_mode.py
RENAMED
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/flow_led_status.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/obstacle_photos.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/smart_wash_params.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-7.2.3 → python_roborock-7.4.0}/roborock/devices/traits/v1/wash_towel_mode.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|