python-roborock 5.20.1__tar.gz → 5.22.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 (119) hide show
  1. {python_roborock-5.20.1 → python_roborock-5.22.0}/PKG-INFO +1 -1
  2. {python_roborock-5.20.1 → python_roborock-5.22.0}/pyproject.toml +1 -1
  3. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/cli.py +221 -5
  4. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +33 -3
  5. python_roborock-5.22.0/roborock/data/b01_q10/b01_q10_containers.py +211 -0
  6. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/code_mappings.py +17 -9
  7. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/b01/q10/__init__.py +59 -3
  8. python_roborock-5.22.0/roborock/devices/traits/b01/q10/button_light.py +29 -0
  9. python_roborock-5.22.0/roborock/devices/traits/b01/q10/child_lock.py +36 -0
  10. python_roborock-5.22.0/roborock/devices/traits/b01/q10/common.py +70 -0
  11. python_roborock-5.22.0/roborock/devices/traits/b01/q10/consumable.py +21 -0
  12. python_roborock-5.22.0/roborock/devices/traits/b01/q10/do_not_disturb.py +36 -0
  13. python_roborock-5.22.0/roborock/devices/traits/b01/q10/dust_collection.py +43 -0
  14. python_roborock-5.22.0/roborock/devices/traits/b01/q10/network_info.py +21 -0
  15. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/b01/q10/remote.py +1 -1
  16. python_roborock-5.22.0/roborock/devices/traits/b01/q10/status.py +26 -0
  17. python_roborock-5.22.0/roborock/devices/traits/b01/q10/vacuum.py +109 -0
  18. python_roborock-5.22.0/roborock/devices/traits/b01/q10/volume.py +29 -0
  19. python_roborock-5.20.1/roborock/data/b01_q10/b01_q10_containers.py +0 -110
  20. python_roborock-5.20.1/roborock/devices/traits/b01/q10/status.py +0 -31
  21. python_roborock-5.20.1/roborock/devices/traits/b01/q10/vacuum.py +0 -81
  22. {python_roborock-5.20.1 → python_roborock-5.22.0}/.gitignore +0 -0
  23. {python_roborock-5.20.1 → python_roborock-5.22.0}/LICENSE +0 -0
  24. {python_roborock-5.20.1 → python_roborock-5.22.0}/README.md +0 -0
  25. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/__init__.py +0 -0
  26. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/broadcast_protocol.py +0 -0
  27. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/callbacks.py +0 -0
  28. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/const.py +0 -0
  29. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/__init__.py +0 -0
  30. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/b01_q10/__init__.py +0 -0
  31. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/b01_q7/__init__.py +0 -0
  32. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  33. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  34. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/containers.py +0 -0
  35. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/dyad/__init__.py +0 -0
  36. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  37. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/dyad/dyad_containers.py +0 -0
  38. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/v1/__init__.py +0 -0
  39. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  40. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  41. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/v1/v1_containers.py +0 -0
  42. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/zeo/__init__.py +0 -0
  43. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  44. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/data/zeo/zeo_containers.py +0 -0
  45. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/device_features.py +0 -0
  46. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/README.md +0 -0
  47. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/__init__.py +0 -0
  48. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/cache.py +0 -0
  49. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/device.py +0 -0
  50. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/device_manager.py +0 -0
  51. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/file_cache.py +0 -0
  52. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/rpc/__init__.py +0 -0
  53. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/rpc/a01_channel.py +0 -0
  54. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  55. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  56. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/rpc/v1_channel.py +0 -0
  57. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/__init__.py +0 -0
  58. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/a01/__init__.py +0 -0
  59. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/b01/__init__.py +0 -0
  60. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  61. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/b01/q10/map.py +0 -0
  62. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  63. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  64. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  65. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  66. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/common.py +0 -0
  67. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/traits_mixin.py +0 -0
  68. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/__init__.py +0 -0
  69. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  70. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  71. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/command.py +0 -0
  72. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/common.py +0 -0
  73. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  74. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/device_features.py +0 -0
  75. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  76. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  77. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  78. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/home.py +0 -0
  79. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/led_status.py +0 -0
  80. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/map_content.py +0 -0
  81. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/maps.py +0 -0
  82. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/network_info.py +0 -0
  83. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/rooms.py +0 -0
  84. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/routines.py +0 -0
  85. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  86. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/status.py +0 -0
  87. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  88. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/volume.py +0 -0
  89. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  90. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/transport/__init__.py +0 -0
  91. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/transport/channel.py +0 -0
  92. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/transport/local_channel.py +0 -0
  93. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  94. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/diagnostics.py +0 -0
  95. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/exceptions.py +0 -0
  96. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/map/__init__.py +0 -0
  97. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/map/b01_grid_layers.py +0 -0
  98. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/map/b01_map_parser.py +0 -0
  99. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/map/b01_q10_map_parser.py +0 -0
  100. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/map/b01_q10_overlays.py +0 -0
  101. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/map/map_parser.py +0 -0
  102. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/map/proto/__init__.py +0 -0
  103. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/map/proto/b01_scmap.proto +0 -0
  104. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  105. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/mqtt/__init__.py +0 -0
  106. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/mqtt/health_manager.py +0 -0
  107. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/mqtt/roborock_session.py +0 -0
  108. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/mqtt/session.py +0 -0
  109. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/protocol.py +0 -0
  110. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/protocols/__init__.py +0 -0
  111. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/protocols/a01_protocol.py +0 -0
  112. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  113. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  114. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/protocols/v1_protocol.py +0 -0
  115. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/py.typed +0 -0
  116. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/roborock_message.py +0 -0
  117. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/roborock_typing.py +0 -0
  118. {python_roborock-5.20.1 → python_roborock-5.22.0}/roborock/util.py +0 -0
  119. {python_roborock-5.20.1 → python_roborock-5.22.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.20.1
3
+ Version: 5.22.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.20.1"
3
+ version = "5.22.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"
@@ -49,7 +49,12 @@ except ImportError as err:
49
49
 
50
50
  from roborock import RoborockCommand
51
51
  from roborock.data import RoborockBase, UserData
52
- from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType, YXFanLevel
52
+ from roborock.data.b01_q10.b01_q10_code_mappings import (
53
+ B01_Q10_DP,
54
+ YXCleanType,
55
+ YXDeviceDustCollectionFrequency,
56
+ YXFanLevel,
57
+ )
53
58
  from roborock.data.code_mappings import SHORT_MODEL_TO_ENUM
54
59
  from roborock.device_features import DeviceFeatures
55
60
  from roborock.devices.cache import Cache, CacheData
@@ -446,13 +451,67 @@ async def _display_v1_trait(context: RoborockContext, device_id: str, display_fu
446
451
  click.echo(dump_json(trait.as_dict()))
447
452
 
448
453
 
449
- async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumTrait:
450
- """Get VacuumTrait from Q10 device."""
454
+ async def _q10_properties(context: RoborockContext, device_id: str) -> Q10PropertiesApi:
455
+ """Get the B01 Q10 properties API for a device."""
451
456
  device_manager = await context.get_device_manager()
452
457
  device = await device_manager.get_device(device_id)
453
458
  if device.b01_q10_properties is None:
454
459
  raise RoborockUnsupportedFeature("Device does not support B01 Q10 protocol. Is it a Q10?")
455
- return device.b01_q10_properties.vacuum
460
+ return device.b01_q10_properties
461
+
462
+
463
+ async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumTrait:
464
+ """Get VacuumTrait from Q10 device."""
465
+ return (await _q10_properties(context, device_id)).vacuum
466
+
467
+
468
+ async def _display_q10_status(context: RoborockContext, device_id: str) -> None:
469
+ """Refresh and display the full status of a B01 Q10 device.
470
+
471
+ Unlike V1 devices, the Q10 reports its state asynchronously: ``refresh()``
472
+ sends a request and the device streams the values back over the persistent
473
+ subscribe loop. That loop also delivers unsolicited pushes, so the read-model
474
+ traits may already hold (possibly stale) values from before this command ran
475
+ -- checking that a field is merely populated isn't enough. To display data
476
+ the device sent *in response to this refresh*, we register update listeners,
477
+ fire the refresh, and wait for a fresh update before reading the traits.
478
+
479
+ All read-model traits refreshed by :meth:`Q10PropertiesApi.refresh` are shown,
480
+ not just ``status`` (volume, child lock, do-not-disturb, dust collection,
481
+ network info and consumables are part of the device's reported state too).
482
+ """
483
+ properties = await _q10_properties(context, device_id)
484
+
485
+ # Read-model traits populated from the device's DPS push stream.
486
+ traits = {
487
+ "status": properties.status,
488
+ "volume": properties.volume,
489
+ "child_lock": properties.child_lock,
490
+ "do_not_disturb": properties.do_not_disturb,
491
+ "dust_collection": properties.dust_collection,
492
+ "network_info": properties.network_info,
493
+ "consumable": properties.consumable,
494
+ }
495
+
496
+ updated = asyncio.Event()
497
+ unsubscribes = [trait.add_update_listener(updated.set) for trait in traits.values()]
498
+ try:
499
+ await properties.refresh()
500
+ try:
501
+ await asyncio.wait_for(updated.wait(), timeout=5)
502
+ except TimeoutError:
503
+ click.echo("Timed out waiting for status from device")
504
+ return
505
+ # The device streams its DPS across several pushes; give the remaining
506
+ # ones a brief window to arrive after the first fresh update.
507
+ await asyncio.sleep(0.5)
508
+ finally:
509
+ for unsubscribe in unsubscribes:
510
+ unsubscribe()
511
+
512
+ # Each concrete trait also subclasses a RoborockBase read-model, so it has
513
+ # ``as_dict``; the cast satisfies the typed UpdatableTrait view above.
514
+ click.echo(dump_json({name: cast(RoborockBase, trait).as_dict() for name, trait in traits.items()}))
456
515
 
457
516
 
458
517
  @session.command()
@@ -462,7 +521,14 @@ async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumT
462
521
  async def status(ctx, device_id: str):
463
522
  """Get device status."""
464
523
  context: RoborockContext = ctx.obj
465
- await _display_v1_trait(context, device_id, lambda v1: v1.status)
524
+ device_manager = await context.get_device_manager()
525
+ device = await device_manager.get_device(device_id)
526
+ if device.v1_properties is not None:
527
+ await _display_v1_trait(context, device_id, lambda v1: v1.status)
528
+ elif device.b01_q10_properties is not None:
529
+ await _display_q10_status(context, device_id)
530
+ else:
531
+ click.echo("Feature not supported by device")
466
532
 
467
533
 
468
534
  @session.command()
@@ -1399,6 +1465,156 @@ async def q10_vacuum_dock(ctx: click.Context, device_id: str) -> None:
1399
1465
  click.echo(f"Error: {e}")
1400
1466
 
1401
1467
 
1468
+ @session.command()
1469
+ @click.option("--device_id", required=True, help="Device ID")
1470
+ @click.pass_context
1471
+ @async_command
1472
+ async def q10_vacuum_spot(ctx: click.Context, device_id: str) -> None:
1473
+ """Start a spot / part clean on a Q10 device."""
1474
+ context: RoborockContext = ctx.obj
1475
+ try:
1476
+ trait = await _q10_vacuum_trait(context, device_id)
1477
+ await trait.spot_clean()
1478
+ click.echo("Starting spot clean...")
1479
+ except RoborockUnsupportedFeature:
1480
+ click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1481
+ except RoborockException as e:
1482
+ click.echo(f"Error: {e}")
1483
+
1484
+
1485
+ @session.command()
1486
+ @click.option("--device_id", required=True, help="Device ID")
1487
+ @click.option(
1488
+ "--segments",
1489
+ required=True,
1490
+ help="Comma-separated room/segment ids to clean (see the `rooms` command), e.g. 9,2",
1491
+ )
1492
+ @click.pass_context
1493
+ @async_command
1494
+ async def q10_clean_segments(ctx: click.Context, device_id: str, segments: str) -> None:
1495
+ """Start a room / segment clean on a Q10 device.
1496
+
1497
+ Room ids come from the `rooms` command (the device's map rooms).
1498
+ """
1499
+ context: RoborockContext = ctx.obj
1500
+ try:
1501
+ segment_ids = [int(s) for s in segments.split(",") if s.strip()]
1502
+ except ValueError:
1503
+ click.echo("--segments must be comma-separated integers, e.g. 9,2")
1504
+ return
1505
+ if not segment_ids:
1506
+ click.echo("No segment ids provided")
1507
+ return
1508
+ try:
1509
+ trait = await _q10_vacuum_trait(context, device_id)
1510
+ await trait.clean_segments(segment_ids)
1511
+ click.echo(f"Starting room clean of segments {segment_ids}...")
1512
+ except RoborockUnsupportedFeature:
1513
+ click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1514
+ except RoborockException as e:
1515
+ click.echo(f"Error: {e}")
1516
+
1517
+
1518
+ async def _q10_set(ctx: click.Context, device_id: str, apply: Callable[[Any], Any], message: str) -> None:
1519
+ """Run a Q10 settings write and report the result."""
1520
+ context: RoborockContext = ctx.obj
1521
+ try:
1522
+ properties = await _q10_properties(context, device_id)
1523
+ await apply(properties)
1524
+ click.echo(message)
1525
+ except RoborockUnsupportedFeature:
1526
+ click.echo("Device does not support B01 Q10 protocol. Is it a Q10?")
1527
+ except (RoborockException, ValueError) as e:
1528
+ click.echo(f"Error: {e}")
1529
+
1530
+
1531
+ @session.command()
1532
+ @click.option("--device_id", required=True, help="Device ID")
1533
+ @click.option("--volume", required=True, type=int, help="Volume 0-100")
1534
+ @click.pass_context
1535
+ @async_command
1536
+ async def q10_set_volume(ctx: click.Context, device_id: str, volume: int) -> None:
1537
+ """Set the speaker volume on a Q10 device."""
1538
+ await _q10_set(ctx, device_id, lambda p: p.volume.set_volume(volume), f"Volume set to {volume}")
1539
+
1540
+
1541
+ @session.command()
1542
+ @click.option("--device_id", required=True, help="Device ID")
1543
+ @click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)")
1544
+ @click.pass_context
1545
+ @async_command
1546
+ async def q10_set_child_lock(ctx: click.Context, device_id: str, enabled: bool) -> None:
1547
+ """Enable or disable the child lock on a Q10 device."""
1548
+ await _q10_set(
1549
+ ctx,
1550
+ device_id,
1551
+ lambda p: p.child_lock.enable() if enabled else p.child_lock.disable(),
1552
+ f"Child lock set to {enabled}",
1553
+ )
1554
+
1555
+
1556
+ @session.command()
1557
+ @click.option("--device_id", required=True, help="Device ID")
1558
+ @click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)")
1559
+ @click.pass_context
1560
+ @async_command
1561
+ async def q10_set_dnd(ctx: click.Context, device_id: str, enabled: bool) -> None:
1562
+ """Enable or disable Do Not Disturb on a Q10 device."""
1563
+ await _q10_set(
1564
+ ctx,
1565
+ device_id,
1566
+ lambda p: p.do_not_disturb.enable() if enabled else p.do_not_disturb.disable(),
1567
+ f"Do Not Disturb set to {enabled}",
1568
+ )
1569
+
1570
+
1571
+ @session.command()
1572
+ @click.option("--device_id", required=True, help="Device ID")
1573
+ @click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)")
1574
+ @click.pass_context
1575
+ @async_command
1576
+ async def q10_set_led(ctx: click.Context, device_id: str, enabled: bool) -> None:
1577
+ """Enable or disable the indicator light (LED) on a Q10 device."""
1578
+ await _q10_set(
1579
+ ctx,
1580
+ device_id,
1581
+ lambda p: p.button_light.enable() if enabled else p.button_light.disable(),
1582
+ f"LED set to {enabled}",
1583
+ )
1584
+
1585
+
1586
+ @session.command()
1587
+ @click.option("--device_id", required=True, help="Device ID")
1588
+ @click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)")
1589
+ @click.pass_context
1590
+ @async_command
1591
+ async def q10_set_dust_collection(ctx: click.Context, device_id: str, enabled: bool) -> None:
1592
+ """Enable or disable automatic dust collection on a Q10 device."""
1593
+ await _q10_set(
1594
+ ctx,
1595
+ device_id,
1596
+ lambda p: p.dust_collection.enable() if enabled else p.dust_collection.disable(),
1597
+ f"Dust collection set to {enabled}",
1598
+ )
1599
+
1600
+
1601
+ @session.command()
1602
+ @click.option("--device_id", required=True, help="Device ID")
1603
+ @click.option(
1604
+ "--frequency",
1605
+ required=True,
1606
+ type=click.Choice([str(m.code) for m in YXDeviceDustCollectionFrequency]),
1607
+ help="Empty after every N cleans (0 = daily).",
1608
+ )
1609
+ @click.pass_context
1610
+ @async_command
1611
+ async def q10_set_dust_frequency(ctx: click.Context, device_id: str, frequency: str) -> None:
1612
+ """Set how often the dock empties the bin (0 = daily, else every N cleans)."""
1613
+ freq = YXDeviceDustCollectionFrequency.from_code(int(frequency))
1614
+ label = "daily" if freq.code == 0 else f"every {freq.code} cleans"
1615
+ await _q10_set(ctx, device_id, lambda p: p.dust_collection.set_frequency(freq), f"Dust frequency set to {label}")
1616
+
1617
+
1402
1618
  @session.command()
1403
1619
  @click.option("--device_id", required=True, help="Device ID")
1404
1620
  @click.pass_context
@@ -85,9 +85,14 @@ class B01_Q10_DP(RoborockModeEnum):
85
85
  SUSPECTED_THRESHOLD = ("dpSuspectedThreshold", 99)
86
86
  SUSPECTED_THRESHOLD_UP = ("dpSuspectedThresholdUp", 100)
87
87
  COMMON = ("dpCommon", 101)
88
- JUMP_SCAN = ("dpJumpScan", 101)
89
88
  REQUEST_DPS = ("dpRequestDps", 102) # NOTE: typo "dpRequetdps" in source code
90
- CLIFF_RESTRICTED_AREA = ("dpCliffRestrictedArea", 102)
89
+ # NOTE: the legacy B01 source also listed dpJumpScan (101) and
90
+ # dpCliffRestrictedArea (102), which collided with the confirmed dpCommon /
91
+ # dpRequestDps codes above (verified against ss07 hardware and the official app
92
+ # plugin) and shadowed them in ``from_code``. Both are unused and their real
93
+ # codes could not be verified, so they were removed rather than left as wrong
94
+ # duplicates. dpCliffRestrictedAreaUp (103) is kept: ss07 hardware does push
95
+ # data point 103 (an empty list when no cliff-restricted areas are set).
91
96
  CLIFF_RESTRICTED_AREA_UP = ("dpCliffRestrictedAreaUp", 103)
92
97
  BREAKPOINT_CLEAN = ("dpBreakpointClean", 104)
93
98
  VALLEY_POINT_CHARGING = ("dpValleyPointCharging", 105)
@@ -96,6 +101,13 @@ class B01_Q10_DP(RoborockModeEnum):
96
101
  VOICE_VERSION = ("dpVoiceVersion", 108)
97
102
  ROBOT_COUNTRY_CODE = ("dpRobotCountryCode", 109)
98
103
  HEARTBEAT = ("dpHeartbeat", 110)
104
+ # NOTE: ss07 hardware also pushes data points 112 and 113 in its full status
105
+ # dump. They are absent from the official app's vacuum plugin and stayed 0
106
+ # across every observed state (docked/charging, segment cleaning, lifted-off-
107
+ # ground fault, returning to dock, dustbin removed), so their meaning is not
108
+ # yet known. They are intentionally left unmapped; ``decode_rpc_response``
109
+ # silently ignores unknown codes via ``from_code_optional``, so they do not
110
+ # produce "not a valid code" warnings. Map them here once identified.
99
111
  STATUS = ("dpStatus", 121)
100
112
  BATTERY = ("dpBattery", 122)
101
113
  FAN_LEVEL = ("dpFanLevel", 123) # NOTE: typo "dpfunLevel" in source code
@@ -215,13 +227,31 @@ class YXDeviceCleanTask(RoborockModeEnum):
215
227
 
216
228
 
217
229
  class YXDeviceDustCollectionFrequency(RoborockModeEnum):
218
- DAILY = "daily", 0
230
+ # The app exposes "regular" (code 0) vs "frequent", where "frequent" selects
231
+ # one of the every-N-cleans intervals below.
232
+ REGULAR = "regular", 0
219
233
  INTERVAL_15 = "interval_15", 15
220
234
  INTERVAL_30 = "interval_30", 30
221
235
  INTERVAL_45 = "interval_45", 45
222
236
  INTERVAL_60 = "interval_60", 60
223
237
 
224
238
 
239
+ class YXAreaUnit(RoborockModeEnum):
240
+ """Unit used to report cleaned area (dpAreaUnit)."""
241
+
242
+ SQUARE_METER = "square_meter", 0
243
+ SQUARE_FEET = "square_feet", 1
244
+
245
+
246
+ class YXCarpetCleanType(RoborockModeEnum):
247
+ """Carpet handling behavior (dpCarpetCleanType)."""
248
+
249
+ RISE = "rise", 0 # lift the mop and boost over carpet
250
+ AVOID = "avoid", 1
251
+ IGNORE = "ignore", 2
252
+ CROSS = "cross", 3
253
+
254
+
225
255
  class RemoteCommand(IntEnum):
226
256
  FORWARD = 0
227
257
  LEFT = 2
@@ -0,0 +1,211 @@
1
+ """Data container classes for Q10 B01 devices.
2
+
3
+ Many of these classes use the `field(metadata={"dps": ...})` convention to map
4
+ dataclass fields to device Data Points (DPS). This metadata is utilized by the
5
+ `UpdatableTrait` helper in `roborock.devices.traits.b01.q10.common` to
6
+ automatically update objects from raw device responses.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from ..containers import RoborockBase
12
+ from .b01_q10_code_mappings import (
13
+ B01_Q10_DP,
14
+ YXAreaUnit,
15
+ YXBackType,
16
+ YXCarpetCleanType,
17
+ YXCleanLine,
18
+ YXCleanType,
19
+ YXDeviceCleanTask,
20
+ YXDeviceDustCollectionFrequency,
21
+ YXDeviceState,
22
+ YXFanLevel,
23
+ YXWaterLevel,
24
+ )
25
+
26
+
27
+ @dataclass
28
+ class dpCleanRecord(RoborockBase):
29
+ op: str
30
+ result: int
31
+ id: str
32
+ data: list
33
+
34
+
35
+ @dataclass
36
+ class dpMultiMap(RoborockBase):
37
+ op: str
38
+ result: int
39
+ data: list
40
+
41
+
42
+ @dataclass
43
+ class dpGetCarpet(RoborockBase):
44
+ op: str
45
+ result: int
46
+ data: str
47
+
48
+
49
+ @dataclass
50
+ class dpSelfIdentifyingCarpet(RoborockBase):
51
+ op: str
52
+ result: int
53
+ data: str
54
+
55
+
56
+ @dataclass
57
+ class dpNetInfo(RoborockBase):
58
+ wifi_name: str | None = None
59
+ # "ip_adress" intentionally mirrors the device's "ipAdress" key (sic).
60
+ ip_adress: str | None = None
61
+ mac: str | None = None
62
+ signal: int | None = None
63
+
64
+ @property
65
+ def ip_address(self) -> str | None:
66
+ """Correctly-spelled alias for :attr:`ip_adress`."""
67
+ return self.ip_adress
68
+
69
+
70
+ @dataclass
71
+ class dpNotDisturbExpand(RoborockBase):
72
+ disturb_dust_enable: int | None = None
73
+ disturb_light: int | None = None
74
+ disturb_resume_clean: int | None = None
75
+ disturb_voice: int | None = None
76
+
77
+
78
+ @dataclass
79
+ class dpCurrentCleanRoomIds(RoborockBase):
80
+ room_id_list: list
81
+
82
+
83
+ @dataclass
84
+ class dpVoiceVersion(RoborockBase):
85
+ version: int
86
+
87
+
88
+ @dataclass
89
+ class dpTimeZone(RoborockBase):
90
+ time_zone_city: str | None = None
91
+ time_zone_sec: int | None = None
92
+
93
+
94
+ @dataclass
95
+ class Q10Status(RoborockBase):
96
+ """Core vacuum status for Q10 devices.
97
+
98
+ Fields are mapped to DPS values using metadata. Objects of this class can be
99
+ automatically updated using the `UpdatableTrait` helper. Settings that have
100
+ their own trait (volume, child lock, do-not-disturb, dust collection,
101
+ network info, consumables) live on those traits instead of here.
102
+ """
103
+
104
+ clean_time: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TIME})
105
+ clean_area: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_AREA})
106
+ battery: int | None = field(default=None, metadata={"dps": B01_Q10_DP.BATTERY})
107
+ status: YXDeviceState | None = field(default=None, metadata={"dps": B01_Q10_DP.STATUS})
108
+ fan_level: YXFanLevel | None = field(default=None, metadata={"dps": B01_Q10_DP.FAN_LEVEL})
109
+ water_level: YXWaterLevel | None = field(default=None, metadata={"dps": B01_Q10_DP.WATER_LEVEL})
110
+ clean_count: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_COUNT})
111
+ total_clean_area: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_AREA})
112
+ total_clean_count: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_COUNT})
113
+ total_clean_time: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_TIME})
114
+ clean_mode: YXCleanType | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_MODE})
115
+ clean_task_type: YXDeviceCleanTask | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TASK_TYPE})
116
+ back_type: YXBackType | None = field(default=None, metadata={"dps": B01_Q10_DP.BACK_TYPE})
117
+ cleaning_progress: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_PROGRESS})
118
+ fault: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FAULT})
119
+
120
+ # Additional state reported in the device's full status dump.
121
+ clean_line: YXCleanLine | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_LINE})
122
+ carpet_clean_type: YXCarpetCleanType | None = field(default=None, metadata={"dps": B01_Q10_DP.CARPET_CLEAN_TYPE})
123
+ area_unit: YXAreaUnit | None = field(default=None, metadata={"dps": B01_Q10_DP.AREA_UNIT})
124
+ auto_boost: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.AUTO_BOOST})
125
+ multi_map_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MULTI_MAP_SWITCH})
126
+ map_save_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MAP_SAVE_SWITCH})
127
+ recent_clean_record: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.RECENT_CLEAN_RECORD})
128
+ valley_point_charging: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.VALLEY_POINT_CHARGING})
129
+ line_laser_obstacle_avoidance: bool | None = field(
130
+ default=None, metadata={"dps": B01_Q10_DP.LINE_LASER_OBSTACLE_AVOIDANCE}
131
+ )
132
+ # Whether a mop module is attached, and whether "clean along floor direction" is on.
133
+ mop_state: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MOP_STATE})
134
+ ground_clean: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.GROUND_CLEAN})
135
+ # True while an "add area" / re-clean (the app's draw-a-rectangle "re cleaning")
136
+ # request is in progress; pulses back to False once the robot has the area.
137
+ add_clean_state: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.ADD_CLEAN_STATE})
138
+ robot_country_code: str | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_COUNTRY_CODE})
139
+ time_zone: dpTimeZone | None = field(default=None, metadata={"dps": B01_Q10_DP.TIME_ZONE})
140
+
141
+ # TODO(#846): value mappings for these ints are not yet decoded (no app
142
+ # control found / internal / constant); keep as int until reverse-engineered.
143
+ breakpoint_clean: int | None = field(default=None, metadata={"dps": B01_Q10_DP.BREAKPOINT_CLEAN})
144
+ timer_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TIMER_TYPE})
145
+ user_plan: int | None = field(default=None, metadata={"dps": B01_Q10_DP.USER_PLAN})
146
+ robot_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_TYPE})
147
+
148
+ # DEPRECATED: consumable/accessory remaining-life now lives on the
149
+ # ``Q10Consumable`` trait. These aliases are kept here for backwards
150
+ # compatibility and will be removed in a follow-up release. See PR #846.
151
+ main_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MAIN_BRUSH_LIFE})
152
+ side_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SIDE_BRUSH_LIFE})
153
+ filter_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FILTER_LIFE})
154
+ sensor_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SENSOR_LIFE})
155
+
156
+
157
+ @dataclass
158
+ class SoundVolume(RoborockBase):
159
+ """Speaker volume read-model (0-100)."""
160
+
161
+ volume: int | None = field(default=None, metadata={"dps": B01_Q10_DP.VOLUME})
162
+
163
+
164
+ @dataclass
165
+ class ChildLock(RoborockBase):
166
+ """Child-lock read-model."""
167
+
168
+ child_lock: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.CHILD_LOCK})
169
+
170
+
171
+ @dataclass
172
+ class DoNotDisturb(RoborockBase):
173
+ """Do Not Disturb read-model."""
174
+
175
+ not_disturb: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB})
176
+ not_disturb_expand: dpNotDisturbExpand | None = field(default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB_EXPAND})
177
+
178
+
179
+ @dataclass
180
+ class DustCollection(RoborockBase):
181
+ """Dock auto-empty (dust collection) read-model."""
182
+
183
+ dust_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.DUST_SWITCH})
184
+ dust_setting: YXDeviceDustCollectionFrequency | None = field(
185
+ default=None, metadata={"dps": B01_Q10_DP.DUST_SETTING}
186
+ )
187
+
188
+
189
+ @dataclass
190
+ class Q10Consumable(RoborockBase):
191
+ """Consumable / accessory remaining-life read-model.
192
+
193
+ Named with a ``Q10`` prefix to avoid shadowing the v1 ``Consumable`` when both
194
+ are star-imported into the ``roborock.data`` namespace.
195
+ """
196
+
197
+ main_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MAIN_BRUSH_LIFE})
198
+ side_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SIDE_BRUSH_LIFE})
199
+ filter_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FILTER_LIFE})
200
+ sensor_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SENSOR_LIFE})
201
+
202
+
203
+ @dataclass
204
+ class Q10NetworkInfo(RoborockBase):
205
+ """Network information read-model.
206
+
207
+ Named with a ``Q10`` prefix to avoid shadowing the v1 ``NetworkInfo`` when both
208
+ are star-imported into the ``roborock.data`` namespace.
209
+ """
210
+
211
+ net_info: dpNetInfo | None = field(default=None, metadata={"dps": B01_Q10_DP.NET_INFO})
@@ -76,11 +76,17 @@ class RoborockModeEnum(StrEnum):
76
76
 
77
77
  @classmethod
78
78
  def from_code_optional(cls, code: int) -> Self | None:
79
- """Gracefully return None if the code does not exist."""
80
- try:
81
- return cls.from_code(code)
82
- except ValueError:
83
- return None
79
+ """Gracefully return None if the code does not exist.
80
+
81
+ This is the silent counterpart to :meth:`from_code`: callers use it when
82
+ an unknown code is expected and tolerable (e.g. decoding a device push
83
+ that may include data points this library does not model yet), so it must
84
+ not emit the "not a valid code" warning that ``from_code`` logs.
85
+ """
86
+ for member in cls:
87
+ if member.code == code:
88
+ return member
89
+ return None
84
90
 
85
91
  @classmethod
86
92
  def from_value(cls, value: str) -> Self:
@@ -115,12 +121,14 @@ class RoborockModeEnum(StrEnum):
115
121
  return cls.from_value(str(value))
116
122
  except ValueError:
117
123
  pass
118
- # Try integer code lookup (e.g. "11")
124
+ # Try integer code lookup (e.g. "11"). Use the silent optional variant so
125
+ # a value that is neither a name, a DP string, nor a known code resolves
126
+ # to None without logging a spurious "not a valid code" warning.
119
127
  try:
120
- return cls.from_code(int(value))
128
+ int_code = int(value)
121
129
  except (ValueError, TypeError):
122
- pass
123
- return None
130
+ return None
131
+ return cls.from_code_optional(int_code)
124
132
 
125
133
  @classmethod
126
134
  def keys(cls) -> list[str]: