python-roborock 5.19.0__tar.gz → 5.20.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {python_roborock-5.19.0 → python_roborock-5.20.1}/PKG-INFO +1 -1
  2. {python_roborock-5.19.0 → python_roborock-5.20.1}/pyproject.toml +1 -1
  3. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/cli.py +103 -4
  4. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/device.py +10 -0
  5. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/rpc/b01_q10_channel.py +16 -10
  6. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/rpc/v1_channel.py +67 -37
  7. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q10/__init__.py +28 -9
  8. python_roborock-5.20.1/roborock/devices/traits/b01/q10/map.py +110 -0
  9. python_roborock-5.20.1/roborock/map/b01_q10_map_parser.py +399 -0
  10. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocols/b01_q10_protocol.py +43 -0
  11. {python_roborock-5.19.0 → python_roborock-5.20.1}/.gitignore +0 -0
  12. {python_roborock-5.19.0 → python_roborock-5.20.1}/LICENSE +0 -0
  13. {python_roborock-5.19.0 → python_roborock-5.20.1}/README.md +0 -0
  14. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/__init__.py +0 -0
  15. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/broadcast_protocol.py +0 -0
  16. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/callbacks.py +0 -0
  17. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/const.py +0 -0
  18. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/__init__.py +0 -0
  19. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q10/__init__.py +0 -0
  20. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  21. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  22. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q7/__init__.py +0 -0
  23. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  24. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  25. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/code_mappings.py +0 -0
  26. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/containers.py +0 -0
  27. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/dyad/__init__.py +0 -0
  28. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  29. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/dyad/dyad_containers.py +0 -0
  30. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/v1/__init__.py +0 -0
  31. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/v1/v1_clean_modes.py +0 -0
  32. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/v1/v1_code_mappings.py +0 -0
  33. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/v1/v1_containers.py +0 -0
  34. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/zeo/__init__.py +0 -0
  35. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  36. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/data/zeo/zeo_containers.py +0 -0
  37. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/device_features.py +0 -0
  38. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/README.md +0 -0
  39. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/__init__.py +0 -0
  40. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/cache.py +0 -0
  41. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/device_manager.py +0 -0
  42. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/file_cache.py +0 -0
  43. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/rpc/__init__.py +0 -0
  44. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/rpc/a01_channel.py +0 -0
  45. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  46. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/__init__.py +0 -0
  47. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/a01/__init__.py +0 -0
  48. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/__init__.py +0 -0
  49. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q10/command.py +0 -0
  50. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q10/remote.py +0 -0
  51. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q10/status.py +0 -0
  52. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  53. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  54. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  55. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q7/map.py +0 -0
  56. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  57. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/common.py +0 -0
  58. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/traits_mixin.py +0 -0
  59. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/__init__.py +0 -0
  60. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/child_lock.py +0 -0
  61. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/clean_summary.py +0 -0
  62. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/command.py +0 -0
  63. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/common.py +0 -0
  64. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/consumeable.py +0 -0
  65. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/device_features.py +0 -0
  66. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  67. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  68. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  69. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/home.py +0 -0
  70. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/led_status.py +0 -0
  71. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/map_content.py +0 -0
  72. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/maps.py +0 -0
  73. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/network_info.py +0 -0
  74. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/rooms.py +0 -0
  75. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/routines.py +0 -0
  76. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  77. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/status.py +0 -0
  78. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  79. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/volume.py +0 -0
  80. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  81. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/transport/__init__.py +0 -0
  82. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/transport/channel.py +0 -0
  83. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/transport/local_channel.py +0 -0
  84. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/devices/transport/mqtt_channel.py +0 -0
  85. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/diagnostics.py +0 -0
  86. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/exceptions.py +0 -0
  87. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/__init__.py +0 -0
  88. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/b01_grid_layers.py +0 -0
  89. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/b01_map_parser.py +0 -0
  90. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/b01_q10_overlays.py +0 -0
  91. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/map_parser.py +0 -0
  92. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/proto/__init__.py +0 -0
  93. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/proto/b01_scmap.proto +0 -0
  94. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  95. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/mqtt/__init__.py +0 -0
  96. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/mqtt/health_manager.py +0 -0
  97. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/mqtt/roborock_session.py +0 -0
  98. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/mqtt/session.py +0 -0
  99. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocol.py +0 -0
  100. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocols/__init__.py +0 -0
  101. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocols/a01_protocol.py +0 -0
  102. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocols/b01_q7_protocol.py +0 -0
  103. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/protocols/v1_protocol.py +0 -0
  104. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/py.typed +0 -0
  105. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/roborock_message.py +0 -0
  106. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/roborock_typing.py +0 -0
  107. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/util.py +0 -0
  108. {python_roborock-5.19.0 → python_roborock-5.20.1}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 5.19.0
3
+ Version: 5.20.1
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/python-roborock/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-roborock"
3
- version = "5.19.0"
3
+ version = "5.20.1"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
6
6
  requires-python = ">=3.11, <4"
@@ -56,6 +56,7 @@ from roborock.devices.cache import Cache, CacheData
56
56
  from roborock.devices.device import RoborockDevice
57
57
  from roborock.devices.device_manager import DeviceManager, UserParams, create_device_manager
58
58
  from roborock.devices.traits import Trait
59
+ from roborock.devices.traits.b01.q10 import Q10PropertiesApi
59
60
  from roborock.devices.traits.b01.q10.vacuum import VacuumTrait
60
61
  from roborock.devices.traits.v1 import V1TraitMixin
61
62
  from roborock.devices.traits.v1.consumeable import ConsumableAttribute
@@ -527,6 +528,45 @@ async def maps(ctx, device_id: str):
527
528
  await _display_v1_trait(context, device_id, lambda v1: v1.maps)
528
529
 
529
530
 
531
+ # The Q10 pushes its map ~9s after a dpRequestDps; firmware throttles pushes to
532
+ # ~once per 60-70s, so a single request is answered quickly but rapid re-requests
533
+ # may not be. This bounds how long a one-shot CLI command waits for that push.
534
+ _Q10_MAP_PUSH_TIMEOUT = 30.0
535
+
536
+
537
+ async def _await_q10_map_push(
538
+ properties: Q10PropertiesApi,
539
+ predicate: Callable[[], bool],
540
+ *,
541
+ timeout: float = _Q10_MAP_PUSH_TIMEOUT,
542
+ allow_cached_on_timeout: bool = False,
543
+ ) -> bool:
544
+ """Nudge a Q10 to push its map/trace and wait for a fresh update.
545
+
546
+ The Q10 map API is entirely push-driven: there is no synchronous get-map
547
+ request. A ``dpRequestDps`` causes the device to publish a ``MAP_RESPONSE``,
548
+ which the device's subscribe loop feeds into the map trait. Here we register
549
+ an update listener, send the request, and wait for a newly pushed update to
550
+ satisfy ``predicate``. Returns whether it did within ``timeout``.
551
+ """
552
+ loop = asyncio.get_running_loop()
553
+ updated: asyncio.Future[None] = loop.create_future()
554
+
555
+ def on_update() -> None:
556
+ if predicate() and not updated.done():
557
+ updated.set_result(None)
558
+
559
+ unsub = properties.map.add_update_listener(on_update)
560
+ try:
561
+ await properties.refresh()
562
+ await asyncio.wait_for(updated, timeout=timeout)
563
+ return True
564
+ except TimeoutError:
565
+ return allow_cached_on_timeout and predicate()
566
+ finally:
567
+ unsub()
568
+
569
+
530
570
  @session.command()
531
571
  @click.option("--device_id", required=True)
532
572
  @click.option("--output-file", required=True, help="Path to save the map image.")
@@ -535,10 +575,22 @@ async def maps(ctx, device_id: str):
535
575
  async def map_image(ctx, device_id: str, output_file: str):
536
576
  """Get device map image and save it to a file."""
537
577
  context: RoborockContext = ctx.obj
538
- 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)
@@ -203,6 +203,16 @@ class RoborockDevice(ABC, TraitsMixin):
203
203
  elif self.b01_q10_properties is not None:
204
204
  await self.b01_q10_properties.start()
205
205
  except RoborockException:
206
+ # Expected: start() can fail transiently. Unsubscribe before propagating
207
+ # so the retry by connect_loop() gets a clean channel.
208
+ unsub()
209
+ raise
210
+ except Exception:
211
+ # Not expected here. We normally avoid a bare ``except Exception`` in
212
+ # this codebase, but a leaked subscription would stop connect_loop() from
213
+ # ever reconnecting, so we deliberately catch broadly, log the unexpected
214
+ # error, and release the channel before propagating.
215
+ self._logger.exception("Unexpected error during connect; releasing channel to avoid a leak")
206
216
  unsub()
207
217
  raise
208
218
  self._logger.info("Connected to device")
@@ -2,36 +2,42 @@
2
2
 
3
3
  import logging
4
4
  from collections.abc import AsyncGenerator
5
- from typing import Any
6
5
 
7
6
  from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
8
7
  from roborock.devices.transport.mqtt_channel import MqttChannel
9
8
  from roborock.exceptions import RoborockException
10
9
  from roborock.protocols.b01_q10_protocol import (
11
10
  ParamsType,
12
- 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(
@@ -295,45 +295,75 @@ class V1Channel(Channel):
295
295
  if self._callback is not None:
296
296
  raise ValueError("Only one subscription allowed at a time")
297
297
 
298
- # Make an initial, optimistic attempt to connect to local with the
299
- # cache. The cache information will be refreshed by the background task.
300
- try:
301
- await self._local_connect(prefer_cache=True)
302
- except RoborockException as err:
303
- self._logger.debug("First local connection attempt failed, will retry: %s", err)
304
-
305
- # Start a background task to manage the local connection health. This
306
- # happens independent of whether we were able to connect locally now.
307
- if self._reconnect_task is None:
308
- loop = asyncio.get_running_loop()
309
- self._reconnect_task = loop.create_task(self._background_reconnect())
310
-
311
- # We maintain an active MQTT subscription even when connected locally to receive
312
- # unsolicited status updates (DPS push messages) directly from the cloud.
298
+ # Claim the subscription up front. Any failure in the setup below routes
299
+ # through _teardown(), which clears this again so the channel is left in
300
+ # a clean, re-subscribable state. Without this, a partially-completed
301
+ # subscribe (e.g. a transient failure later in connect()) would leave a
302
+ # stale callback and the next subscribe() would raise the guard above.
303
+ self._callback = callback
313
304
  try:
314
- self._mqtt_unsub = await self._mqtt_channel.subscribe(self._on_mqtt_message)
315
- except RoborockException as err:
316
- if not self.is_local_connected:
317
- # Propagate error if both local and MQTT failed
318
- self._logger.debug("MQTT connection also failed: %s", err)
319
- raise
320
- self._logger.debug("MQTT subscription failed, continuing with local-only connection: %s", err)
321
-
322
- def unsub() -> None:
323
- """Unsubscribe from all messages."""
324
- if self._reconnect_task:
325
- self._reconnect_task.cancel()
326
- self._reconnect_task = None
327
- if self._mqtt_unsub:
328
- self._mqtt_unsub()
329
- self._mqtt_unsub = None
330
- if self._local_unsub:
331
- self._local_unsub()
332
- self._local_unsub = None
333
- self._logger.debug("Unsubscribed from device")
305
+ # Make an initial, optimistic attempt to connect to local with the
306
+ # cache. The cache information will be refreshed by the background task.
307
+ try:
308
+ await self._local_connect(prefer_cache=True)
309
+ except RoborockException as err:
310
+ self._logger.debug("First local connection attempt failed, will retry: %s", err)
334
311
 
335
- self._callback = callback
336
- return unsub
312
+ # Start a background task to manage the local connection health. This
313
+ # happens independent of whether we were able to connect locally now.
314
+ if self._reconnect_task is None:
315
+ loop = asyncio.get_running_loop()
316
+ self._reconnect_task = loop.create_task(self._background_reconnect())
317
+
318
+ # We maintain an active MQTT subscription even when connected locally to receive
319
+ # unsolicited status updates (DPS push messages) directly from the cloud.
320
+ try:
321
+ self._mqtt_unsub = await self._mqtt_channel.subscribe(self._on_mqtt_message)
322
+ except RoborockException as err:
323
+ if not self.is_local_connected:
324
+ # Propagate error if both local and MQTT failed
325
+ self._logger.debug("MQTT connection also failed: %s", err)
326
+ raise
327
+ self._logger.debug("MQTT subscription failed, continuing with local-only connection: %s", err)
328
+ except RoborockException:
329
+ # Expected failure path (e.g. both local and MQTT transports down).
330
+ # Release the channel so the next subscribe() starts clean.
331
+ self._teardown()
332
+ raise
333
+ except Exception:
334
+ # Not expected here. We normally avoid a bare ``except Exception`` in
335
+ # this codebase, but leaving a partial subscription behind (reconnect
336
+ # task, MQTT subscription, stale callback) would brick the device, so we
337
+ # deliberately catch broadly, log the unexpected error, and tear down
338
+ # before propagating.
339
+ self._logger.exception("Unexpected error during subscribe; tearing down to avoid a leak")
340
+ self._teardown()
341
+ raise
342
+
343
+ self._logger.debug("Subscribed to device")
344
+ return self._teardown
345
+
346
+ def _teardown(self) -> None:
347
+ """Tear down all subscriptions and reset the channel to a re-subscribable state.
348
+
349
+ Returned from subscribe() as the unsubscribe function and also invoked on
350
+ any failure partway through subscribe(). Idempotent: each resource is
351
+ guarded so repeat calls are no-ops.
352
+ """
353
+ if self._reconnect_task:
354
+ self._reconnect_task.cancel()
355
+ self._reconnect_task = None
356
+ if self._mqtt_unsub:
357
+ self._mqtt_unsub()
358
+ self._mqtt_unsub = None
359
+ if self._local_unsub:
360
+ self._local_unsub()
361
+ self._local_unsub = None
362
+ if self._local_channel:
363
+ self._local_channel.close()
364
+ self._local_channel = None
365
+ self._callback = None
366
+ self._logger.debug("Unsubscribed from device")
337
367
 
338
368
  def add_dps_listener(self, listener: Callable[[dict[RoborockDataProtocol, Any]], None]) -> Callable[[], None]:
339
369
  """Add a listener for DPS updates.
@@ -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()