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.
Files changed (108) hide show
  1. {python_roborock-5.19.0 → python_roborock-5.20.0}/PKG-INFO +1 -1
  2. {python_roborock-5.19.0 → python_roborock-5.20.0}/pyproject.toml +1 -1
  3. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/cli.py +103 -4
  4. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/rpc/b01_q10_channel.py +16 -10
  5. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/__init__.py +28 -9
  6. python_roborock-5.20.0/roborock/devices/traits/b01/q10/map.py +110 -0
  7. python_roborock-5.20.0/roborock/map/b01_q10_map_parser.py +399 -0
  8. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocols/b01_q10_protocol.py +43 -0
  9. {python_roborock-5.19.0 → python_roborock-5.20.0}/.gitignore +0 -0
  10. {python_roborock-5.19.0 → python_roborock-5.20.0}/LICENSE +0 -0
  11. {python_roborock-5.19.0 → python_roborock-5.20.0}/README.md +0 -0
  12. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/__init__.py +0 -0
  13. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/broadcast_protocol.py +0 -0
  14. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/callbacks.py +0 -0
  15. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/const.py +0 -0
  16. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/__init__.py +0 -0
  17. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q10/__init__.py +0 -0
  18. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  19. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  20. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q7/__init__.py +0 -0
  21. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  22. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  23. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/code_mappings.py +0 -0
  24. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/containers.py +0 -0
  25. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/dyad/__init__.py +0 -0
  26. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  27. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/dyad/dyad_containers.py +0 -0
  28. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/v1/__init__.py +0 -0
  29. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  30. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  31. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/v1/v1_containers.py +0 -0
  32. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/zeo/__init__.py +0 -0
  33. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  34. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/data/zeo/zeo_containers.py +0 -0
  35. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/device_features.py +0 -0
  36. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/README.md +0 -0
  37. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/__init__.py +0 -0
  38. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/cache.py +0 -0
  39. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/device.py +0 -0
  40. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/device_manager.py +0 -0
  41. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/file_cache.py +0 -0
  42. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/rpc/__init__.py +0 -0
  43. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/rpc/a01_channel.py +0 -0
  44. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  45. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/rpc/v1_channel.py +0 -0
  46. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/__init__.py +0 -0
  47. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/a01/__init__.py +0 -0
  48. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/__init__.py +0 -0
  49. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  50. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
  51. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  52. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  53. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  54. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  55. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  56. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  57. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/common.py +0 -0
  58. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/traits_mixin.py +0 -0
  59. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/__init__.py +0 -0
  60. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  61. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  62. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/command.py +0 -0
  63. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/common.py +0 -0
  64. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  65. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/device_features.py +0 -0
  66. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  67. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  68. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  69. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/home.py +0 -0
  70. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/led_status.py +0 -0
  71. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/map_content.py +0 -0
  72. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/maps.py +0 -0
  73. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/network_info.py +0 -0
  74. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/rooms.py +0 -0
  75. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/routines.py +0 -0
  76. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  77. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/status.py +0 -0
  78. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  79. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/volume.py +0 -0
  80. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  81. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/transport/__init__.py +0 -0
  82. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/transport/channel.py +0 -0
  83. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/transport/local_channel.py +0 -0
  84. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  85. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/diagnostics.py +0 -0
  86. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/exceptions.py +0 -0
  87. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/__init__.py +0 -0
  88. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/b01_grid_layers.py +0 -0
  89. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/b01_map_parser.py +0 -0
  90. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/b01_q10_overlays.py +0 -0
  91. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/map_parser.py +0 -0
  92. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/proto/__init__.py +0 -0
  93. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/proto/b01_scmap.proto +0 -0
  94. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  95. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/mqtt/__init__.py +0 -0
  96. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/mqtt/health_manager.py +0 -0
  97. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/mqtt/roborock_session.py +0 -0
  98. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/mqtt/session.py +0 -0
  99. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocol.py +0 -0
  100. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocols/__init__.py +0 -0
  101. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocols/a01_protocol.py +0 -0
  102. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  103. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/protocols/v1_protocol.py +0 -0
  104. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/py.typed +0 -0
  105. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/roborock_message.py +0 -0
  106. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/roborock_typing.py +0 -0
  107. {python_roborock-5.19.0 → python_roborock-5.20.0}/roborock/util.py +0 -0
  108. {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.19.0
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.19.0"
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
- trait: MapContentTrait = await _v1_trait(context, device_id, lambda v1: v1.map_content)
539
- if trait.image_content:
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(trait.image_content)
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 _display_v1_trait(context, device_id, lambda v1: v1.rooms)
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
- decode_rpc_response,
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 stream_decoded_responses(
19
+ async def stream_decoded_messages(
20
20
  mqtt_channel: MqttChannel,
21
- ) -> AsyncGenerator[dict[B01_Q10_DP, Any], None]:
22
- """Stream decoded DPS messages received via MQTT."""
21
+ ) -> AsyncGenerator[Q10Message, None]:
22
+ """Stream decoded Q10 messages received via MQTT.
23
23
 
24
- async for response_message in mqtt_channel.subscribe_stream():
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
- decoded_dps = decode_rpc_response(response_message)
31
+ decoded = decode_message(message)
27
32
  except RoborockException as ex:
28
33
  _LOGGER.debug(
29
- "Failed to decode B01 Q10 RPC response: %s: %s",
30
- response_message,
34
+ "Failed to decode B01 Q10 message: %s: %s",
35
+ message,
31
36
  ex,
32
37
  )
33
38
  continue
34
- yield decoded_dps
39
+ if decoded is not None:
40
+ yield decoded
35
41
 
36
42
 
37
43
  async def send_command(
@@ -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 stream_decoded_responses
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 listen for status updates."""
69
- async for decoded_dps in stream_decoded_responses(self._channel):
70
- _LOGGER.debug("Received Q10 status update: %s", decoded_dps)
71
-
72
- # Notify all traits about a new message and each trait will
73
- # only update what fields that it is responsible for.
74
- # More traits can be added here below.
75
- self.status.update_from_dps(decoded_dps)
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))