python-roborock 5.19.0__tar.gz → 5.20.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.
- {python_roborock-5.19.0 → python_roborock-5.20.1}/PKG-INFO +1 -1
- {python_roborock-5.19.0 → python_roborock-5.20.1}/pyproject.toml +1 -1
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/cli.py +103 -4
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/device.py +10 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/rpc/b01_q10_channel.py +16 -10
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/rpc/v1_channel.py +67 -37
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q10/__init__.py +28 -9
- python_roborock-5.20.1/roborock/devices/traits/b01/q10/map.py +110 -0
- python_roborock-5.20.1/roborock/map/b01_q10_map_parser.py +399 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocols/b01_q10_protocol.py +43 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/.gitignore +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/LICENSE +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/README.md +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/broadcast_protocol.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/callbacks.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/const.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q10/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q7/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/dyad/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/dyad/dyad_code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/dyad/dyad_containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/v1/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/v1/v1_clean_modes.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/v1/v1_code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/v1/v1_containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/zeo/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/zeo/zeo_code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/zeo/zeo_containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/device_features.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/README.md +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/cache.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/device_manager.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/file_cache.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/rpc/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/rpc/a01_channel.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/rpc/b01_q7_channel.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/a01/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q10/command.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q10/remote.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q10/status.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q7/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q7/map.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q7/map_content.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/common.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/traits_mixin.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/child_lock.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/clean_summary.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/command.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/common.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/consumeable.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/device_features.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/flow_led_status.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/home.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/led_status.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/map_content.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/maps.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/network_info.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/rooms.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/routines.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/status.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/volume.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/transport/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/transport/channel.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/transport/local_channel.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/transport/mqtt_channel.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/diagnostics.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/exceptions.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/b01_grid_layers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/b01_map_parser.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/b01_q10_overlays.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/map_parser.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/proto/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/proto/b01_scmap.proto +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/proto/b01_scmap_pb2.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/mqtt/health_manager.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/mqtt/session.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocol.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocols/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocols/b01_q7_protocol.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocols/v1_protocol.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/py.typed +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/roborock_message.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/roborock_typing.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/util.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/web_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-roborock
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.20.1
|
|
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 = "5.
|
|
3
|
+
version = "5.20.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"
|
|
@@ -56,6 +56,7 @@ from roborock.devices.cache import Cache, CacheData
|
|
|
56
56
|
from roborock.devices.device import RoborockDevice
|
|
57
57
|
from roborock.devices.device_manager import DeviceManager, UserParams, create_device_manager
|
|
58
58
|
from roborock.devices.traits import Trait
|
|
59
|
+
from roborock.devices.traits.b01.q10 import Q10PropertiesApi
|
|
59
60
|
from roborock.devices.traits.b01.q10.vacuum import VacuumTrait
|
|
60
61
|
from roborock.devices.traits.v1 import V1TraitMixin
|
|
61
62
|
from roborock.devices.traits.v1.consumeable import ConsumableAttribute
|
|
@@ -527,6 +528,45 @@ async def maps(ctx, device_id: str):
|
|
|
527
528
|
await _display_v1_trait(context, device_id, lambda v1: v1.maps)
|
|
528
529
|
|
|
529
530
|
|
|
531
|
+
# The Q10 pushes its map ~9s after a dpRequestDps; firmware throttles pushes to
|
|
532
|
+
# ~once per 60-70s, so a single request is answered quickly but rapid re-requests
|
|
533
|
+
# may not be. This bounds how long a one-shot CLI command waits for that push.
|
|
534
|
+
_Q10_MAP_PUSH_TIMEOUT = 30.0
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
async def _await_q10_map_push(
|
|
538
|
+
properties: Q10PropertiesApi,
|
|
539
|
+
predicate: Callable[[], bool],
|
|
540
|
+
*,
|
|
541
|
+
timeout: float = _Q10_MAP_PUSH_TIMEOUT,
|
|
542
|
+
allow_cached_on_timeout: bool = False,
|
|
543
|
+
) -> bool:
|
|
544
|
+
"""Nudge a Q10 to push its map/trace and wait for a fresh update.
|
|
545
|
+
|
|
546
|
+
The Q10 map API is entirely push-driven: there is no synchronous get-map
|
|
547
|
+
request. A ``dpRequestDps`` causes the device to publish a ``MAP_RESPONSE``,
|
|
548
|
+
which the device's subscribe loop feeds into the map trait. Here we register
|
|
549
|
+
an update listener, send the request, and wait for a newly pushed update to
|
|
550
|
+
satisfy ``predicate``. Returns whether it did within ``timeout``.
|
|
551
|
+
"""
|
|
552
|
+
loop = asyncio.get_running_loop()
|
|
553
|
+
updated: asyncio.Future[None] = loop.create_future()
|
|
554
|
+
|
|
555
|
+
def on_update() -> None:
|
|
556
|
+
if predicate() and not updated.done():
|
|
557
|
+
updated.set_result(None)
|
|
558
|
+
|
|
559
|
+
unsub = properties.map.add_update_listener(on_update)
|
|
560
|
+
try:
|
|
561
|
+
await properties.refresh()
|
|
562
|
+
await asyncio.wait_for(updated, timeout=timeout)
|
|
563
|
+
return True
|
|
564
|
+
except TimeoutError:
|
|
565
|
+
return allow_cached_on_timeout and predicate()
|
|
566
|
+
finally:
|
|
567
|
+
unsub()
|
|
568
|
+
|
|
569
|
+
|
|
530
570
|
@session.command()
|
|
531
571
|
@click.option("--device_id", required=True)
|
|
532
572
|
@click.option("--output-file", required=True, help="Path to save the map image.")
|
|
@@ -535,10 +575,22 @@ async def maps(ctx, device_id: str):
|
|
|
535
575
|
async def map_image(ctx, device_id: str, output_file: str):
|
|
536
576
|
"""Get device map image and save it to a file."""
|
|
537
577
|
context: RoborockContext = ctx.obj
|
|
538
|
-
|
|
539
|
-
|
|
578
|
+
device_manager = await context.get_device_manager()
|
|
579
|
+
device = await device_manager.get_device(device_id)
|
|
580
|
+
if device.b01_q10_properties is not None:
|
|
581
|
+
properties = device.b01_q10_properties
|
|
582
|
+
await _await_q10_map_push(
|
|
583
|
+
properties,
|
|
584
|
+
lambda: properties.map.image_content is not None,
|
|
585
|
+
allow_cached_on_timeout=True,
|
|
586
|
+
)
|
|
587
|
+
image_content = properties.map.image_content
|
|
588
|
+
else:
|
|
589
|
+
v1_trait: MapContentTrait = await _v1_trait(context, device_id, lambda v1: v1.map_content)
|
|
590
|
+
image_content = v1_trait.image_content
|
|
591
|
+
if image_content:
|
|
540
592
|
with open(output_file, "wb") as f:
|
|
541
|
-
f.write(
|
|
593
|
+
f.write(image_content)
|
|
542
594
|
click.echo(f"Map image saved to {output_file}")
|
|
543
595
|
else:
|
|
544
596
|
click.echo("No map image content available.")
|
|
@@ -570,6 +622,39 @@ async def map_data(ctx, device_id: str, include_path: bool):
|
|
|
570
622
|
click.echo(dump_json(data_summary))
|
|
571
623
|
|
|
572
624
|
|
|
625
|
+
@session.command()
|
|
626
|
+
@click.option("--device_id", required=True)
|
|
627
|
+
@click.option("--include_path", is_flag=True, default=False, help="Include all path points in the output.")
|
|
628
|
+
@click.pass_context
|
|
629
|
+
@async_command
|
|
630
|
+
async def q10_position(ctx, device_id: str, include_path: bool):
|
|
631
|
+
"""Get the current Q10 robot position and live cleaning path.
|
|
632
|
+
|
|
633
|
+
The Q10 only streams its position/path while it is actively cleaning, so this
|
|
634
|
+
will report that no live trace is available for an idle/docked robot.
|
|
635
|
+
"""
|
|
636
|
+
context: RoborockContext = ctx.obj
|
|
637
|
+
device_manager = await context.get_device_manager()
|
|
638
|
+
device = await device_manager.get_device(device_id)
|
|
639
|
+
if device.b01_q10_properties is None:
|
|
640
|
+
click.echo("Feature not supported by device")
|
|
641
|
+
return
|
|
642
|
+
properties = device.b01_q10_properties
|
|
643
|
+
got_trace = await _await_q10_map_push(properties, lambda: bool(properties.map.path))
|
|
644
|
+
if not got_trace:
|
|
645
|
+
click.echo("No live trace available (the robot only reports position while cleaning).")
|
|
646
|
+
return
|
|
647
|
+
map_trait = properties.map
|
|
648
|
+
position = map_trait.robot_position
|
|
649
|
+
summary: dict[str, Any] = {
|
|
650
|
+
"robot_position": {"x": position.x, "y": position.y} if position else None,
|
|
651
|
+
"path_points": len(map_trait.path),
|
|
652
|
+
}
|
|
653
|
+
if include_path:
|
|
654
|
+
summary["path"] = [[p.x, p.y] for p in map_trait.path]
|
|
655
|
+
click.echo(dump_json(summary))
|
|
656
|
+
|
|
657
|
+
|
|
573
658
|
@session.command()
|
|
574
659
|
@click.option("--device_id", required=True)
|
|
575
660
|
@click.pass_context
|
|
@@ -711,7 +796,20 @@ async def set_child_lock(ctx, device_id: str, enabled: bool):
|
|
|
711
796
|
async def rooms(ctx, device_id: str):
|
|
712
797
|
"""Get device room mapping info."""
|
|
713
798
|
context: RoborockContext = ctx.obj
|
|
714
|
-
await
|
|
799
|
+
device_manager = await context.get_device_manager()
|
|
800
|
+
device = await device_manager.get_device(device_id)
|
|
801
|
+
if device.b01_q10_properties is not None:
|
|
802
|
+
properties = device.b01_q10_properties
|
|
803
|
+
# A valid map may have no room records, so wait on the map arriving
|
|
804
|
+
# (image_content) rather than on rooms being non-empty.
|
|
805
|
+
await _await_q10_map_push(
|
|
806
|
+
properties,
|
|
807
|
+
lambda: properties.map.image_content is not None,
|
|
808
|
+
allow_cached_on_timeout=True,
|
|
809
|
+
)
|
|
810
|
+
click.echo(dump_json({room.id: room.name for room in properties.map.rooms}))
|
|
811
|
+
else:
|
|
812
|
+
await _display_v1_trait(context, device_id, lambda v1: v1.rooms)
|
|
715
813
|
|
|
716
814
|
|
|
717
815
|
@session.command()
|
|
@@ -1200,6 +1298,7 @@ cli.add_command(set_volume)
|
|
|
1200
1298
|
cli.add_command(maps)
|
|
1201
1299
|
cli.add_command(map_image)
|
|
1202
1300
|
cli.add_command(map_data)
|
|
1301
|
+
cli.add_command(q10_position)
|
|
1203
1302
|
cli.add_command(consumables)
|
|
1204
1303
|
cli.add_command(reset_consumable)
|
|
1205
1304
|
cli.add_command(rooms)
|
|
@@ -203,6 +203,16 @@ class RoborockDevice(ABC, TraitsMixin):
|
|
|
203
203
|
elif self.b01_q10_properties is not None:
|
|
204
204
|
await self.b01_q10_properties.start()
|
|
205
205
|
except RoborockException:
|
|
206
|
+
# Expected: start() can fail transiently. Unsubscribe before propagating
|
|
207
|
+
# so the retry by connect_loop() gets a clean channel.
|
|
208
|
+
unsub()
|
|
209
|
+
raise
|
|
210
|
+
except Exception:
|
|
211
|
+
# Not expected here. We normally avoid a bare ``except Exception`` in
|
|
212
|
+
# this codebase, but a leaked subscription would stop connect_loop() from
|
|
213
|
+
# ever reconnecting, so we deliberately catch broadly, log the unexpected
|
|
214
|
+
# error, and release the channel before propagating.
|
|
215
|
+
self._logger.exception("Unexpected error during connect; releasing channel to avoid a leak")
|
|
206
216
|
unsub()
|
|
207
217
|
raise
|
|
208
218
|
self._logger.info("Connected to device")
|
|
@@ -2,36 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
from collections.abc import AsyncGenerator
|
|
5
|
-
from typing import Any
|
|
6
5
|
|
|
7
6
|
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
|
|
8
7
|
from roborock.devices.transport.mqtt_channel import MqttChannel
|
|
9
8
|
from roborock.exceptions import RoborockException
|
|
10
9
|
from roborock.protocols.b01_q10_protocol import (
|
|
11
10
|
ParamsType,
|
|
12
|
-
|
|
11
|
+
Q10Message,
|
|
12
|
+
decode_message,
|
|
13
13
|
encode_mqtt_payload,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
_LOGGER = logging.getLogger(__name__)
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
async def
|
|
19
|
+
async def stream_decoded_messages(
|
|
20
20
|
mqtt_channel: MqttChannel,
|
|
21
|
-
) -> AsyncGenerator[
|
|
22
|
-
"""Stream decoded
|
|
21
|
+
) -> AsyncGenerator[Q10Message, None]:
|
|
22
|
+
"""Stream decoded Q10 messages received via MQTT.
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Each pushed ``RoborockMessage`` is decoded into a typed :data:`Q10Message`
|
|
25
|
+
(a DPS status update, a map packet, or a trace packet). Messages that fail
|
|
26
|
+
to decode or carry an unrecognized payload are skipped.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
async for message in mqtt_channel.subscribe_stream():
|
|
25
30
|
try:
|
|
26
|
-
|
|
31
|
+
decoded = decode_message(message)
|
|
27
32
|
except RoborockException as ex:
|
|
28
33
|
_LOGGER.debug(
|
|
29
|
-
"Failed to decode B01 Q10
|
|
30
|
-
|
|
34
|
+
"Failed to decode B01 Q10 message: %s: %s",
|
|
35
|
+
message,
|
|
31
36
|
ex,
|
|
32
37
|
)
|
|
33
38
|
continue
|
|
34
|
-
|
|
39
|
+
if decoded is not None:
|
|
40
|
+
yield decoded
|
|
35
41
|
|
|
36
42
|
|
|
37
43
|
async def send_command(
|
|
@@ -295,45 +295,75 @@ class V1Channel(Channel):
|
|
|
295
295
|
if self._callback is not None:
|
|
296
296
|
raise ValueError("Only one subscription allowed at a time")
|
|
297
297
|
|
|
298
|
-
#
|
|
299
|
-
#
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
# Start a background task to manage the local connection health. This
|
|
306
|
-
# happens independent of whether we were able to connect locally now.
|
|
307
|
-
if self._reconnect_task is None:
|
|
308
|
-
loop = asyncio.get_running_loop()
|
|
309
|
-
self._reconnect_task = loop.create_task(self._background_reconnect())
|
|
310
|
-
|
|
311
|
-
# We maintain an active MQTT subscription even when connected locally to receive
|
|
312
|
-
# unsolicited status updates (DPS push messages) directly from the cloud.
|
|
298
|
+
# Claim the subscription up front. Any failure in the setup below routes
|
|
299
|
+
# through _teardown(), which clears this again so the channel is left in
|
|
300
|
+
# a clean, re-subscribable state. Without this, a partially-completed
|
|
301
|
+
# subscribe (e.g. a transient failure later in connect()) would leave a
|
|
302
|
+
# stale callback and the next subscribe() would raise the guard above.
|
|
303
|
+
self._callback = callback
|
|
313
304
|
try:
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
self._logger.debug("MQTT subscription failed, continuing with local-only connection: %s", err)
|
|
321
|
-
|
|
322
|
-
def unsub() -> None:
|
|
323
|
-
"""Unsubscribe from all messages."""
|
|
324
|
-
if self._reconnect_task:
|
|
325
|
-
self._reconnect_task.cancel()
|
|
326
|
-
self._reconnect_task = None
|
|
327
|
-
if self._mqtt_unsub:
|
|
328
|
-
self._mqtt_unsub()
|
|
329
|
-
self._mqtt_unsub = None
|
|
330
|
-
if self._local_unsub:
|
|
331
|
-
self._local_unsub()
|
|
332
|
-
self._local_unsub = None
|
|
333
|
-
self._logger.debug("Unsubscribed from device")
|
|
305
|
+
# Make an initial, optimistic attempt to connect to local with the
|
|
306
|
+
# cache. The cache information will be refreshed by the background task.
|
|
307
|
+
try:
|
|
308
|
+
await self._local_connect(prefer_cache=True)
|
|
309
|
+
except RoborockException as err:
|
|
310
|
+
self._logger.debug("First local connection attempt failed, will retry: %s", err)
|
|
334
311
|
|
|
335
|
-
|
|
336
|
-
|
|
312
|
+
# Start a background task to manage the local connection health. This
|
|
313
|
+
# happens independent of whether we were able to connect locally now.
|
|
314
|
+
if self._reconnect_task is None:
|
|
315
|
+
loop = asyncio.get_running_loop()
|
|
316
|
+
self._reconnect_task = loop.create_task(self._background_reconnect())
|
|
317
|
+
|
|
318
|
+
# We maintain an active MQTT subscription even when connected locally to receive
|
|
319
|
+
# unsolicited status updates (DPS push messages) directly from the cloud.
|
|
320
|
+
try:
|
|
321
|
+
self._mqtt_unsub = await self._mqtt_channel.subscribe(self._on_mqtt_message)
|
|
322
|
+
except RoborockException as err:
|
|
323
|
+
if not self.is_local_connected:
|
|
324
|
+
# Propagate error if both local and MQTT failed
|
|
325
|
+
self._logger.debug("MQTT connection also failed: %s", err)
|
|
326
|
+
raise
|
|
327
|
+
self._logger.debug("MQTT subscription failed, continuing with local-only connection: %s", err)
|
|
328
|
+
except RoborockException:
|
|
329
|
+
# Expected failure path (e.g. both local and MQTT transports down).
|
|
330
|
+
# Release the channel so the next subscribe() starts clean.
|
|
331
|
+
self._teardown()
|
|
332
|
+
raise
|
|
333
|
+
except Exception:
|
|
334
|
+
# Not expected here. We normally avoid a bare ``except Exception`` in
|
|
335
|
+
# this codebase, but leaving a partial subscription behind (reconnect
|
|
336
|
+
# task, MQTT subscription, stale callback) would brick the device, so we
|
|
337
|
+
# deliberately catch broadly, log the unexpected error, and tear down
|
|
338
|
+
# before propagating.
|
|
339
|
+
self._logger.exception("Unexpected error during subscribe; tearing down to avoid a leak")
|
|
340
|
+
self._teardown()
|
|
341
|
+
raise
|
|
342
|
+
|
|
343
|
+
self._logger.debug("Subscribed to device")
|
|
344
|
+
return self._teardown
|
|
345
|
+
|
|
346
|
+
def _teardown(self) -> None:
|
|
347
|
+
"""Tear down all subscriptions and reset the channel to a re-subscribable state.
|
|
348
|
+
|
|
349
|
+
Returned from subscribe() as the unsubscribe function and also invoked on
|
|
350
|
+
any failure partway through subscribe(). Idempotent: each resource is
|
|
351
|
+
guarded so repeat calls are no-ops.
|
|
352
|
+
"""
|
|
353
|
+
if self._reconnect_task:
|
|
354
|
+
self._reconnect_task.cancel()
|
|
355
|
+
self._reconnect_task = None
|
|
356
|
+
if self._mqtt_unsub:
|
|
357
|
+
self._mqtt_unsub()
|
|
358
|
+
self._mqtt_unsub = None
|
|
359
|
+
if self._local_unsub:
|
|
360
|
+
self._local_unsub()
|
|
361
|
+
self._local_unsub = None
|
|
362
|
+
if self._local_channel:
|
|
363
|
+
self._local_channel.close()
|
|
364
|
+
self._local_channel = None
|
|
365
|
+
self._callback = None
|
|
366
|
+
self._logger.debug("Unsubscribed from device")
|
|
337
367
|
|
|
338
368
|
def add_dps_listener(self, listener: Callable[[dict[RoborockDataProtocol, Any]], None]) -> Callable[[], None]:
|
|
339
369
|
"""Add a listener for DPS updates.
|
{python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q10/__init__.py
RENAMED
|
@@ -4,17 +4,21 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
|
|
6
6
|
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
|
|
7
|
-
from roborock.devices.rpc.b01_q10_channel import
|
|
7
|
+
from roborock.devices.rpc.b01_q10_channel import stream_decoded_messages
|
|
8
8
|
from roborock.devices.traits import Trait
|
|
9
9
|
from roborock.devices.transport.mqtt_channel import MqttChannel
|
|
10
|
+
from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket
|
|
11
|
+
from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message
|
|
10
12
|
|
|
11
13
|
from .command import CommandTrait
|
|
14
|
+
from .map import MapContentTrait
|
|
12
15
|
from .remote import RemoteTrait
|
|
13
16
|
from .status import StatusTrait
|
|
14
17
|
from .vacuum import VacuumTrait
|
|
15
18
|
|
|
16
19
|
__all__ = [
|
|
17
20
|
"Q10PropertiesApi",
|
|
21
|
+
"MapContentTrait",
|
|
18
22
|
]
|
|
19
23
|
|
|
20
24
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -35,6 +39,9 @@ class Q10PropertiesApi(Trait):
|
|
|
35
39
|
remote: RemoteTrait
|
|
36
40
|
"""Trait for sending remote control related commands to Q10 devices."""
|
|
37
41
|
|
|
42
|
+
map: MapContentTrait
|
|
43
|
+
"""Trait for fetching the current parsed map (image + rooms)."""
|
|
44
|
+
|
|
38
45
|
def __init__(self, channel: MqttChannel) -> None:
|
|
39
46
|
"""Initialize the B01Props API."""
|
|
40
47
|
self._channel = channel
|
|
@@ -42,6 +49,7 @@ class Q10PropertiesApi(Trait):
|
|
|
42
49
|
self.vacuum = VacuumTrait(self.command)
|
|
43
50
|
self.remote = RemoteTrait(self.command)
|
|
44
51
|
self.status = StatusTrait()
|
|
52
|
+
self.map = MapContentTrait()
|
|
45
53
|
self._subscribe_task: asyncio.Task[None] | None = None
|
|
46
54
|
|
|
47
55
|
async def start(self) -> None:
|
|
@@ -65,14 +73,25 @@ class Q10PropertiesApi(Trait):
|
|
|
65
73
|
await self.command.send(B01_Q10_DP.REQUEST_DPS, params={})
|
|
66
74
|
|
|
67
75
|
async def _subscribe_loop(self) -> None:
|
|
68
|
-
"""Persistent loop to
|
|
69
|
-
async for
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
"""Persistent loop dispatching decoded messages to the read-model traits."""
|
|
77
|
+
async for message in stream_decoded_messages(self._channel):
|
|
78
|
+
self._handle_message(message)
|
|
79
|
+
|
|
80
|
+
def _handle_message(self, message: Q10Message) -> None:
|
|
81
|
+
"""Route a single decoded message to the trait responsible for it.
|
|
82
|
+
|
|
83
|
+
Map and trace packets arrive as protocol-301 ``MAP_RESPONSE`` pushes (the
|
|
84
|
+
Q10 is entirely push-driven: there is no synchronous get-map request, a
|
|
85
|
+
``dpRequestDps`` just nudges the device to publish its current map). DPS
|
|
86
|
+
updates feed the status trait. More traits can be dispatched here below.
|
|
87
|
+
"""
|
|
88
|
+
if isinstance(message, Q10MapPacket):
|
|
89
|
+
self.map.update_from_map_packet(message)
|
|
90
|
+
elif isinstance(message, Q10TracePacket):
|
|
91
|
+
self.map.update_from_trace_packet(message)
|
|
92
|
+
elif isinstance(message, Q10DpsUpdate):
|
|
93
|
+
_LOGGER.debug("Received Q10 status update: %s", message.dps)
|
|
94
|
+
self.status.update_from_dps(message.dps)
|
|
76
95
|
|
|
77
96
|
|
|
78
97
|
def create(channel: MqttChannel) -> Q10PropertiesApi:
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Map content trait for B01 Q10 devices.
|
|
2
|
+
|
|
3
|
+
Unlike the v1 / Q7 maps, the Q10 has no synchronous "get map" command, so this
|
|
4
|
+
trait is purely push-driven and mirrors the Q10 ``StatusTrait`` contract:
|
|
5
|
+
|
|
6
|
+
- The device pushes its current map/path as protocol-301 ``MAP_RESPONSE``
|
|
7
|
+
messages (a ``dpRequestDps`` nudges it to do so). The protocol layer decodes
|
|
8
|
+
those into :class:`Q10MapPacket` / :class:`Q10TracePacket` objects and the
|
|
9
|
+
``Q10PropertiesApi`` subscribe loop routes them to
|
|
10
|
+
:meth:`MapContentTrait.update_from_map_packet` /
|
|
11
|
+
:meth:`MapContentTrait.update_from_trace_packet`.
|
|
12
|
+
- Those methods render/cache the content and notify update listeners (register
|
|
13
|
+
via :meth:`add_update_listener`).
|
|
14
|
+
- ``image_content``, ``map_data``, ``rooms``, ``path`` and ``robot_position``
|
|
15
|
+
are readable and reflect the most recently pushed map.
|
|
16
|
+
|
|
17
|
+
Unlike the Q7, the Q10 map payload is unencrypted, so no map key is required.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
from vacuum_map_parser_base.map_data import MapData
|
|
24
|
+
|
|
25
|
+
from roborock.data import RoborockBase
|
|
26
|
+
from roborock.devices.traits.common import TraitUpdateListener
|
|
27
|
+
from roborock.map.b01_q10_map_parser import (
|
|
28
|
+
B01Q10MapParser,
|
|
29
|
+
B01Q10MapParserConfig,
|
|
30
|
+
Q10MapPacket,
|
|
31
|
+
Q10Point,
|
|
32
|
+
Q10Room,
|
|
33
|
+
Q10TracePacket,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_LOGGER = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
_TRUNCATE_LENGTH = 20
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class MapContent(RoborockBase):
|
|
43
|
+
"""Dataclass representing Q10 map content."""
|
|
44
|
+
|
|
45
|
+
image_content: bytes | None = None
|
|
46
|
+
"""The rendered image of the map in PNG format."""
|
|
47
|
+
|
|
48
|
+
map_data: MapData | None = None
|
|
49
|
+
"""Parsed map data (image metadata + room names)."""
|
|
50
|
+
|
|
51
|
+
rooms: list[Q10Room] = field(default_factory=list)
|
|
52
|
+
"""Rooms (segments) reported by the device, with ids and names."""
|
|
53
|
+
|
|
54
|
+
path: list[Q10Point] = field(default_factory=list)
|
|
55
|
+
"""Full path of the current cleaning session (oldest point first).
|
|
56
|
+
|
|
57
|
+
The robot accumulates this server-side and serves the whole trajectory so
|
|
58
|
+
far in one packet, so it is complete even if we connect mid-session. Only
|
|
59
|
+
populated while a cleaning session is active."""
|
|
60
|
+
|
|
61
|
+
robot_position: Q10Point | None = None
|
|
62
|
+
"""Current robot position (the most recent path point), if known."""
|
|
63
|
+
|
|
64
|
+
def __repr__(self) -> str:
|
|
65
|
+
img = self.image_content
|
|
66
|
+
if img and len(img) > _TRUNCATE_LENGTH:
|
|
67
|
+
img = img[: _TRUNCATE_LENGTH - 3] + b"..."
|
|
68
|
+
return f"MapContent(image_content={img!r}, rooms={self.rooms!r})"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MapContentTrait(MapContent, TraitUpdateListener):
|
|
72
|
+
"""Trait holding the most recently pushed parsed map content for Q10 devices.
|
|
73
|
+
|
|
74
|
+
The Q10 has no synchronous get-map request; the device pushes map and trace
|
|
75
|
+
packets, which the protocol layer decodes and the ``Q10PropertiesApi``
|
|
76
|
+
subscribe loop feeds into :meth:`update_from_map_packet` /
|
|
77
|
+
:meth:`update_from_trace_packet`. Consumers read the cached fields and/or
|
|
78
|
+
register a callback with :meth:`add_update_listener` to be notified when new
|
|
79
|
+
map content arrives.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
map_parser_config: B01Q10MapParserConfig | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
super().__init__()
|
|
88
|
+
TraitUpdateListener.__init__(self, logger=_LOGGER)
|
|
89
|
+
self._map_parser = B01Q10MapParser(map_parser_config)
|
|
90
|
+
|
|
91
|
+
def update_from_map_packet(self, packet: Q10MapPacket) -> None:
|
|
92
|
+
"""Render a pushed full-map packet into the cached image/rooms.
|
|
93
|
+
|
|
94
|
+
Rendering failures are logged and skipped (listeners are not notified) so
|
|
95
|
+
a single bad push cannot tear down the subscribe loop.
|
|
96
|
+
"""
|
|
97
|
+
parsed = self._map_parser.parse_packet(packet)
|
|
98
|
+
if parsed.image_content is None:
|
|
99
|
+
_LOGGER.debug("Failed to render Q10 map image")
|
|
100
|
+
return
|
|
101
|
+
self.image_content = parsed.image_content
|
|
102
|
+
self.map_data = parsed.map_data
|
|
103
|
+
self.rooms = packet.rooms
|
|
104
|
+
self._notify_update()
|
|
105
|
+
|
|
106
|
+
def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
|
|
107
|
+
"""Cache the path/robot position from a pushed trace packet."""
|
|
108
|
+
self.path = packet.points
|
|
109
|
+
self.robot_position = packet.robot_position
|
|
110
|
+
self._notify_update()
|