python-roborock 5.19.0__tar.gz → 5.20.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-5.19.0 → python_roborock-5.20.0}/PKG-INFO +1 -1
- {python_roborock-5.19.0 → python_roborock-5.20.0}/pyproject.toml +1 -1
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/cli.py +103 -4
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/rpc/b01_q10_channel.py +16 -10
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/__init__.py +28 -9
- python_roborock-5.20.0/roborock/devices/traits/b01/q10/map.py +110 -0
- python_roborock-5.20.0/roborock/map/b01_q10_map_parser.py +399 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocols/b01_q10_protocol.py +43 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/.gitignore +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/LICENSE +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/README.md +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/broadcast_protocol.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/callbacks.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/const.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q10/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q7/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/dyad/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/dyad/dyad_containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/v1/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/v1/v1_clean_modes.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/v1/v1_code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/v1/v1_containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/zeo/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/zeo/zeo_containers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/device_features.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/README.md +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/cache.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/device.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/device_manager.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/file_cache.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/rpc/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/rpc/a01_channel.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/rpc/v1_channel.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/a01/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/command.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/status.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/map.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/common.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/traits_mixin.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/child_lock.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/command.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/common.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/consumeable.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/device_features.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/home.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/led_status.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/map_content.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/maps.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/network_info.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/rooms.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/routines.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/status.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/volume.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/transport/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/transport/channel.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/transport/local_channel.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/transport/mqtt_channel.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/diagnostics.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/exceptions.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/b01_grid_layers.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/b01_map_parser.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/b01_q10_overlays.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/map_parser.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/proto/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/proto/b01_scmap.proto +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/mqtt/health_manager.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocol.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocols/__init__.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocols/b01_q7_protocol.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocols/v1_protocol.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/py.typed +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/roborock_message.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/util.py +0 -0
- {python_roborock-5.19.0 → python_roborock-5.20.0}/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.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 = "5.
|
|
3
|
+
version = "5.20.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"
|
|
@@ -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)
|
|
@@ -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(
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/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()
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Parser for Roborock Q10 (B01/ss07) map packets.
|
|
2
|
+
|
|
3
|
+
Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message (pushed a
|
|
4
|
+
few seconds after a ``dpRequestDps`` request). Unlike the Q7 ``SCMap`` protobuf
|
|
5
|
+
format, the Q10 uses a custom, unencrypted binary packet:
|
|
6
|
+
|
|
7
|
+
- ``01 01`` marker, then a ``u32be`` map id (bytes 2-5) and two consecutive
|
|
8
|
+
``u16be`` dimensions: grid width (bytes 7-8) and grid height (bytes 9-10).
|
|
9
|
+
- A header field at offset 27 (``u16be``) giving the compressed layout length.
|
|
10
|
+
- An LZ4-block-compressed occupancy grid starting at offset 29. Once inflated it
|
|
11
|
+
is ``width * height`` cells of grid data followed by room metadata records.
|
|
12
|
+
- Room metadata begins with ``01 <room_count>`` followed by fixed 47-byte
|
|
13
|
+
records (id, hints, ascii name). Each room paints cells with value
|
|
14
|
+
``room_id * 4`` in the grid.
|
|
15
|
+
|
|
16
|
+
The packet layout was confirmed against live Q10 captures. The format
|
|
17
|
+
documentation that informed this clean-room implementation comes from the
|
|
18
|
+
``roborock-qseries-map-bridge`` project (GPL-3.0-or-later):
|
|
19
|
+
https://github.com/v1b3c0d3x3r/roborock-qseries-map-bridge
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import colorsys
|
|
23
|
+
import io
|
|
24
|
+
import math
|
|
25
|
+
import statistics
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
|
|
28
|
+
from PIL import Image
|
|
29
|
+
from vacuum_map_parser_base.config.image_config import ImageConfig
|
|
30
|
+
from vacuum_map_parser_base.map_data import ImageData, MapData
|
|
31
|
+
|
|
32
|
+
from roborock.exceptions import RoborockException
|
|
33
|
+
|
|
34
|
+
from .map_parser import ParsedMapData
|
|
35
|
+
|
|
36
|
+
_MAP_FILE_FORMAT = "PNG"
|
|
37
|
+
|
|
38
|
+
MAP_PACKET_MARKER = b"\x01\x01"
|
|
39
|
+
TRACE_PACKET_MARKER = b"\x02\x01"
|
|
40
|
+
|
|
41
|
+
_MAP_ID_OFFSET = 2
|
|
42
|
+
# Width and height are two consecutive u16be fields. An earlier revision read the
|
|
43
|
+
# width as u16le at offset 8; that high byte is actually the height's high byte,
|
|
44
|
+
# so it only matched the true width when width and height fell in the same
|
|
45
|
+
# 256-band -- e.g. a 222x261 map decoded its width as 478 and failed to split.
|
|
46
|
+
# Reported and diagnosed by @andrewlyeats (independent B01/Q10 decoder), and
|
|
47
|
+
# corroborated by the ioBroker roborock adapter (both read these as u16be).
|
|
48
|
+
_WIDTH_OFFSET = 7
|
|
49
|
+
_HEIGHT_OFFSET = 9
|
|
50
|
+
_COMPRESSED_LAYOUT_LENGTH_OFFSET = 27
|
|
51
|
+
_LAYOUT_COMPRESSED_OFFSET = 29
|
|
52
|
+
_ROOM_RECORD_LENGTH = 47
|
|
53
|
+
_ROOM_NAME_LENGTH_OFFSET = 26
|
|
54
|
+
_MAX_ROOMS = 32
|
|
55
|
+
|
|
56
|
+
# Grid cell values >= this are walls / borders rather than room segments.
|
|
57
|
+
_WALL_THRESHOLD = 240
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Q10Room:
|
|
62
|
+
"""A room (segment) described in a Q10 map packet."""
|
|
63
|
+
|
|
64
|
+
id: int
|
|
65
|
+
raw_name: str
|
|
66
|
+
pixel_value: int
|
|
67
|
+
pixel_count: int
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def name(self) -> str:
|
|
71
|
+
"""User friendly room name (firmware ``rr_`` defaults are normalized)."""
|
|
72
|
+
return self.raw_name.removeprefix("rr_").replace("_", " ").strip().title()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Q10MapPacket:
|
|
77
|
+
"""Decoded contents of a Q10 ``01 01`` map packet."""
|
|
78
|
+
|
|
79
|
+
map_id: int
|
|
80
|
+
width: int
|
|
81
|
+
height: int
|
|
82
|
+
grid: bytes
|
|
83
|
+
rooms: list[Q10Room] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Q10Point:
|
|
88
|
+
"""A single point in Q10 map/trace coordinate space."""
|
|
89
|
+
|
|
90
|
+
x: int
|
|
91
|
+
y: int
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class Q10TracePacket:
|
|
96
|
+
"""Decoded contents of a Q10 ``02 01`` cleaning-path packet.
|
|
97
|
+
|
|
98
|
+
The robot accumulates the **full path of the current cleaning session** and
|
|
99
|
+
serves it in a single packet: ``points`` holds the whole trajectory so far
|
|
100
|
+
(oldest first), growing as the robot cleans. This was confirmed live -- a
|
|
101
|
+
corridor run produced packets of 1, then 3, then 15 points, each a strict
|
|
102
|
+
superset describing the path travelled. Because the robot keeps the path
|
|
103
|
+
server-side, a client that connects mid-session still receives the complete
|
|
104
|
+
path (this is how the app shows the trail even after a cold launch).
|
|
105
|
+
|
|
106
|
+
The robot only emits these while a session is active, so an idle/docked robot
|
|
107
|
+
will not produce them. The most recent point is the current robot position.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
points: list[Q10Point] = field(default_factory=list)
|
|
111
|
+
sequence: int = 0
|
|
112
|
+
"""Session counter (byte 3); increments per cleaning session, tracking the
|
|
113
|
+
device clean count. Not a per-packet sequence."""
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def robot_position(self) -> Q10Point | None:
|
|
117
|
+
"""The current robot position (the most recent point)."""
|
|
118
|
+
return self.points[-1] if self.points else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Trace packet (``02 01``): a 10-byte header followed by big-endian int16 (x, y)
|
|
122
|
+
# point pairs forming the accumulated session path. Header layout confirmed
|
|
123
|
+
# against live ss07 captures: byte 3 is a session counter (tracks the device
|
|
124
|
+
# clean count); bytes 8-9 are a u16be point count minus one (verified: a 15-point
|
|
125
|
+
# packet carried 0x000e == 14). The parser reads all 4-byte pairs in the body
|
|
126
|
+
# rather than trusting the count field, so a truncated tail can't desync it.
|
|
127
|
+
# NOTE: the format documented by roborock-qseries-map-bridge (18-byte header)
|
|
128
|
+
# did not match this firmware -- this 10-byte layout is what the device sent.
|
|
129
|
+
_TRACE_HEADER_LENGTH = 10
|
|
130
|
+
_TRACE_SEQUENCE_OFFSET = 3
|
|
131
|
+
|
|
132
|
+
# Some cleans prepend a single stray point to the path, far outside the map
|
|
133
|
+
# (e.g. ~(0, -1907) when the real path starts near (-3760, -1920)); it skews the
|
|
134
|
+
# rendered start/bounding box and any path-based calibration. We drop points[0]
|
|
135
|
+
# only when its step to points[1] is a gross outlier (this multiple of the
|
|
136
|
+
# median step of the rest of the path), so a genuine first point is never lost.
|
|
137
|
+
# The current position (last point) is unaffected. Trigger and threshold
|
|
138
|
+
# reported and verified by @andrewlyeats across independent B01/Q10 captures.
|
|
139
|
+
_STRAY_POINT_STEP_RATIO = 20
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def is_map_packet(payload: bytes) -> bool:
|
|
143
|
+
"""Return True if the payload is a Q10 full-map (``01 01``) packet."""
|
|
144
|
+
return payload[:2] == MAP_PACKET_MARKER
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_trace_packet(payload: bytes) -> bool:
|
|
148
|
+
"""Return True if the payload is a Q10 live trace (``02 01``) packet."""
|
|
149
|
+
return payload[:2] == TRACE_PACKET_MARKER
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_trace_packet(payload: bytes) -> Q10TracePacket:
|
|
153
|
+
"""Parse a Q10 ``02 01`` trace packet into path points + robot position."""
|
|
154
|
+
if not is_trace_packet(payload):
|
|
155
|
+
raise RoborockException("Payload is not a Q10 trace packet")
|
|
156
|
+
if len(payload) < _TRACE_HEADER_LENGTH:
|
|
157
|
+
raise RoborockException("Q10 trace packet is shorter than its header")
|
|
158
|
+
body = payload[_TRACE_HEADER_LENGTH:]
|
|
159
|
+
if len(body) % 4:
|
|
160
|
+
raise RoborockException("Q10 trace points are not 4-byte (x, y) pairs")
|
|
161
|
+
|
|
162
|
+
points = [
|
|
163
|
+
Q10Point(
|
|
164
|
+
x=int.from_bytes(body[offset : offset + 2], "big", signed=True),
|
|
165
|
+
y=int.from_bytes(body[offset + 2 : offset + 4], "big", signed=True),
|
|
166
|
+
)
|
|
167
|
+
for offset in range(0, len(body), 4)
|
|
168
|
+
]
|
|
169
|
+
points = _drop_stray_leading_point(points)
|
|
170
|
+
return Q10TracePacket(points=points, sequence=payload[_TRACE_SEQUENCE_OFFSET])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _drop_stray_leading_point(points: list[Q10Point]) -> list[Q10Point]:
|
|
174
|
+
"""Drop a spurious leading point that some cleans prepend to the trace.
|
|
175
|
+
|
|
176
|
+
Returns ``points`` unchanged unless the very first step is a gross outlier
|
|
177
|
+
versus the median of the remaining steps (see ``_STRAY_POINT_STEP_RATIO``),
|
|
178
|
+
in which case the first point is dropped. Needs at least three points to have
|
|
179
|
+
a stable median to compare against.
|
|
180
|
+
"""
|
|
181
|
+
if len(points) < 3:
|
|
182
|
+
return points
|
|
183
|
+
steps = [math.hypot(b.x - a.x, b.y - a.y) for a, b in zip(points, points[1:])]
|
|
184
|
+
median_rest = statistics.median(steps[1:])
|
|
185
|
+
if median_rest > 0 and steps[0] > _STRAY_POINT_STEP_RATIO * median_rest:
|
|
186
|
+
return points[1:]
|
|
187
|
+
return points
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def lz4_block_decompress(data: bytes) -> bytes:
|
|
191
|
+
"""Decompress a raw LZ4 *block* (no frame header).
|
|
192
|
+
|
|
193
|
+
The Q10 map grid is stored as a single LZ4 block. This implements the
|
|
194
|
+
standard LZ4 block format so we don't add a native dependency.
|
|
195
|
+
"""
|
|
196
|
+
index = 0
|
|
197
|
+
output = bytearray()
|
|
198
|
+
|
|
199
|
+
def read_length(value: int) -> int:
|
|
200
|
+
nonlocal index
|
|
201
|
+
if value != 0x0F:
|
|
202
|
+
return value
|
|
203
|
+
while True:
|
|
204
|
+
if index >= len(data):
|
|
205
|
+
raise RoborockException("Truncated LZ4 block while reading length")
|
|
206
|
+
part = data[index]
|
|
207
|
+
index += 1
|
|
208
|
+
value += part
|
|
209
|
+
if part != 0xFF:
|
|
210
|
+
return value
|
|
211
|
+
|
|
212
|
+
while True:
|
|
213
|
+
if index >= len(data):
|
|
214
|
+
raise RoborockException("Truncated LZ4 block while reading token")
|
|
215
|
+
token = data[index]
|
|
216
|
+
index += 1
|
|
217
|
+
|
|
218
|
+
literal_length = read_length((token >> 4) & 0x0F)
|
|
219
|
+
end = index + literal_length
|
|
220
|
+
if end > len(data):
|
|
221
|
+
raise RoborockException("Truncated LZ4 block while reading literals")
|
|
222
|
+
output.extend(data[index:end])
|
|
223
|
+
index = end
|
|
224
|
+
|
|
225
|
+
if index == len(data):
|
|
226
|
+
return bytes(output)
|
|
227
|
+
if index + 2 > len(data):
|
|
228
|
+
raise RoborockException("Truncated LZ4 block while reading match offset")
|
|
229
|
+
|
|
230
|
+
offset = data[index] | (data[index + 1] << 8)
|
|
231
|
+
index += 2
|
|
232
|
+
if offset == 0 or offset > len(output):
|
|
233
|
+
raise RoborockException("Invalid LZ4 back-reference offset")
|
|
234
|
+
|
|
235
|
+
match_length = read_length(token & 0x0F) + 4
|
|
236
|
+
for _ in range(match_length):
|
|
237
|
+
output.append(output[-offset])
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _split_with_dims(decoded: bytes, width: int, height: int) -> tuple[bytes, bytes] | None:
|
|
241
|
+
"""Split the inflated layout into (grid, room_data) using header dimensions.
|
|
242
|
+
|
|
243
|
+
Returns ``None`` when ``width * height`` does not leave a well-formed
|
|
244
|
+
``01 <room_count>`` room-record section, so the caller can fall back to
|
|
245
|
+
brute-force inference (e.g. for captures/fixtures without a height field).
|
|
246
|
+
"""
|
|
247
|
+
area = width * height
|
|
248
|
+
if area <= 0 or area > len(decoded):
|
|
249
|
+
return None
|
|
250
|
+
room_data = decoded[area:]
|
|
251
|
+
if len(room_data) < 2 or room_data[0] != 1:
|
|
252
|
+
return None
|
|
253
|
+
if len(room_data) != 2 + room_data[1] * _ROOM_RECORD_LENGTH:
|
|
254
|
+
return None
|
|
255
|
+
return decoded[:area], room_data
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _infer_layout(decoded: bytes, width: int) -> tuple[int, bytes, bytes]:
|
|
259
|
+
"""Split the inflated layout into (height, grid, room_data).
|
|
260
|
+
|
|
261
|
+
The grid is ``width * height`` cells; the remaining bytes are room records
|
|
262
|
+
introduced by an ``01 <room_count>`` marker. The room count is unknown up
|
|
263
|
+
front, so we search for the split that makes the grid rectangular and lines
|
|
264
|
+
up with the marker. Used as a fallback when the header carries no usable
|
|
265
|
+
height.
|
|
266
|
+
"""
|
|
267
|
+
for room_count in range(0, _MAX_ROOMS + 1):
|
|
268
|
+
room_data_length = 2 + room_count * _ROOM_RECORD_LENGTH
|
|
269
|
+
area = len(decoded) - room_data_length
|
|
270
|
+
if area <= 0 or area % width:
|
|
271
|
+
continue
|
|
272
|
+
room_data = decoded[area:]
|
|
273
|
+
if room_data[0] == 1 and room_data[1] == room_count:
|
|
274
|
+
return area // width, decoded[:area], room_data
|
|
275
|
+
raise RoborockException("Could not infer Q10 layout dimensions / room records")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _parse_rooms(room_data: bytes, grid: bytes) -> list[Q10Room]:
|
|
279
|
+
rooms: list[Q10Room] = []
|
|
280
|
+
room_count = room_data[1]
|
|
281
|
+
for index in range(room_count):
|
|
282
|
+
start = 2 + index * _ROOM_RECORD_LENGTH
|
|
283
|
+
record = room_data[start : start + _ROOM_RECORD_LENGTH]
|
|
284
|
+
room_id = int.from_bytes(record[0:2], "big")
|
|
285
|
+
name_length = record[_ROOM_NAME_LENGTH_OFFSET]
|
|
286
|
+
raw_name = record[27 : 27 + name_length].decode("utf-8", errors="replace")
|
|
287
|
+
pixel_value = (room_id * 4) & 0xFF
|
|
288
|
+
rooms.append(
|
|
289
|
+
Q10Room(
|
|
290
|
+
id=room_id,
|
|
291
|
+
raw_name=raw_name,
|
|
292
|
+
pixel_value=pixel_value,
|
|
293
|
+
pixel_count=grid.count(pixel_value),
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
return rooms
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def parse_map_packet(payload: bytes) -> Q10MapPacket:
|
|
300
|
+
"""Parse a Q10 ``01 01`` map packet into grid + room metadata."""
|
|
301
|
+
if len(payload) < _LAYOUT_COMPRESSED_OFFSET or not is_map_packet(payload):
|
|
302
|
+
raise RoborockException("Payload is not a Q10 map packet")
|
|
303
|
+
|
|
304
|
+
map_id = int.from_bytes(payload[_MAP_ID_OFFSET : _MAP_ID_OFFSET + 4], "big")
|
|
305
|
+
width = int.from_bytes(payload[_WIDTH_OFFSET : _WIDTH_OFFSET + 2], "big")
|
|
306
|
+
height = int.from_bytes(payload[_HEIGHT_OFFSET : _HEIGHT_OFFSET + 2], "big")
|
|
307
|
+
if width <= 0:
|
|
308
|
+
raise RoborockException("Q10 map packet has invalid width")
|
|
309
|
+
|
|
310
|
+
compressed_length = int.from_bytes(
|
|
311
|
+
payload[_COMPRESSED_LAYOUT_LENGTH_OFFSET : _COMPRESSED_LAYOUT_LENGTH_OFFSET + 2], "big"
|
|
312
|
+
)
|
|
313
|
+
layout_end = _LAYOUT_COMPRESSED_OFFSET + compressed_length
|
|
314
|
+
if compressed_length <= 0 or layout_end > len(payload):
|
|
315
|
+
raise RoborockException("Q10 map packet has invalid layout block length")
|
|
316
|
+
|
|
317
|
+
decoded = lz4_block_decompress(payload[_LAYOUT_COMPRESSED_OFFSET:layout_end])
|
|
318
|
+
# Prefer the header height; fall back to inference if it doesn't line up
|
|
319
|
+
# (e.g. older captures/fixtures that don't populate the height field).
|
|
320
|
+
split = _split_with_dims(decoded, width, height) if height > 0 else None
|
|
321
|
+
if split is not None:
|
|
322
|
+
grid, room_data = split
|
|
323
|
+
else:
|
|
324
|
+
height, grid, room_data = _infer_layout(decoded, width)
|
|
325
|
+
rooms = _parse_rooms(room_data, grid)
|
|
326
|
+
return Q10MapPacket(map_id=map_id, width=width, height=height, grid=grid, rooms=rooms)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@dataclass
|
|
330
|
+
class B01Q10MapParserConfig:
|
|
331
|
+
"""Configuration for the Q10 map parser."""
|
|
332
|
+
|
|
333
|
+
map_scale: int = 4
|
|
334
|
+
"""Scale factor for the rendered map image."""
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class B01Q10MapParser:
|
|
338
|
+
"""Decoder/renderer for Q10 ``MAP_RESPONSE`` (protocol 301) payloads."""
|
|
339
|
+
|
|
340
|
+
def __init__(self, config: B01Q10MapParserConfig | None = None) -> None:
|
|
341
|
+
self._config = config or B01Q10MapParserConfig()
|
|
342
|
+
|
|
343
|
+
def parse(self, payload: bytes) -> ParsedMapData:
|
|
344
|
+
"""Parse a raw Q10 map packet into a rendered PNG + ``MapData``."""
|
|
345
|
+
return self.parse_packet(parse_map_packet(payload))
|
|
346
|
+
|
|
347
|
+
def parse_packet(self, packet: Q10MapPacket) -> ParsedMapData:
|
|
348
|
+
"""Render an already-parsed Q10 map packet into a PNG + ``MapData``.
|
|
349
|
+
|
|
350
|
+
The protocol layer parses the wire bytes into a :class:`Q10MapPacket`;
|
|
351
|
+
this renders that packet without re-parsing it.
|
|
352
|
+
"""
|
|
353
|
+
image = self._render(packet)
|
|
354
|
+
|
|
355
|
+
map_data = MapData()
|
|
356
|
+
map_data.image = ImageData(
|
|
357
|
+
size=packet.width * packet.height,
|
|
358
|
+
top=0,
|
|
359
|
+
left=0,
|
|
360
|
+
height=packet.height,
|
|
361
|
+
width=packet.width,
|
|
362
|
+
image_config=ImageConfig(scale=self._config.map_scale),
|
|
363
|
+
data=image,
|
|
364
|
+
img_transformation=lambda p: p,
|
|
365
|
+
)
|
|
366
|
+
room_names = {room.id: room.name for room in packet.rooms}
|
|
367
|
+
if room_names:
|
|
368
|
+
map_data.additional_parameters["room_names"] = room_names
|
|
369
|
+
|
|
370
|
+
image_bytes = io.BytesIO()
|
|
371
|
+
image.save(image_bytes, format=_MAP_FILE_FORMAT)
|
|
372
|
+
return ParsedMapData(image_content=image_bytes.getvalue(), map_data=map_data)
|
|
373
|
+
|
|
374
|
+
def _render(self, packet: Q10MapPacket) -> Image.Image:
|
|
375
|
+
"""Render the Q10 grid: rooms get distinct colors, walls white, rest dark."""
|
|
376
|
+
palette = _build_palette(packet.grid)
|
|
377
|
+
rgb = bytearray()
|
|
378
|
+
for value in packet.grid:
|
|
379
|
+
rgb.extend(palette[value])
|
|
380
|
+
img = Image.frombytes("RGB", (packet.width, packet.height), bytes(rgb))
|
|
381
|
+
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
|
|
382
|
+
scale = self._config.map_scale
|
|
383
|
+
if scale > 1:
|
|
384
|
+
img = img.resize((packet.width * scale, packet.height * scale), resample=Image.Resampling.NEAREST)
|
|
385
|
+
return img
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _build_palette(grid: bytes) -> list[tuple[int, int, int]]:
|
|
389
|
+
"""Map each grid value to an RGB color (rooms distinct, walls white)."""
|
|
390
|
+
palette: list[tuple[int, int, int]] = [(28, 30, 38)] * 256 # default: unknown/outside
|
|
391
|
+
room_values = sorted({v for v in set(grid) if 0 < v < _WALL_THRESHOLD})
|
|
392
|
+
for index, value in enumerate(room_values):
|
|
393
|
+
hue = (index * 0.139) % 1.0
|
|
394
|
+
r, g, b = colorsys.hsv_to_rgb(hue, 0.5, 0.95)
|
|
395
|
+
palette[value] = (int(r * 255), int(g * 255), int(b * 255))
|
|
396
|
+
for value in range(_WALL_THRESHOLD, 256):
|
|
397
|
+
palette[value] = (235, 235, 240) # walls / borders
|
|
398
|
+
palette[0] = (28, 30, 38)
|
|
399
|
+
return palette
|
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
|
|
8
9
|
from roborock.exceptions import RoborockException
|
|
10
|
+
from roborock.map.b01_q10_map_parser import (
|
|
11
|
+
Q10MapPacket,
|
|
12
|
+
Q10TracePacket,
|
|
13
|
+
is_map_packet,
|
|
14
|
+
is_trace_packet,
|
|
15
|
+
parse_map_packet,
|
|
16
|
+
parse_trace_packet,
|
|
17
|
+
)
|
|
9
18
|
from roborock.roborock_message import (
|
|
10
19
|
RoborockMessage,
|
|
11
20
|
RoborockMessageProtocol,
|
|
@@ -85,3 +94,37 @@ def decode_rpc_response(message: RoborockMessage) -> dict[B01_Q10_DP, Any]:
|
|
|
85
94
|
result.update(common_dps_result)
|
|
86
95
|
|
|
87
96
|
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Q10DpsUpdate:
|
|
101
|
+
"""A decoded Q10 DPS status update pushed by the device."""
|
|
102
|
+
|
|
103
|
+
dps: dict[B01_Q10_DP, Any]
|
|
104
|
+
"""Data points keyed by ``B01_Q10_DP`` code."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# A single decoded message from a Q10 device: a DPS status update, a full map
|
|
108
|
+
# packet, or a live cleaning-path (trace) packet. Map/trace packets arrive as
|
|
109
|
+
# protocol-301 ``MAP_RESPONSE`` pushes; everything else is a DPS update.
|
|
110
|
+
Q10Message = Q10DpsUpdate | Q10MapPacket | Q10TracePacket
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def decode_message(message: RoborockMessage) -> Q10Message | None:
|
|
114
|
+
"""Decode a pushed Q10 ``RoborockMessage`` into a typed message.
|
|
115
|
+
|
|
116
|
+
``MAP_RESPONSE`` (protocol 301) payloads carry the binary map (``01 01``) or
|
|
117
|
+
trace (``02 01``) packets, which are parsed by the map parser; any other
|
|
118
|
+
``MAP_RESPONSE`` marker is unrecognized and yields ``None``. Every other
|
|
119
|
+
protocol is treated as a DPS status update.
|
|
120
|
+
|
|
121
|
+
Raises ``RoborockException`` if a recognized payload fails to parse.
|
|
122
|
+
"""
|
|
123
|
+
if message.protocol == RoborockMessageProtocol.MAP_RESPONSE:
|
|
124
|
+
payload = message.payload or b""
|
|
125
|
+
if is_map_packet(payload):
|
|
126
|
+
return parse_map_packet(payload)
|
|
127
|
+
if is_trace_packet(payload):
|
|
128
|
+
return parse_trace_packet(payload)
|
|
129
|
+
return None
|
|
130
|
+
return Q10DpsUpdate(dps=decode_rpc_response(message))
|
|
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-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q10/b01_q10_code_mappings.py
RENAMED
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q10/b01_q10_containers.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.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
|
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/command.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/__init__.py
RENAMED
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/clean_summary.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.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
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/clean_summary.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/device_features.py
RENAMED
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/do_not_disturb.py
RENAMED
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/dust_collection_mode.py
RENAMED
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.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
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/network_info.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/smart_wash_params.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/wash_towel_mode.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/transport/local_channel.py
RENAMED
|
File without changes
|
{python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/transport/mqtt_channel.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
|