python-roborock 5.20.1__py3-none-any.whl → 5.22.0__py3-none-any.whl

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.
@@ -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,7 +1,7 @@
1
1
  roborock/__init__.py,sha256=jpp3MEmevRvoJ5tq2waCDGH5VfJk--Lv1DmrwEdDyEQ,375
2
2
  roborock/broadcast_protocol.py,sha256=fjNQW7NdBKRzSy6126TfCSoYGpT2Esj9Jd-t7tXFc_E,4015
3
3
  roborock/callbacks.py,sha256=w2yPXtWFH7zfoFsLPYoCTbfwbE9p1ivA7iDcw20lJQI,4300
4
- roborock/cli.py,sha256=LQ2mxKlsloSJMp7-DjLiXS5S-AN85tads2ZvA4MzcVg,54916
4
+ roborock/cli.py,sha256=3AZHW8Co1H0Bqi7o1nMKA2MkfPuld_OdbPHQh_sNzCA,63478
5
5
  roborock/const.py,sha256=fWpgUPrCgLnaXmncgjCLZET-t7wHWQlpyzV8qHtTjb4,2860
6
6
  roborock/device_features.py,sha256=ltWheUjsaOHm-Naw24evRkDUVBT9gB2l4wX-meI_dWo,35960
7
7
  roborock/diagnostics.py,sha256=YIc7BhC6Cy6W2a6hykvfSRPG8NpLrPZCc_7jTX2Zdho,5091
@@ -13,11 +13,11 @@ roborock/roborock_typing.py,sha256=Rwx0c0oZCMY-4O8VMLnFSO_OUNb_D59cyG_ktZTZoZI,1
13
13
  roborock/util.py,sha256=mjw329ujLjoWPPAr9mG0VZstaVL06dmVvNnYACB-Pxk,1650
14
14
  roborock/web_api.py,sha256=M_A4PiRK5cwkoT3oS8fvglOU97s1jpWKuuCxeeCiF_E,37507
15
15
  roborock/data/__init__.py,sha256=DikqaVM7VPVddkBNkHSss5zb1_iKB7GYmc2GiHoMAkA,257
16
- roborock/data/code_mappings.py,sha256=CzD9q-VuPy70pDZdjZ6kgK4vcGdzNGV3Eo5z9QGOYp0,8399
16
+ roborock/data/code_mappings.py,sha256=Cj1nEQkv3Vko7Mh8FODNtfIS5gn5KlTq2QXFtcincHo,8948
17
17
  roborock/data/containers.py,sha256=-b6H2snGQTzsJSsctgil6sx8rLtCDihTTzdkxcFRdlE,16489
18
18
  roborock/data/b01_q10/__init__.py,sha256=E4lwJcz4AI1z92FcYOJ-cY3fcfHSoXqvYVgfA-TgaRk,71
19
- roborock/data/b01_q10/b01_q10_code_mappings.py,sha256=qJmw4lGYjQO_Uowsp6gMudbULmkBtNq0ZiHaUxoUZ-4,8336
20
- roborock/data/b01_q10/b01_q10_containers.py,sha256=wFHeZPGj6NvljKL99j1209ldwiVNAdqKh52UvMFU640,3514
19
+ roborock/data/b01_q10/b01_q10_code_mappings.py,sha256=NDKLgm9UvsRo90rLFvEkZ-eD5QbA_-hqgSSHa6DkhLM,9883
20
+ roborock/data/b01_q10/b01_q10_containers.py,sha256=YD08ZJnHv-ItK6guYtj-cH0bbYsvD2kMCfKat9DblBI,8628
21
21
  roborock/data/b01_q7/__init__.py,sha256=D-Cd8eB0njf1PQPM6Exl_9A0AhpzIjrL0hapGOCEkkA,69
22
22
  roborock/data/b01_q7/b01_q7_code_mappings.py,sha256=EHSCZSv_Os049NcenKo4tkPjDOB4vpmQ2p2Psr9FRCc,10698
23
23
  roborock/data/b01_q7/b01_q7_containers.py,sha256=jNEHoNEdq276XJZGGUmtSyJ81cSIzJwem-O_NOgmfdQ,10270
@@ -47,12 +47,20 @@ roborock/devices/traits/common.py,sha256=KUlXvx2UcFVeNXEhPiCAdy2APu7zrJmM_jME_bM
47
47
  roborock/devices/traits/traits_mixin.py,sha256=7t15l_Ty1cN-3-fKNtRH1XR6-mfadPnTBCQRZP4rAtc,2092
48
48
  roborock/devices/traits/a01/__init__.py,sha256=i-us9zLPWPfSQ5XWq8t0MiYLYe5NkcLvk3aEQTYT7E0,8104
49
49
  roborock/devices/traits/b01/__init__.py,sha256=pGN6wEocE6Fr2BTasG8Z_75crD6OdTwpju59Vw7eTDk,202
50
- roborock/devices/traits/b01/q10/__init__.py,sha256=bABqhW9Xr-FGjNHSl8b6veOVFI4M2LebLnnoCTfzaPQ,3729
50
+ roborock/devices/traits/b01/q10/__init__.py,sha256=CGzhddpgCvEJF-H9ZbZ1P89XNcPf2wcpBMn-1JVliN8,5803
51
+ roborock/devices/traits/b01/q10/button_light.py,sha256=_CCWSjFe_iZnnSXgBHOju31VUmwG1KKd_jGkmSpLew4,994
52
+ roborock/devices/traits/b01/q10/child_lock.py,sha256=bTsbAMxn7AF1zpWteyZCkQm4jj3NiWD8ddZTM3tlG_0,1131
51
53
  roborock/devices/traits/b01/q10/command.py,sha256=mcWNRSq72bfa79_EjKRgMEIDTV3vWFh2ofBe4aF7EOE,1306
54
+ roborock/devices/traits/b01/q10/common.py,sha256=Qxl3zBAUulqdrjFZJ0DPEFEIJLvlPqUKuTQGPloy7Ds,3089
55
+ roborock/devices/traits/b01/q10/consumable.py,sha256=MbtMgYKqzNdVn6M1iyOAqZPCYTbN_Q_Pr4h8EYGEiuY,669
56
+ roborock/devices/traits/b01/q10/do_not_disturb.py,sha256=klyGQZ_nHyO6bhPlwBl8vElTnMwPf4A8VXwxkU8rMlM,1157
57
+ roborock/devices/traits/b01/q10/dust_collection.py,sha256=LM-0YH4pMrEqLUCFP2g-DymhL5ZUQ4bHd-2EyKHmucY,1582
52
58
  roborock/devices/traits/b01/q10/map.py,sha256=ETaPWIH1qpiZQrutwm3aJu-WxS-idxLtjxntRduE70Q,4170
53
- roborock/devices/traits/b01/q10/remote.py,sha256=ZvURdYP023vsJF6Mx9rA004bi6tOPAjrL4o7dC1baFQ,1257
54
- roborock/devices/traits/b01/q10/status.py,sha256=dIuUpRVjOVXvFr-EA_7lV9KCF7XXjE4vQOFsd-Bbh8w,1133
55
- roborock/devices/traits/b01/q10/vacuum.py,sha256=7PUPWaLqj3jsNLw87oyf1oz2UXckF4Z5Fh6ZYqeY7Ww,2280
59
+ roborock/devices/traits/b01/q10/network_info.py,sha256=XiCr6YPu7xkvhgeaGwXli-_4SB88QW63ykiegyaau2k,656
60
+ roborock/devices/traits/b01/q10/remote.py,sha256=8-fg4SA5M-tdMufHPeD9hIcax7y9Qh7pzcE7rBBVW_o,1267
61
+ roborock/devices/traits/b01/q10/status.py,sha256=JAPixbsQ8jfN3OvI7o_NxISXFk8l7rGHFYcL4pH97Jk,859
62
+ roborock/devices/traits/b01/q10/vacuum.py,sha256=w2FftNH5n-Tb-9drR3-5OiMy85FJfTDNmtt34LklC8w,4374
63
+ roborock/devices/traits/b01/q10/volume.py,sha256=UnT0yYDQntxYwnpv4_NVychm0FnGAYRAWA_FJR4yJag,1001
56
64
  roborock/devices/traits/b01/q7/__init__.py,sha256=gHgIK-KBLDJDEneBmSJfTa38sumj1IuZl-lcvoZeYLY,8116
57
65
  roborock/devices/traits/b01/q7/clean_summary.py,sha256=Tod29LUHBsOS0XL8SU1SdBQEHWTCifGZVCwSWGhYpy8,2933
58
66
  roborock/devices/traits/b01/q7/map.py,sha256=Zrvj8LNctIFksCBoiZVQ1x4hgG3wwp1jqPY-If__JXw,1489
@@ -101,8 +109,8 @@ roborock/protocols/a01_protocol.py,sha256=JCIhUuVNamcENu0gtZR2x8rBz6dpAZLcbA-NQg
101
109
  roborock/protocols/b01_q10_protocol.py,sha256=Ac3_VHJ2DAwBiXFMPYOHExEV0prxS9Ju0djiC8nKtiI,4935
102
110
  roborock/protocols/b01_q7_protocol.py,sha256=o8repRrxhxknzspsg1K27ds643rs87ilWXbj8RJtOCs,4619
103
111
  roborock/protocols/v1_protocol.py,sha256=uA-EMXd1AUBAXjkjAm0VxZlPLre0v2X9Kei5UqyPzaY,11141
104
- python_roborock-5.20.1.dist-info/METADATA,sha256=3EoWV62lgSj-6Cu9c8ve9cZjRlRC2--VgTDynMbEmtg,5281
105
- python_roborock-5.20.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
106
- python_roborock-5.20.1.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
107
- python_roborock-5.20.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
108
- python_roborock-5.20.1.dist-info/RECORD,,
112
+ python_roborock-5.22.0.dist-info/METADATA,sha256=5-phrqS20MJVmIfW1RzPIpiWW2LhAJOra6wFGM9V7ho,5281
113
+ python_roborock-5.22.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
114
+ python_roborock-5.22.0.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
115
+ python_roborock-5.22.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
116
+ python_roborock-5.22.0.dist-info/RECORD,,
roborock/cli.py CHANGED
@@ -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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Many of these classes use the `field(metadata={"dps": ...})` convention to map
4
4
  dataclass fields to device Data Points (DPS). This metadata is utilized by the
5
- `update_from_dps` helper in `roborock.devices.traits.b01.q10.common` to
5
+ `UpdatableTrait` helper in `roborock.devices.traits.b01.q10.common` to
6
6
  automatically update objects from raw device responses.
7
7
  """
8
8
 
@@ -11,9 +11,13 @@ from dataclasses import dataclass, field
11
11
  from ..containers import RoborockBase
12
12
  from .b01_q10_code_mappings import (
13
13
  B01_Q10_DP,
14
+ YXAreaUnit,
14
15
  YXBackType,
16
+ YXCarpetCleanType,
17
+ YXCleanLine,
15
18
  YXCleanType,
16
19
  YXDeviceCleanTask,
20
+ YXDeviceDustCollectionFrequency,
17
21
  YXDeviceState,
18
22
  YXFanLevel,
19
23
  YXWaterLevel,
@@ -51,18 +55,24 @@ class dpSelfIdentifyingCarpet(RoborockBase):
51
55
 
52
56
  @dataclass
53
57
  class dpNetInfo(RoborockBase):
54
- wifiName: str
55
- ipAdress: str
56
- mac: str
57
- signal: int
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
58
68
 
59
69
 
60
70
  @dataclass
61
71
  class dpNotDisturbExpand(RoborockBase):
62
- disturb_dust_enable: int
63
- disturb_light: int
64
- disturb_resume_clean: int
65
- disturb_voice: int
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
66
76
 
67
77
 
68
78
  @dataclass
@@ -77,16 +87,18 @@ class dpVoiceVersion(RoborockBase):
77
87
 
78
88
  @dataclass
79
89
  class dpTimeZone(RoborockBase):
80
- timeZoneCity: str
81
- timeZoneSec: int
90
+ time_zone_city: str | None = None
91
+ time_zone_sec: int | None = None
82
92
 
83
93
 
84
94
  @dataclass
85
95
  class Q10Status(RoborockBase):
86
- """Status for Q10 devices.
96
+ """Core vacuum status for Q10 devices.
87
97
 
88
98
  Fields are mapped to DPS values using metadata. Objects of this class can be
89
- automatically updated using the `update_from_dps` helper.
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.
90
102
  """
91
103
 
92
104
  clean_time: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TIME})
@@ -99,12 +111,101 @@ class Q10Status(RoborockBase):
99
111
  total_clean_area: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_AREA})
100
112
  total_clean_count: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_COUNT})
101
113
  total_clean_time: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_TIME})
102
- main_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MAIN_BRUSH_LIFE})
103
- side_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SIDE_BRUSH_LIFE})
104
- filter_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FILTER_LIFE})
105
- sensor_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SENSOR_LIFE})
106
114
  clean_mode: YXCleanType | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_MODE})
107
115
  clean_task_type: YXDeviceCleanTask | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TASK_TYPE})
108
116
  back_type: YXBackType | None = field(default=None, metadata={"dps": B01_Q10_DP.BACK_TYPE})
109
117
  cleaning_progress: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_PROGRESS})
110
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]:
@@ -10,15 +10,30 @@ from roborock.devices.transport.mqtt_channel import MqttChannel
10
10
  from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket
11
11
  from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message
12
12
 
13
+ from .button_light import ButtonLightTrait
14
+ from .child_lock import ChildLockTrait
13
15
  from .command import CommandTrait
16
+ from .consumable import ConsumableTrait
17
+ from .do_not_disturb import DoNotDisturbTrait
18
+ from .dust_collection import DustCollectionTrait
14
19
  from .map import MapContentTrait
20
+ from .network_info import NetworkInfoTrait
15
21
  from .remote import RemoteTrait
16
22
  from .status import StatusTrait
17
23
  from .vacuum import VacuumTrait
24
+ from .volume import SoundVolumeTrait
18
25
 
19
26
  __all__ = [
20
27
  "Q10PropertiesApi",
28
+ "ButtonLightTrait",
29
+ "ChildLockTrait",
30
+ "ConsumableTrait",
31
+ "DoNotDisturbTrait",
32
+ "DustCollectionTrait",
21
33
  "MapContentTrait",
34
+ "NetworkInfoTrait",
35
+ "SoundVolumeTrait",
36
+ "StatusTrait",
22
37
  ]
23
38
 
24
39
  _LOGGER = logging.getLogger(__name__)
@@ -31,7 +46,7 @@ class Q10PropertiesApi(Trait):
31
46
  """Trait for sending commands to Q10 devices."""
32
47
 
33
48
  status: StatusTrait
34
- """Trait for managing the status of Q10 devices."""
49
+ """Trait for managing the core status of Q10 devices."""
35
50
 
36
51
  vacuum: VacuumTrait
37
52
  """Trait for sending vacuum related commands to Q10 devices."""
@@ -39,6 +54,27 @@ class Q10PropertiesApi(Trait):
39
54
  remote: RemoteTrait
40
55
  """Trait for sending remote control related commands to Q10 devices."""
41
56
 
57
+ volume: SoundVolumeTrait
58
+ """Trait for reading / setting the speaker volume."""
59
+
60
+ child_lock: ChildLockTrait
61
+ """Trait for reading / controlling the child lock."""
62
+
63
+ do_not_disturb: DoNotDisturbTrait
64
+ """Trait for reading / controlling Do Not Disturb."""
65
+
66
+ dust_collection: DustCollectionTrait
67
+ """Trait for reading / controlling dock auto-empty (dust collection)."""
68
+
69
+ button_light: ButtonLightTrait
70
+ """Trait for controlling the indicator / button light (LED)."""
71
+
72
+ network_info: NetworkInfoTrait
73
+ """Trait exposing the device's network information."""
74
+
75
+ consumable: ConsumableTrait
76
+ """Trait exposing remaining life of consumables."""
77
+
42
78
  map: MapContentTrait
43
79
  """Trait for fetching the current parsed map (image + rooms)."""
44
80
 
@@ -49,7 +85,24 @@ class Q10PropertiesApi(Trait):
49
85
  self.vacuum = VacuumTrait(self.command)
50
86
  self.remote = RemoteTrait(self.command)
51
87
  self.status = StatusTrait()
88
+ self.volume = SoundVolumeTrait(self.command)
89
+ self.child_lock = ChildLockTrait(self.command)
90
+ self.do_not_disturb = DoNotDisturbTrait(self.command)
91
+ self.dust_collection = DustCollectionTrait(self.command)
92
+ self.button_light = ButtonLightTrait(self.command)
93
+ self.network_info = NetworkInfoTrait()
94
+ self.consumable = ConsumableTrait()
52
95
  self.map = MapContentTrait()
96
+ # Read-model traits updated from the device's DPS push stream.
97
+ self._updatable_traits = [
98
+ self.status,
99
+ self.volume,
100
+ self.child_lock,
101
+ self.do_not_disturb,
102
+ self.dust_collection,
103
+ self.network_info,
104
+ self.consumable,
105
+ ]
53
106
  self._subscribe_task: asyncio.Task[None] | None = None
54
107
 
55
108
  async def start(self) -> None:
@@ -83,7 +136,7 @@ class Q10PropertiesApi(Trait):
83
136
  Map and trace packets arrive as protocol-301 ``MAP_RESPONSE`` pushes (the
84
137
  Q10 is entirely push-driven: there is no synchronous get-map request, a
85
138
  ``dpRequestDps`` just nudges the device to publish its current map). DPS
86
- updates feed the status trait. More traits can be dispatched here below.
139
+ updates feed the read-model traits. More traits can be dispatched here below.
87
140
  """
88
141
  if isinstance(message, Q10MapPacket):
89
142
  self.map.update_from_map_packet(message)
@@ -91,7 +144,10 @@ class Q10PropertiesApi(Trait):
91
144
  self.map.update_from_trace_packet(message)
92
145
  elif isinstance(message, Q10DpsUpdate):
93
146
  _LOGGER.debug("Received Q10 status update: %s", message.dps)
94
- self.status.update_from_dps(message.dps)
147
+ # Notify all read-model traits about the new message; each trait
148
+ # only updates the fields that it is responsible for.
149
+ for trait in self._updatable_traits:
150
+ trait.update_from_dps(message.dps)
95
151
 
96
152
 
97
153
  def create(channel: MqttChannel) -> Q10PropertiesApi:
@@ -0,0 +1,29 @@
1
+ """Indicator / button light (LED) trait for Q10 B01 devices."""
2
+
3
+ from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
4
+
5
+ from .command import CommandTrait
6
+
7
+
8
+ class ButtonLightTrait:
9
+ """Trait for controlling the indicator / button light (LED) of a Q10 device.
10
+
11
+ The device does not report the button-light state in its status dump, so
12
+ this trait is write-only (no read-back).
13
+ """
14
+
15
+ def __init__(self, command: CommandTrait) -> None:
16
+ """Initialize the button light trait."""
17
+ self._command = command
18
+
19
+ async def _write(self, value: int) -> None:
20
+ """Write the button-light data point via the dpCommon (101) wrapper."""
21
+ await self._command.send(B01_Q10_DP.COMMON, {str(B01_Q10_DP.BUTTON_LIGHT_SWITCH.code): value})
22
+
23
+ async def enable(self) -> None:
24
+ """Turn the indicator light on."""
25
+ await self._write(1)
26
+
27
+ async def disable(self) -> None:
28
+ """Turn the indicator light off."""
29
+ await self._write(0)
@@ -0,0 +1,36 @@
1
+ """Child lock trait for Q10 B01 devices."""
2
+
3
+ import logging
4
+
5
+ from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
6
+ from roborock.data.b01_q10.b01_q10_containers import ChildLock
7
+ from roborock.devices.traits.common import DpsDataConverter
8
+
9
+ from .command import CommandTrait
10
+ from .common import UpdatableTrait
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ class ChildLockTrait(ChildLock, UpdatableTrait):
16
+ """Trait for reading and controlling the child lock of a Q10 device."""
17
+
18
+ _CONVERTER = DpsDataConverter.from_dataclass(ChildLock)
19
+
20
+ def __init__(self, command: CommandTrait) -> None:
21
+ """Initialize the child lock trait."""
22
+ ChildLock.__init__(self)
23
+ UpdatableTrait.__init__(self, command, _LOGGER)
24
+
25
+ @property
26
+ def is_on(self) -> bool:
27
+ """Return whether the child lock is enabled."""
28
+ return bool(self.child_lock)
29
+
30
+ async def enable(self) -> None:
31
+ """Enable the child lock."""
32
+ await self._write(B01_Q10_DP.CHILD_LOCK, 1)
33
+
34
+ async def disable(self) -> None:
35
+ """Disable the child lock."""
36
+ await self._write(B01_Q10_DP.CHILD_LOCK, 0)
@@ -0,0 +1,70 @@
1
+ """Common helpers for Q10 B01 traits.
2
+
3
+ Q10 devices push their full state as a single decoded DPS dictionary (see
4
+ ``Q10PropertiesApi._subscribe_loop``). Each trait owns a small ``RoborockBase``
5
+ read-model whose fields are annotated with ``field(metadata={"dps": ...})`` and
6
+ only updates the fields it is responsible for, ignoring the rest.
7
+
8
+ The :class:`UpdatableTrait` base wires that read-model to the update lifecycle
9
+ and (for traits that also write) exposes the ``dpCommon`` (101) wrapper used by
10
+ Q10 setting writes.
11
+ """
12
+
13
+ import logging
14
+ from typing import Any, ClassVar, cast
15
+
16
+ from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
17
+ from roborock.data.containers import RoborockBase
18
+ from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
19
+
20
+ from .command import CommandTrait
21
+
22
+
23
+ class UpdatableTrait(TraitUpdateListener):
24
+ """Base for Q10 traits backed by a read-model updated from the DPS stream.
25
+
26
+ Concrete traits subclass both their ``RoborockBase`` read-model and this
27
+ class, set the ``_CONVERTER`` class attribute, and initialize the read-model
28
+ explicitly in their constructor, e.g.::
29
+
30
+ class SoundVolumeTrait(SoundVolume, UpdatableTrait):
31
+ _CONVERTER = DpsDataConverter.from_dataclass(SoundVolume)
32
+
33
+ def __init__(self, command: CommandTrait) -> None:
34
+ SoundVolume.__init__(self)
35
+ UpdatableTrait.__init__(self, command, _LOGGER)
36
+
37
+ The read-model init is called explicitly (rather than via ``super()``)
38
+ because the read-model dataclass precedes this class in the MRO.
39
+
40
+ Traits that also send commands receive a :class:`CommandTrait` and use the
41
+ :meth:`_write` helper, which wraps the write in the ``dpCommon`` (101) data
42
+ point as the device requires.
43
+ """
44
+
45
+ _CONVERTER: ClassVar[DpsDataConverter]
46
+
47
+ def __init__(self, command: CommandTrait | None, logger: logging.Logger) -> None:
48
+ """Initialize the update listener. The read-model is initialized by the subclass."""
49
+ TraitUpdateListener.__init__(self, logger=logger)
50
+ self._command = command
51
+
52
+ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
53
+ """Update the trait's read-model from raw DPS data and notify listeners.
54
+
55
+ Concrete traits also subclass a ``RoborockBase`` read-model, so the cast
56
+ is always valid at runtime.
57
+ """
58
+ if self._CONVERTER.update_from_dps(cast(RoborockBase, self), decoded_dps):
59
+ self._notify_update()
60
+
61
+ async def _write(self, dp: B01_Q10_DP, value: int) -> None:
62
+ """Write a single data point value via the dpCommon (101) wrapper.
63
+
64
+ Q10 setting writes must be wrapped in ``dpCommon`` (101), e.g. setting
65
+ the volume sends ``{"dps": {"101": {"26": <value>}}}``. Writing the bare
66
+ data point (without the wrapper) is silently ignored by the device.
67
+ """
68
+ if self._command is None:
69
+ raise ValueError("Trait is read-only; no command channel was provided")
70
+ await self._command.send(B01_Q10_DP.COMMON, {str(dp.code): value})
@@ -0,0 +1,21 @@
1
+ """Consumable / accessory life trait for Q10 B01 devices."""
2
+
3
+ import logging
4
+
5
+ from roborock.data.b01_q10.b01_q10_containers import Q10Consumable
6
+ from roborock.devices.traits.common import DpsDataConverter
7
+
8
+ from .common import UpdatableTrait
9
+
10
+ _LOGGER = logging.getLogger(__name__)
11
+
12
+
13
+ class ConsumableTrait(Q10Consumable, UpdatableTrait):
14
+ """Trait exposing remaining life of consumables (brushes, filter, sensors)."""
15
+
16
+ _CONVERTER = DpsDataConverter.from_dataclass(Q10Consumable)
17
+
18
+ def __init__(self) -> None:
19
+ """Initialize the consumable trait."""
20
+ Q10Consumable.__init__(self)
21
+ UpdatableTrait.__init__(self, command=None, logger=_LOGGER)
@@ -0,0 +1,36 @@
1
+ """Do Not Disturb trait for Q10 B01 devices."""
2
+
3
+ import logging
4
+
5
+ from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
6
+ from roborock.data.b01_q10.b01_q10_containers import DoNotDisturb
7
+ from roborock.devices.traits.common import DpsDataConverter
8
+
9
+ from .command import CommandTrait
10
+ from .common import UpdatableTrait
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ class DoNotDisturbTrait(DoNotDisturb, UpdatableTrait):
16
+ """Trait for reading and controlling Do Not Disturb on a Q10 device."""
17
+
18
+ _CONVERTER = DpsDataConverter.from_dataclass(DoNotDisturb)
19
+
20
+ def __init__(self, command: CommandTrait) -> None:
21
+ """Initialize the Do Not Disturb trait."""
22
+ DoNotDisturb.__init__(self)
23
+ UpdatableTrait.__init__(self, command, _LOGGER)
24
+
25
+ @property
26
+ def is_on(self) -> bool:
27
+ """Return whether Do Not Disturb is enabled."""
28
+ return bool(self.not_disturb)
29
+
30
+ async def enable(self) -> None:
31
+ """Enable Do Not Disturb."""
32
+ await self._write(B01_Q10_DP.NOT_DISTURB, 1)
33
+
34
+ async def disable(self) -> None:
35
+ """Disable Do Not Disturb."""
36
+ await self._write(B01_Q10_DP.NOT_DISTURB, 0)
@@ -0,0 +1,43 @@
1
+ """Dust collection (dock auto-empty) trait for Q10 B01 devices."""
2
+
3
+ import logging
4
+
5
+ from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceDustCollectionFrequency
6
+ from roborock.data.b01_q10.b01_q10_containers import DustCollection
7
+ from roborock.devices.traits.common import DpsDataConverter
8
+
9
+ from .command import CommandTrait
10
+ from .common import UpdatableTrait
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ class DustCollectionTrait(DustCollection, UpdatableTrait):
16
+ """Trait for reading and controlling automatic dust collection at the dock."""
17
+
18
+ _CONVERTER = DpsDataConverter.from_dataclass(DustCollection)
19
+
20
+ def __init__(self, command: CommandTrait) -> None:
21
+ """Initialize the dust collection trait."""
22
+ DustCollection.__init__(self)
23
+ UpdatableTrait.__init__(self, command, _LOGGER)
24
+
25
+ @property
26
+ def is_on(self) -> bool:
27
+ """Return whether automatic dust collection is enabled."""
28
+ return bool(self.dust_switch)
29
+
30
+ async def enable(self) -> None:
31
+ """Enable automatic dust collection at the dock."""
32
+ await self._write(B01_Q10_DP.DUST_SWITCH, 1)
33
+
34
+ async def disable(self) -> None:
35
+ """Disable automatic dust collection at the dock."""
36
+ await self._write(B01_Q10_DP.DUST_SWITCH, 0)
37
+
38
+ async def set_frequency(self, frequency: YXDeviceDustCollectionFrequency) -> None:
39
+ """Set how often the dock empties the bin.
40
+
41
+ The value is the interval in cleans, with ``DAILY`` (0) meaning daily.
42
+ """
43
+ await self._write(B01_Q10_DP.DUST_SETTING, frequency.code)
@@ -0,0 +1,21 @@
1
+ """Network information trait for Q10 B01 devices."""
2
+
3
+ import logging
4
+
5
+ from roborock.data.b01_q10.b01_q10_containers import Q10NetworkInfo
6
+ from roborock.devices.traits.common import DpsDataConverter
7
+
8
+ from .common import UpdatableTrait
9
+
10
+ _LOGGER = logging.getLogger(__name__)
11
+
12
+
13
+ class NetworkInfoTrait(Q10NetworkInfo, UpdatableTrait):
14
+ """Trait exposing the device's network information (read-only)."""
15
+
16
+ _CONVERTER = DpsDataConverter.from_dataclass(Q10NetworkInfo)
17
+
18
+ def __init__(self) -> None:
19
+ """Initialize the network info trait."""
20
+ Q10NetworkInfo.__init__(self)
21
+ UpdatableTrait.__init__(self, command=None, logger=_LOGGER)
@@ -20,7 +20,7 @@ class RemoteTrait:
20
20
  self._command = command
21
21
 
22
22
  async def _send_remote(self, action: RemoteCommand) -> None:
23
- await self._command.send(B01_Q10_DP.COMMON, params={B01_Q10_DP.REMOTE: action.value})
23
+ await self._command.send(B01_Q10_DP.COMMON, params={str(B01_Q10_DP.REMOTE.code): action.value})
24
24
 
25
25
  async def forward(self) -> None:
26
26
  """Move forward."""
@@ -1,31 +1,26 @@
1
1
  """Status trait for Q10 B01 devices."""
2
2
 
3
3
  import logging
4
- from typing import Any
5
4
 
6
- from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
7
5
  from roborock.data.b01_q10.b01_q10_containers import Q10Status
8
- from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
6
+ from roborock.devices.traits.common import DpsDataConverter
9
7
 
10
- _LOGGER = logging.getLogger(__name__)
8
+ from .common import UpdatableTrait
11
9
 
12
- _CONVERTER = DpsDataConverter.from_dataclass(Q10Status)
10
+ _LOGGER = logging.getLogger(__name__)
13
11
 
14
12
 
15
- class StatusTrait(Q10Status, TraitUpdateListener):
16
- """Trait for managing the status of Q10 Roborock devices.
13
+ class StatusTrait(Q10Status, UpdatableTrait):
14
+ """Trait for managing the core status of Q10 Roborock devices.
17
15
 
18
16
  This is a thin wrapper around Q10Status that provides the Trait interface.
19
17
  The current values reflect the most recently received data from the device.
20
18
  New values can be requested through the `Q10PropertiesApi`'s `refresh` method.
21
19
  """
22
20
 
21
+ _CONVERTER = DpsDataConverter.from_dataclass(Q10Status)
22
+
23
23
  def __init__(self) -> None:
24
24
  """Initialize the status trait."""
25
- super().__init__()
26
- TraitUpdateListener.__init__(self, logger=_LOGGER)
27
-
28
- def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
29
- """Update the trait from raw DPS data."""
30
- if _CONVERTER.update_from_dps(self, decoded_dps):
31
- self._notify_update()
25
+ Q10Status.__init__(self)
26
+ UpdatableTrait.__init__(self, command=None, logger=_LOGGER)
@@ -3,6 +3,7 @@
3
3
  from roborock.data.b01_q10.b01_q10_code_mappings import (
4
4
  B01_Q10_DP,
5
5
  YXCleanType,
6
+ YXDeviceCleanTask,
6
7
  YXFanLevel,
7
8
  )
8
9
 
@@ -21,50 +22,77 @@ class VacuumTrait:
21
22
  self._command = command
22
23
 
23
24
  async def start_clean(self) -> None:
24
- """Start cleaning."""
25
+ """Start a whole-home clean.
26
+
27
+ The ``dpStartClean`` (201) command selects a task by code: ``1`` =
28
+ whole-home, ``2`` = segment/room (see :meth:`clean_segments`), ``3`` =
29
+ zone, ``4`` = build map, ``5`` = spot. Whole-home and spot accept the
30
+ bare integer code; segment cleaning needs a room selection (an object
31
+ payload) instead.
32
+
33
+ Verified live against ss07 hardware: ``{"dps": {"201": 1}}`` starts a
34
+ whole-home clean (clean_task_type -> 1).
35
+ """
36
+ await self._command.send(command=B01_Q10_DP.START_CLEAN, params=1)
37
+
38
+ async def clean_segments(self, segment_ids: list[int]) -> None:
39
+ """Start a room / segment clean for the given segment (room) ids.
40
+
41
+ The ids are the same room ids the device reports on its map (see the Q10
42
+ ``MapContentTrait`` -- ``map.rooms``, each with an ``id``).
43
+
44
+ Unlike whole-home and spot, ``dpStartClean`` (201) carries the room
45
+ selection as an object: ``{"cmd": <task>, "clean_paramters": [<id>, ...]}``,
46
+ where ``cmd`` is the segment-clean task code.
47
+
48
+ Verified live against ss07 hardware: sending
49
+ ``{"dps": {"201": {"cmd": 2, "clean_paramters": [9]}}}`` starts cleaning
50
+ room 9 (clean_task_type -> 2 / electoral).
51
+ """
25
52
  await self._command.send(
26
53
  command=B01_Q10_DP.START_CLEAN,
27
- # TODO: figure out other commands
28
- # 1 = start cleaning
29
- # 2 = "electoral" clean, also has "clean_parameters"
30
- # 4 = fast create map
31
- params={"cmd": 1},
54
+ # "clean_paramters" intentionally mirrors the device's misspelling of
55
+ # "parameters" -- the firmware only accepts that exact key.
56
+ params={"cmd": YXDeviceCleanTask.ELECTORAL.code, "clean_paramters": segment_ids},
32
57
  )
33
58
 
59
+ async def spot_clean(self) -> None:
60
+ """Start a spot / part clean around the robot's current position.
61
+
62
+ Verified live: ``{"dps": {"201": 5}}`` (clean_task_type -> 5).
63
+ """
64
+ await self._command.send(command=B01_Q10_DP.START_CLEAN, params=5)
65
+
34
66
  async def pause_clean(self) -> None:
35
- """Pause cleaning."""
36
- await self._command.send(
37
- command=B01_Q10_DP.PAUSE,
38
- params={},
39
- )
67
+ """Pause the current task. Verified live: ``{"dps": {"204": 0}}``."""
68
+ await self._command.send(command=B01_Q10_DP.PAUSE, params=0)
40
69
 
41
70
  async def resume_clean(self) -> None:
42
- """Resume cleaning."""
43
- await self._command.send(
44
- command=B01_Q10_DP.RESUME,
45
- params={},
46
- )
71
+ """Resume a paused task. Verified live: ``{"dps": {"205": 0}}``."""
72
+ await self._command.send(command=B01_Q10_DP.RESUME, params=0)
47
73
 
48
74
  async def stop_clean(self) -> None:
49
- """Stop cleaning."""
50
- await self._command.send(
51
- command=B01_Q10_DP.STOP,
52
- params={},
53
- )
75
+ """Stop / cancel the current task. Verified live: ``{"dps": {"206": 0}}``."""
76
+ await self._command.send(command=B01_Q10_DP.STOP, params=0)
54
77
 
55
78
  async def return_to_dock(self) -> None:
56
- """Return to dock."""
57
- await self._command.send(
58
- command=B01_Q10_DP.START_DOCK_TASK,
59
- params={},
60
- )
79
+ """Send the robot back to the dock to charge.
80
+
81
+ Uses ``dpStartBack`` (202) with the back-dock task code ``5`` (charge),
82
+ matching the official app. Verified live: ``{"dps": {"202": 5}}`` puts the
83
+ robot into the returning state. (The other back-dock codes are ``1`` =
84
+ wash mop en route and ``4`` = collect dust en route.)
85
+ """
86
+ await self._command.send(command=B01_Q10_DP.START_BACK, params=5)
61
87
 
62
88
  async def empty_dustbin(self) -> None:
63
- """Empty the dustbin at the dock."""
64
- await self._command.send(
65
- command=B01_Q10_DP.START_DOCK_TASK,
66
- params=2, # 2 = dock task type for "empty dustbin"
67
- )
89
+ """Empty the dustbin at the dock.
90
+
91
+ Verified live: ``{"dps": {"203": 2}}`` triggers dust collection
92
+ (status -> emptying_the_bin). This is a dock task (``dpStartDockTask``),
93
+ distinct from the en-route collect-dust back-dock code.
94
+ """
95
+ await self._command.send(command=B01_Q10_DP.START_DOCK_TASK, params=2)
68
96
 
69
97
  async def set_clean_mode(self, mode: YXCleanType) -> None:
70
98
  """Set the cleaning mode (vacuum, mop, or both)."""
@@ -0,0 +1,29 @@
1
+ """Sound volume trait for Q10 B01 devices."""
2
+
3
+ import logging
4
+
5
+ from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
6
+ from roborock.data.b01_q10.b01_q10_containers import SoundVolume
7
+ from roborock.devices.traits.common import DpsDataConverter
8
+
9
+ from .command import CommandTrait
10
+ from .common import UpdatableTrait
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ class SoundVolumeTrait(SoundVolume, UpdatableTrait):
16
+ """Trait for reading and setting the speaker volume of a Q10 device."""
17
+
18
+ _CONVERTER = DpsDataConverter.from_dataclass(SoundVolume)
19
+
20
+ def __init__(self, command: CommandTrait) -> None:
21
+ """Initialize the volume trait."""
22
+ SoundVolume.__init__(self)
23
+ UpdatableTrait.__init__(self, command, _LOGGER)
24
+
25
+ async def set_volume(self, volume: int) -> None:
26
+ """Set the speaker volume (0-100)."""
27
+ if not 0 <= volume <= 100:
28
+ raise ValueError("volume must be between 0 and 100")
29
+ await self._write(B01_Q10_DP.VOLUME, volume)