python-roborock 5.18.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.18.0 → python_roborock-5.20.0}/PKG-INFO +1 -1
  2. {python_roborock-5.18.0 → python_roborock-5.20.0}/pyproject.toml +1 -1
  3. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/cli.py +103 -4
  4. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/rpc/b01_q10_channel.py +16 -10
  5. {python_roborock-5.18.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.18.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/__init__.py +23 -0
  8. python_roborock-5.20.0/roborock/map/b01_q10_map_parser.py +399 -0
  9. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/protocols/b01_q10_protocol.py +43 -0
  10. {python_roborock-5.18.0 → python_roborock-5.20.0}/.gitignore +0 -0
  11. {python_roborock-5.18.0 → python_roborock-5.20.0}/LICENSE +0 -0
  12. {python_roborock-5.18.0 → python_roborock-5.20.0}/README.md +0 -0
  13. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/__init__.py +0 -0
  14. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/broadcast_protocol.py +0 -0
  15. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/callbacks.py +0 -0
  16. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/const.py +0 -0
  17. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/__init__.py +0 -0
  18. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/b01_q10/__init__.py +0 -0
  19. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  20. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  21. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/b01_q7/__init__.py +0 -0
  22. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  23. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  24. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/code_mappings.py +0 -0
  25. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/containers.py +0 -0
  26. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/dyad/__init__.py +0 -0
  27. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  28. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/dyad/dyad_containers.py +0 -0
  29. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/v1/__init__.py +0 -0
  30. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  31. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  32. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/v1/v1_containers.py +0 -0
  33. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/zeo/__init__.py +0 -0
  34. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  35. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/data/zeo/zeo_containers.py +0 -0
  36. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/device_features.py +0 -0
  37. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/README.md +0 -0
  38. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/__init__.py +0 -0
  39. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/cache.py +0 -0
  40. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/device.py +0 -0
  41. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/device_manager.py +0 -0
  42. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/file_cache.py +0 -0
  43. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/rpc/__init__.py +0 -0
  44. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/rpc/a01_channel.py +0 -0
  45. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  46. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/rpc/v1_channel.py +0 -0
  47. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/__init__.py +0 -0
  48. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/a01/__init__.py +0 -0
  49. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/__init__.py +0 -0
  50. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  51. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
  52. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  53. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  54. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  55. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  56. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  57. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/common.py +0 -0
  58. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/traits_mixin.py +0 -0
  59. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/__init__.py +0 -0
  60. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  61. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  62. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/command.py +0 -0
  63. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/common.py +0 -0
  64. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  65. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/device_features.py +0 -0
  66. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  67. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  68. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  69. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/home.py +0 -0
  70. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/led_status.py +0 -0
  71. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/map_content.py +0 -0
  72. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/maps.py +0 -0
  73. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/network_info.py +0 -0
  74. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/rooms.py +0 -0
  75. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/routines.py +0 -0
  76. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  77. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/status.py +0 -0
  78. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  79. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/volume.py +0 -0
  80. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  81. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/transport/__init__.py +0 -0
  82. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/transport/channel.py +0 -0
  83. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/transport/local_channel.py +0 -0
  84. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  85. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/diagnostics.py +0 -0
  86. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/exceptions.py +0 -0
  87. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/map/__init__.py +0 -0
  88. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/map/b01_grid_layers.py +0 -0
  89. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/map/b01_map_parser.py +0 -0
  90. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/map/b01_q10_overlays.py +0 -0
  91. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/map/map_parser.py +0 -0
  92. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/map/proto/__init__.py +0 -0
  93. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/map/proto/b01_scmap.proto +0 -0
  94. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  95. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/mqtt/__init__.py +0 -0
  96. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/mqtt/health_manager.py +0 -0
  97. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/mqtt/roborock_session.py +0 -0
  98. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/mqtt/session.py +0 -0
  99. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/protocol.py +0 -0
  100. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/protocols/__init__.py +0 -0
  101. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/protocols/a01_protocol.py +0 -0
  102. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  103. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/protocols/v1_protocol.py +0 -0
  104. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/py.typed +0 -0
  105. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/roborock_message.py +0 -0
  106. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/roborock_typing.py +0 -0
  107. {python_roborock-5.18.0 → python_roborock-5.20.0}/roborock/util.py +0 -0
  108. {python_roborock-5.18.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.18.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.18.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()
@@ -116,6 +116,29 @@ class Q7PropertiesApi(Trait):
116
116
  """Enable or disable the child lock."""
117
117
  await self.set_prop(RoborockB01Props.CHILD_LOCK, int(enabled))
118
118
 
119
+ async def set_do_not_disturb(self, enabled: bool, begin_time: int, end_time: int) -> None:
120
+ """Configure do-not-disturb.
121
+
122
+ The device expects all three values together via ``service.set_quiet_time``
123
+ (individual ``prop.set`` calls are ignored). ``begin_time``/``end_time`` are
124
+ minutes since midnight and must be in the range 0-1439 (inclusive).
125
+
126
+ Ranges that cross midnight are supported by passing a ``begin_time`` that is
127
+ greater than ``end_time`` (e.g. 22:00-07:00 is ``begin_time=1320``,
128
+ ``end_time=420``).
129
+ """
130
+ for name, value in (("begin_time", begin_time), ("end_time", end_time)):
131
+ if not 0 <= value <= 1439:
132
+ raise ValueError(f"{name} must be between 0 and 1439 minutes since midnight, got {value}")
133
+ await self.send(
134
+ RoborockB01Q7Methods.SET_QUIET_TIME,
135
+ {
136
+ "is_open": int(enabled),
137
+ "quiet_begin_time": begin_time,
138
+ "quiet_end_time": end_time,
139
+ },
140
+ )
141
+
119
142
  async def start_clean(self) -> None:
120
143
  """Start cleaning."""
121
144
  await self.send(