From 93b921e4190b90066a8d6d8b2128267681230bcb Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:33:44 +0400 Subject: [PATCH 01/12] feat: expand Q10 (B01/ss07) status support and add device info Verified against two physical Roborock Q10 (roborock.vacuum.ss07) devices: - Add the roborock.vacuum.ss07 schema to device_info.yaml (captured via `roborock get-device-info`), per CONTRIBUTING. - Expand Q10Status to capture the ~25 additional fields the device reports in its full status dump (volume, child_lock, DND, net_info, time_zone, etc.). - Fix dpNetInfo/dpTimeZone container field names so the decamelized device keys actually parse (they previously failed to construct). - Add a CLI `status` path for B01 Q10 devices, which refreshes and prints the decoded status (previously `status` only worked for V1 devices). - Add a second real (scrubbed) full-status protocol fixture and extend the status trait tests to cover the new fields. --- device_info.yaml | 161 ++++++++++++++++++ roborock/cli.py | 40 ++++- roborock/data/b01_q10/b01_q10_containers.py | 54 ++++-- tests/devices/traits/b01/q10/test_status.py | 45 +++++ .../__snapshots__/test_b01_q10_protocol.ambr | 63 +++++++ .../dpRequestDps_vacuum_only.json | 1 + 6 files changed, 350 insertions(+), 14 deletions(-) create mode 100644 tests/protocols/testdata/b01_q10_protocol/dpRequestDps_vacuum_only.json diff --git a/device_info.yaml b/device_info.yaml index df22af85..3034065a 100644 --- a/device_info.yaml +++ b/device_info.yaml @@ -1348,3 +1348,164 @@ roborock.vacuum.a288: code: map_diff mode: ro type: RAW + +roborock.vacuum.ss07: + protocol_version: B01 + product_nickname: PEARLPLUS + product: + id: 3gKDz7BnDrjXOVBAEXj4uw + name: Roborock Q10 Series + model: roborock.vacuum.ss07 + category: robot.vacuum.cleaner + capability: 0 + schema: + - id: 101 + name: RPC Request + code: rpc_request + mode: rw + type: RAW + property: 'null' + - id: 102 + name: RPC Response + code: rpc_response + mode: rw + type: RAW + property: 'null' + - id: 120 + name: "\u9519\u8BEF\u4EE3\u7801" + code: error_code + mode: ro + type: ENUM + property: '{"range": []}' + - id: 121 + name: "\u8BBE\u5907\u72B6\u6001" + code: state + mode: ro + type: VALUE + property: 'null' + - id: 122 + name: "\u8BBE\u5907\u7535\u91CF" + code: battery + mode: ro + type: ENUM + property: '{"range": []}' + - id: 123 + name: "\u5438\u529B\u6863\u4F4D" + code: fan_power + mode: rw + type: ENUM + property: '{"range": []}' + - id: 124 + name: "\u62D6\u5730\u6863\u4F4D" + code: water_box_mode + mode: rw + type: RAW + property: 'null' + - id: 125 + name: "\u4E3B\u5237\u5BFF\u547D" + code: main_brush_life + mode: ro + type: ENUM + property: '{"range": []}' + - id: 126 + name: "\u8FB9\u5237\u5BFF\u547D" + code: side_brush_life + mode: ro + type: ENUM + property: '{"range": []}' + - id: 127 + name: "\u6EE4\u7F51\u5BFF\u547D" + code: filter_life + mode: ro + type: ENUM + property: '{"range": []}' + - id: 135 + name: "\u79BB\u7EBF\u539F\u56E0" + code: offline_status + mode: ro + type: ENUM + property: '{"range": []}' + - id: 136 + name: "\u6E05\u6D01\u6B21\u6570" + code: clean_times + mode: rw + type: ENUM + property: '{"range": []}' + - id: 137 + name: "\u626B\u62D6\u6A21\u5F0F" + code: cleaning_preference + mode: rw + type: ENUM + property: '{"range": []}' + - id: 138 + name: "\u6E05\u6D01\u4EFB\u52A1\u7C7B\u578B" + code: clean_task_type + mode: ro + type: ENUM + property: '{"range": []}' + - id: 139 + name: "\u8FD4\u56DE\u57FA\u7AD9\u7C7B\u578B" + code: back_type + mode: ro + type: ENUM + property: '{"range": []}' + - id: 140 + name: "\u57FA\u7AD9\u4EFB\u52A1\u7C7B\u578B" + code: dock_task_type + mode: ro + type: ENUM + property: '{"range": []}' + - id: 141 + name: "\u6E05\u6D01\u8FDB\u5EA6" + code: cleaning_progress + mode: ro + type: ENUM + property: '{"range": []}' + - id: 142 + name: "\u7A9C\u8D27\u4FE1\u606F" + code: fc_state + mode: ro + type: RAW + property: 'null' + - id: 201 + name: "\u542F\u52A8\u6E05\u6D01\u4EFB\u52A1" + code: start_clean_task + mode: wo + type: ENUM + property: '{"range": []}' + - id: 202 + name: "\u8FD4\u56DE\u57FA\u7AD9\u4EFB\u52A1" + code: start_back_dock_task + mode: wo + type: ENUM + property: '{"range": []}' + - id: 203 + name: "\u542F\u52A8\u57FA\u7AD9\u4EFB\u52A1" + code: start_dock_task + mode: wo + type: ENUM + property: '{"range": []}' + - id: 204 + name: "\u6682\u505C\u4EFB\u52A1" + code: pause + mode: wo + type: RAW + property: 'null' + - id: 205 + name: "\u7EE7\u7EED\u4EFB\u52A1" + code: resume + mode: wo + type: RAW + property: 'null' + - id: 206 + name: "\u7ED3\u675F\u4EFB\u52A1" + code: stop + mode: wo + type: RAW + property: 'null' + - id: 207 + name: "\u7528\u6237\u6539\u5584\u8BA1\u5212" + code: ceip + mode: rw + type: ENUM + property: '{"range": ["0,1"]}' diff --git a/roborock/cli.py b/roborock/cli.py index b36b11ce..d62e1056 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -50,6 +50,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import DeviceManager, UserParams, create_device_manager from roborock.devices.traits import Trait +from roborock.devices.traits.b01.q10 import Q10PropertiesApi from roborock.devices.traits.b01.q10.vacuum import VacuumTrait from roborock.devices.traits.v1 import V1TraitMixin from roborock.devices.traits.v1.consumeable import ConsumableAttribute @@ -439,13 +440,37 @@ async def _display_v1_trait(context: RoborockContext, device_id: str, display_fu click.echo(dump_json(trait.as_dict())) -async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumTrait: - """Get VacuumTrait from Q10 device.""" +async def _q10_properties(context: RoborockContext, device_id: str) -> Q10PropertiesApi: + """Get the B01 Q10 properties API for a device.""" device_manager = await context.get_device_manager() device = await device_manager.get_device(device_id) if device.b01_q10_properties is None: raise RoborockUnsupportedFeature("Device does not support B01 Q10 protocol. Is it a Q10?") - return device.b01_q10_properties.vacuum + return device.b01_q10_properties + + +async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumTrait: + """Get VacuumTrait from Q10 device.""" + return (await _q10_properties(context, device_id)).vacuum + + +async def _display_q10_status(context: RoborockContext, device_id: str) -> None: + """Refresh and display the status of a B01 Q10 device. + + Unlike V1 devices, the Q10 reports its status asynchronously: ``refresh()`` + sends a request and the device streams the values back, so we poll the + status trait briefly until it has been populated. + """ + properties = await _q10_properties(context, device_id) + await properties.refresh() + for _ in range(50): + if properties.status.status is not None: + break + await asyncio.sleep(0.1) + else: + click.echo("Timed out waiting for status from device") + return + click.echo(dump_json(properties.status.as_dict())) @session.command() @@ -455,7 +480,14 @@ async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumT async def status(ctx, device_id: str): """Get device status.""" context: RoborockContext = ctx.obj - await _display_v1_trait(context, device_id, lambda v1: v1.status) + device_manager = await context.get_device_manager() + device = await device_manager.get_device(device_id) + if device.v1_properties is not None: + await _display_v1_trait(context, device_id, lambda v1: v1.status) + elif device.b01_q10_properties is not None: + await _display_q10_status(context, device_id) + else: + click.echo("Feature not supported by device") @session.command() diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index 393eb231..20203d2c 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -12,6 +12,7 @@ from .b01_q10_code_mappings import ( B01_Q10_DP, YXBackType, + YXCleanLine, YXCleanType, YXDeviceCleanTask, YXDeviceState, @@ -51,18 +52,21 @@ class dpSelfIdentifyingCarpet(RoborockBase): @dataclass class dpNetInfo(RoborockBase): - wifiName: str - ipAdress: str - mac: str - signal: int + # Field names are snake_case so they match the device's camelCase keys once + # `RoborockBase.from_dict` decamelizes them (e.g. "wifiName" -> "wifi_name"). + # The "ip_adress" spelling intentionally mirrors the device's "ipAdress" typo. + wifi_name: str | None = None + ip_adress: str | None = None + mac: str | None = None + signal: int | None = None @dataclass class dpNotDisturbExpand(RoborockBase): - disturb_dust_enable: int - disturb_light: int - disturb_resume_clean: int - disturb_voice: int + disturb_dust_enable: int | None = None + disturb_light: int | None = None + disturb_resume_clean: int | None = None + disturb_voice: int | None = None @dataclass @@ -77,8 +81,9 @@ class dpVoiceVersion(RoborockBase): @dataclass class dpTimeZone(RoborockBase): - timeZoneCity: str - timeZoneSec: int + # snake_case so the decamelized device keys ("timeZoneCity") map correctly. + time_zone_city: str | None = None + time_zone_sec: int | None = None @dataclass @@ -108,3 +113,32 @@ class Q10Status(RoborockBase): back_type: YXBackType | None = field(default=None, metadata={"dps": B01_Q10_DP.BACK_TYPE}) cleaning_progress: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_PROGRESS}) fault: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FAULT}) + + # Additional settings and state reported in the device's full status dump. + volume: int | None = field(default=None, metadata={"dps": B01_Q10_DP.VOLUME}) + not_disturb: int | None = field(default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB}) + not_disturb_expand: dpNotDisturbExpand | None = field(default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB_EXPAND}) + child_lock: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CHILD_LOCK}) + mop_state: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MOP_STATE}) + auto_boost: int | None = field(default=None, metadata={"dps": B01_Q10_DP.AUTO_BOOST}) + dust_switch: int | None = field(default=None, metadata={"dps": B01_Q10_DP.DUST_SWITCH}) + dust_setting: int | None = field(default=None, metadata={"dps": B01_Q10_DP.DUST_SETTING}) + map_save_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MAP_SAVE_SWITCH}) + multi_map_switch: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MULTI_MAP_SWITCH}) + recent_clean_record: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.RECENT_CLEAN_RECORD}) + breakpoint_clean: int | None = field(default=None, metadata={"dps": B01_Q10_DP.BREAKPOINT_CLEAN}) + valley_point_charging: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.VALLEY_POINT_CHARGING}) + carpet_clean_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CARPET_CLEAN_TYPE}) + clean_line: YXCleanLine | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_LINE}) + ground_clean: int | None = field(default=None, metadata={"dps": B01_Q10_DP.GROUND_CLEAN}) + line_laser_obstacle_avoidance: int | None = field( + default=None, metadata={"dps": B01_Q10_DP.LINE_LASER_OBSTACLE_AVOIDANCE} + ) + add_clean_state: int | None = field(default=None, metadata={"dps": B01_Q10_DP.ADD_CLEAN_STATE}) + timer_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TIMER_TYPE}) + user_plan: int | None = field(default=None, metadata={"dps": B01_Q10_DP.USER_PLAN}) + robot_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_TYPE}) + robot_country_code: str | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_COUNTRY_CODE}) + area_unit: int | None = field(default=None, metadata={"dps": B01_Q10_DP.AREA_UNIT}) + time_zone: dpTimeZone | None = field(default=None, metadata={"dps": B01_Q10_DP.TIME_ZONE}) + net_info: dpNetInfo | None = field(default=None, metadata={"dps": B01_Q10_DP.NET_INFO}) diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index 8e2588ad..e62cdb04 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -11,11 +11,14 @@ from roborock.data.b01_q10.b01_q10_code_mappings import ( B01_Q10_DP, + YXCleanLine, YXCleanType, YXDeviceCleanTask, YXDeviceState, YXFanLevel, + YXWaterLevel, ) +from roborock.data.b01_q10.b01_q10_containers import dpNetInfo, dpNotDisturbExpand, dpTimeZone from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol @@ -155,6 +158,48 @@ async def test_status_trait_refresh( assert q10_api.status.cleaning_progress == 100 assert q10_api.status.fault == 0 assert q10_api.status.clean_mode == YXCleanType.VAC_AND_MOP + assert q10_api.status.water_level == YXWaterLevel.LOW + + # Additional settings/state captured from the full status dump. + assert q10_api.status.volume == 74 + assert q10_api.status.not_disturb == 1 + assert q10_api.status.child_lock == 0 + assert q10_api.status.mop_state == 1 + assert q10_api.status.auto_boost == 0 + assert q10_api.status.dust_switch == 1 + assert q10_api.status.map_save_switch is True + assert q10_api.status.recent_clean_record is False + assert q10_api.status.valley_point_charging is False + assert q10_api.status.clean_line == YXCleanLine.FAST + assert q10_api.status.line_laser_obstacle_avoidance == 1 + assert q10_api.status.robot_country_code == "us" + assert q10_api.status.robot_type == 1 + + # Nested containers are parsed into their dataclasses. + assert q10_api.status.not_disturb_expand == dpNotDisturbExpand( + disturb_dust_enable=1, disturb_light=1, disturb_resume_clean=1, disturb_voice=1 + ) + assert q10_api.status.time_zone == dpTimeZone(time_zone_city="America/Los_Angeles", time_zone_sec=-28800) + assert q10_api.status.net_info == dpNetInfo( + wifi_name="wifi-network-name", ip_adress="1.1.1.2", mac="99:AA:88:BB:77:CC", signal=-50 + ) + + +async def test_status_trait_vacuum_only_refresh( + q10_api: Q10PropertiesApi, + message_queue: asyncio.Queue[RoborockMessage], +) -> None: + """Test decoding a full status dump from a vacuum-only (no mop) Q10.""" + payload = (TEST_DATA_DIR / "dpRequestDps_vacuum_only.json").read_bytes() + message_queue.put_nowait(build_message(payload)) + + await wait_for_attribute_value(q10_api.status, "battery", 75) + + assert q10_api.status.fan_level == YXFanLevel.MAX_PLUS + assert q10_api.status.water_level == YXWaterLevel.MEDIUM + assert q10_api.status.clean_mode == YXCleanType.VACUUM + assert q10_api.status.recent_clean_record is True + assert q10_api.status.total_clean_count == 7 def test_status_trait_update_listener(q10_api: Q10PropertiesApi) -> None: diff --git a/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr b/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr index 726d9203..a1c15e8c 100644 --- a/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr +++ b/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr @@ -6,6 +6,69 @@ } ''' # --- +# name: test_decode_rpc_payload[dpRequestDps_vacuum_only] + ''' + { + "dpStatus": 8, + "dpBattery": 75, + "dpFanLevel": 8, + "dpWaterLevel": 2, + "dpMainBrushLife": 2, + "dpSideBrushLife": 2, + "dpFilterLife": 2, + "dpCleanCount": 1, + "dpCleanMode": 2, + "dpCleanTaskType": 0, + "dpBackType": 5, + "dpBreakpointClean": 0, + "dpValleyPointCharging": false, + "dpRobotCountryCode": "us", + "dpUserPlan": 0, + "dpNotDisturb": 1, + "dpVolume": 70, + "dpTotalCleanArea": 72, + "dpTotalCleanCount": 7, + "dpTotalCleanTime": 111, + "dpDustSwitch": 1, + "dpMopState": 1, + "dpAutoBoost": 1, + "dpChildLock": 0, + "dpDustSetting": 0, + "dpMapSaveSwitch": true, + "dpRecentCleanRecord": true, + "dpCleanTime": 0, + "dpMultiMapSwitch": 1, + "dpSensorLife": 2, + "dpCleanArea": 0, + "dpCarpetCleanType": 0, + "dpCleanLine": 0, + "dpTimeZone": { + "timeZoneCity": "America/Los_Angeles", + "timeZoneSec": -28800 + }, + "dpAreaUnit": 0, + "dpNetInfo": { + "ipAdress": "1.1.1.2", + "mac": "99:AA:88:BB:77:CC", + "signal": -50, + "wifiName": "wifi-network-name" + }, + "dpRobotType": 1, + "dpLineLaserObstacleAvoidance": 1, + "dpCleanProgress": 100, + "dpGroundClean": 0, + "dpFault": 0, + "dpNotDisturbExpand": { + "disturb_dust_enable": 1, + "disturb_light": 1, + "disturb_resume_clean": 1, + "disturb_voice": 1 + }, + "dpTimerType": 0, + "dpAddCleanState": 0 + } + ''' +# --- # name: test_decode_rpc_payload[dpRequetdps] ''' { diff --git a/tests/protocols/testdata/b01_q10_protocol/dpRequestDps_vacuum_only.json b/tests/protocols/testdata/b01_q10_protocol/dpRequestDps_vacuum_only.json new file mode 100644 index 00000000..8d45eebc --- /dev/null +++ b/tests/protocols/testdata/b01_q10_protocol/dpRequestDps_vacuum_only.json @@ -0,0 +1 @@ +{"dps":{"101":{"104":0,"105":false,"109":"us","207":0,"25":1,"26":70,"29":72,"30":7,"31":111,"37":1,"40":1,"45":1,"47":0,"50":0,"51":true,"53":true,"6":0,"60":1,"67":2,"7":0,"76":0,"78":0,"79":{"timeZoneCity":"America/Los_Angeles","timeZoneSec":-28800},"80":0,"81":{"ipAdress":"1.1.1.2","mac":"99:AA:88:BB:77:CC","signal":-50,"wifiName":"wifi-network-name"},"83":1,"86":1,"87":100,"88":0,"90":0,"92":{"disturb_dust_enable":1,"disturb_light":1,"disturb_resume_clean":1,"disturb_voice":1},"93":0,"96":0},"121":8,"122":75,"123":8,"124":2,"125":2,"126":2,"127":2,"136":1,"137":2,"138":0,"139":5},"t":1766802312} From bc2413b07cffaaed6b5ab1242632e6dbb99f24df Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:25:00 +0400 Subject: [PATCH 02/12] feat: add Q10 (B01/ss07) settings writers Adds a SettingsTrait so Q10 settings the status trait already reads can also be written: volume, child lock, Do Not Disturb, indicator LED, and dust collection. Q10 setting writes were reverse-engineered + validated against a live ss07 robot: the bare data point write ({"dps": {"26": v}}) is silently ignored; the value must be wrapped in the dpCommon (101) data point ({"dps": {"101": {"26": v}}}). Verified live by setting volume and child lock and reading the new value back from the status trait, then restoring. - traits/b01/q10/settings.py: SettingsTrait wired into Q10PropertiesApi. - cli: q10-set-volume / -child-lock / -dnd / -led / -dust-collection. - Unit tests assert the exact dpCommon-wrapped payload for each setter. --- roborock/cli.py | 63 +++++++++++++++++++ roborock/devices/traits/b01/q10/__init__.py | 6 ++ roborock/devices/traits/b01/q10/settings.py | 46 ++++++++++++++ tests/devices/traits/b01/q10/test_settings.py | 62 ++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 roborock/devices/traits/b01/q10/settings.py create mode 100644 tests/devices/traits/b01/q10/test_settings.py diff --git a/roborock/cli.py b/roborock/cli.py index d62e1056..fa91ac30 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -1326,6 +1326,69 @@ async def q10_vacuum_dock(ctx: click.Context, device_id: str) -> None: click.echo(f"Error: {e}") +async def _q10_set(ctx: click.Context, device_id: str, apply: Callable[[Any], Any], message: str) -> None: + """Run a Q10 settings write and report the result.""" + context: RoborockContext = ctx.obj + try: + properties = await _q10_properties(context, device_id) + await apply(properties.settings) + click.echo(message) + except RoborockUnsupportedFeature: + click.echo("Device does not support B01 Q10 protocol. Is it a Q10?") + except (RoborockException, ValueError) as e: + click.echo(f"Error: {e}") + + +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option("--volume", required=True, type=int, help="Volume 0-100") +@click.pass_context +@async_command +async def q10_set_volume(ctx: click.Context, device_id: str, volume: int) -> None: + """Set the speaker volume on a Q10 device.""" + await _q10_set(ctx, device_id, lambda s: s.set_volume(volume), f"Volume set to {volume}") + + +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)") +@click.pass_context +@async_command +async def q10_set_child_lock(ctx: click.Context, device_id: str, enabled: bool) -> None: + """Enable or disable the child lock on a Q10 device.""" + await _q10_set(ctx, device_id, lambda s: s.set_child_lock(enabled), f"Child lock set to {enabled}") + + +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)") +@click.pass_context +@async_command +async def q10_set_dnd(ctx: click.Context, device_id: str, enabled: bool) -> None: + """Enable or disable Do Not Disturb on a Q10 device.""" + await _q10_set(ctx, device_id, lambda s: s.set_do_not_disturb(enabled), f"Do Not Disturb set to {enabled}") + + +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)") +@click.pass_context +@async_command +async def q10_set_led(ctx: click.Context, device_id: str, enabled: bool) -> None: + """Enable or disable the indicator light (LED) on a Q10 device.""" + await _q10_set(ctx, device_id, lambda s: s.set_button_light(enabled), f"LED set to {enabled}") + + +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)") +@click.pass_context +@async_command +async def q10_set_dust_collection(ctx: click.Context, device_id: str, enabled: bool) -> None: + """Enable or disable automatic dust collection on a Q10 device.""" + await _q10_set(ctx, device_id, lambda s: s.set_dust_collection(enabled), f"Dust collection set to {enabled}") + + @session.command() @click.option("--device_id", required=True, help="Device ID") @click.pass_context diff --git a/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index 184de2d2..5d02af06 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -10,11 +10,13 @@ from .command import CommandTrait from .remote import RemoteTrait +from .settings import SettingsTrait from .status import StatusTrait from .vacuum import VacuumTrait __all__ = [ "Q10PropertiesApi", + "SettingsTrait", ] _LOGGER = logging.getLogger(__name__) @@ -35,6 +37,9 @@ class Q10PropertiesApi(Trait): remote: RemoteTrait """Trait for sending remote control related commands to Q10 devices.""" + settings: SettingsTrait + """Trait for changing device settings (volume, child lock, DND, LED, dust).""" + def __init__(self, channel: MqttChannel) -> None: """Initialize the B01Props API.""" self._channel = channel @@ -42,6 +47,7 @@ def __init__(self, channel: MqttChannel) -> None: self.vacuum = VacuumTrait(self.command) self.remote = RemoteTrait(self.command) self.status = StatusTrait() + self.settings = SettingsTrait(self.command) self._subscribe_task: asyncio.Task[None] | None = None async def start(self) -> None: diff --git a/roborock/devices/traits/b01/q10/settings.py b/roborock/devices/traits/b01/q10/settings.py new file mode 100644 index 00000000..3615e3e4 --- /dev/null +++ b/roborock/devices/traits/b01/q10/settings.py @@ -0,0 +1,46 @@ +"""Settings writer trait for Q10 B01 devices.""" + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP + +from .command import CommandTrait + + +class SettingsTrait: + """Trait for changing Q10 device settings. + + Q10 setting writes must be wrapped in the ``dpCommon`` (101) data point, e.g. + setting the volume sends ``{"dps": {"101": {"26": }}}``. Writing the + bare data point (without the ``dpCommon`` wrapper) is silently ignored by the + device. The corresponding values can be read back from ``StatusTrait`` after + a refresh. + """ + + def __init__(self, command: CommandTrait) -> None: + """Initialize the SettingsTrait.""" + self._command = command + + async def _write(self, dp: B01_Q10_DP, value: int) -> None: + """Write a single data point value via the dpCommon (101) wrapper.""" + await self._command.send(B01_Q10_DP.COMMON, {str(dp.code): value}) + + async def set_volume(self, volume: int) -> None: + """Set the speaker volume (0-100).""" + if not 0 <= volume <= 100: + raise ValueError("volume must be between 0 and 100") + await self._write(B01_Q10_DP.VOLUME, volume) + + async def set_child_lock(self, enabled: bool) -> None: + """Enable or disable the child lock.""" + await self._write(B01_Q10_DP.CHILD_LOCK, int(enabled)) + + async def set_do_not_disturb(self, enabled: bool) -> None: + """Enable or disable Do Not Disturb.""" + await self._write(B01_Q10_DP.NOT_DISTURB, int(enabled)) + + async def set_button_light(self, enabled: bool) -> None: + """Enable or disable the indicator / button light (LED).""" + await self._write(B01_Q10_DP.BUTTON_LIGHT_SWITCH, int(enabled)) + + async def set_dust_collection(self, enabled: bool) -> None: + """Enable or disable automatic dust collection at the dock.""" + await self._write(B01_Q10_DP.DUST_SWITCH, int(enabled)) diff --git a/tests/devices/traits/b01/q10/test_settings.py b/tests/devices/traits/b01/q10/test_settings.py new file mode 100644 index 00000000..16cb5912 --- /dev/null +++ b/tests/devices/traits/b01/q10/test_settings.py @@ -0,0 +1,62 @@ +"""Tests for the Q10 B01 settings writer trait.""" + +import json +from typing import cast + +import pytest + +from roborock.devices.traits.b01.q10.command import CommandTrait +from roborock.devices.traits.b01.q10.settings import SettingsTrait +from roborock.devices.transport.mqtt_channel import MqttChannel +from tests.fixtures.channel_fixtures import FakeChannel + + +@pytest.fixture +def fake_channel() -> FakeChannel: + return FakeChannel() + + +@pytest.fixture +def settings(fake_channel: FakeChannel) -> SettingsTrait: + return SettingsTrait(CommandTrait(cast(MqttChannel, fake_channel))) + + +def _sent_dps(fake_channel: FakeChannel) -> dict: + assert len(fake_channel.published_messages) == 1 + payload = fake_channel.published_messages[0].payload + assert payload is not None + return json.loads(payload)["dps"] + + +async def test_set_volume_uses_common_wrapper(fake_channel: FakeChannel, settings: SettingsTrait) -> None: + """Volume writes are wrapped in dpCommon (101) -> {"26": value}.""" + await settings.set_volume(55) + assert _sent_dps(fake_channel) == {"101": {"26": 55}} + + +@pytest.mark.parametrize("volume", [-1, 101, 1000]) +async def test_set_volume_rejects_out_of_range(settings: SettingsTrait, volume: int) -> None: + with pytest.raises(ValueError, match="between 0 and 100"): + await settings.set_volume(volume) + + +@pytest.mark.parametrize( + ("call", "code"), + [ + ("set_child_lock", "47"), + ("set_do_not_disturb", "25"), + ("set_button_light", "77"), + ("set_dust_collection", "37"), + ], +) +async def test_boolean_setters_write_common_wrapped_dp( + fake_channel: FakeChannel, settings: SettingsTrait, call: str, code: str +) -> None: + """Each boolean setter writes its data point as int 1/0 under dpCommon.""" + await getattr(settings, call)(True) + assert _sent_dps(fake_channel) == {"101": {code: 1}} + + +async def test_boolean_setter_disable_sends_zero(fake_channel: FakeChannel, settings: SettingsTrait) -> None: + await settings.set_child_lock(False) + assert _sent_dps(fake_channel) == {"101": {"47": 0}} From ce11580635670d0351f6b329097e9dfb81f8d994 Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:54:05 +0400 Subject: [PATCH 03/12] feat: add Q10 dust-collection frequency writer Adds SettingsTrait.set_dust_collection_frequency (dpDustSetting 50 via the dpCommon wrapper) accepting YXDeviceDustCollectionFrequency or its int code (0=daily, 15/30/45/60 cleans), plus a q10-set-dust-frequency CLI command. Validated live on an R1: dust_setting 0 -> 15 -> restored to 0. --- roborock/cli.py | 24 ++++++++++++++++++- roborock/devices/traits/b01/q10/settings.py | 18 +++++++++++++- tests/devices/traits/b01/q10/test_settings.py | 24 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/roborock/cli.py b/roborock/cli.py index fa91ac30..5d4252cf 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -43,7 +43,12 @@ from roborock import RoborockCommand from roborock.data import RoborockBase, UserData -from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType, YXFanLevel +from roborock.data.b01_q10.b01_q10_code_mappings import ( + B01_Q10_DP, + YXCleanType, + YXDeviceDustCollectionFrequency, + YXFanLevel, +) from roborock.data.code_mappings import SHORT_MODEL_TO_ENUM from roborock.device_features import DeviceFeatures from roborock.devices.cache import Cache, CacheData @@ -1389,6 +1394,23 @@ async def q10_set_dust_collection(ctx: click.Context, device_id: str, enabled: b await _q10_set(ctx, device_id, lambda s: s.set_dust_collection(enabled), f"Dust collection set to {enabled}") +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option( + "--frequency", + required=True, + type=click.Choice([str(m.code) for m in YXDeviceDustCollectionFrequency]), + help="Empty after every N cleans (0 = daily).", +) +@click.pass_context +@async_command +async def q10_set_dust_frequency(ctx: click.Context, device_id: str, frequency: str) -> None: + """Set how often the dock empties the bin (0 = daily, else every N cleans).""" + code = int(frequency) + label = "daily" if code == 0 else f"every {code} cleans" + await _q10_set(ctx, device_id, lambda s: s.set_dust_collection_frequency(code), f"Dust frequency set to {label}") + + @session.command() @click.option("--device_id", required=True, help="Device ID") @click.pass_context diff --git a/roborock/devices/traits/b01/q10/settings.py b/roborock/devices/traits/b01/q10/settings.py index 3615e3e4..3d80215e 100644 --- a/roborock/devices/traits/b01/q10/settings.py +++ b/roborock/devices/traits/b01/q10/settings.py @@ -1,6 +1,6 @@ """Settings writer trait for Q10 B01 devices.""" -from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceDustCollectionFrequency from .command import CommandTrait @@ -44,3 +44,19 @@ async def set_button_light(self, enabled: bool) -> None: async def set_dust_collection(self, enabled: bool) -> None: """Enable or disable automatic dust collection at the dock.""" await self._write(B01_Q10_DP.DUST_SWITCH, int(enabled)) + + async def set_dust_collection_frequency(self, frequency: YXDeviceDustCollectionFrequency | int) -> None: + """Set how often the dock empties the bin. + + Accepts a :class:`YXDeviceDustCollectionFrequency` (``DAILY`` or after + every 15/30/45/60 cleans) or its integer code (0, 15, 30, 45, 60). The + value is the interval in cleans, with ``0`` meaning daily. + """ + if isinstance(frequency, YXDeviceDustCollectionFrequency): + code = frequency.code + else: + valid = {member.code for member in YXDeviceDustCollectionFrequency} + if frequency not in valid: + raise ValueError(f"dust collection frequency must be one of {sorted(valid)}") + code = frequency + await self._write(B01_Q10_DP.DUST_SETTING, code) diff --git a/tests/devices/traits/b01/q10/test_settings.py b/tests/devices/traits/b01/q10/test_settings.py index 16cb5912..f062ab04 100644 --- a/tests/devices/traits/b01/q10/test_settings.py +++ b/tests/devices/traits/b01/q10/test_settings.py @@ -5,6 +5,7 @@ import pytest +from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceDustCollectionFrequency from roborock.devices.traits.b01.q10.command import CommandTrait from roborock.devices.traits.b01.q10.settings import SettingsTrait from roborock.devices.transport.mqtt_channel import MqttChannel @@ -60,3 +61,26 @@ async def test_boolean_setters_write_common_wrapped_dp( async def test_boolean_setter_disable_sends_zero(fake_channel: FakeChannel, settings: SettingsTrait) -> None: await settings.set_child_lock(False) assert _sent_dps(fake_channel) == {"101": {"47": 0}} + + +@pytest.mark.parametrize( + ("frequency", "code"), + [ + (YXDeviceDustCollectionFrequency.DAILY, 0), + (YXDeviceDustCollectionFrequency.INTERVAL_30, 30), + (60, 60), + (15, 15), + ], +) +async def test_set_dust_frequency_writes_interval_code( + fake_channel: FakeChannel, settings: SettingsTrait, frequency: object, code: int +) -> None: + """Frequency (enum or int) writes its interval code under dpDustSetting (50).""" + await settings.set_dust_collection_frequency(frequency) # type: ignore[arg-type] + assert _sent_dps(fake_channel) == {"101": {"50": code}} + + +@pytest.mark.parametrize("bad", [1, 7, 90, -1]) +async def test_set_dust_frequency_rejects_invalid(settings: SettingsTrait, bad: int) -> None: + with pytest.raises(ValueError, match="dust collection frequency"): + await settings.set_dust_collection_frequency(bad) From a7612ccecd58f64cbebb2698282c7b310f4074e2 Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:10:44 +0400 Subject: [PATCH 04/12] refactor: split Q10 status/settings into per-concern traits Address PR #846 review feedback: - Split the monolithic Q10Status read-model + catch-all SettingsTrait into per-concern read/write traits mirroring the v1 layout: SoundVolumeTrait, ChildLockTrait, DoNotDisturbTrait, DustCollectionTrait, ButtonLightTrait, NetworkInfoTrait, ConsumableTrait. Each owns its read-model and setters. - Add an UpdatableTrait base (q10/common.py) that wires a RoborockBase read-model to the DPS push stream; Q10PropertiesApi fans each decoded message out to every read-model trait. - Convert clear on/off flags to bool (child_lock, not_disturb, dust_switch, auto_boost, multi_map_switch, line_laser_obstacle_avoidance) and map dust_setting to YXDeviceDustCollectionFrequency. - set_frequency now takes YXDeviceDustCollectionFrequency only (no int union). - Drop redundant decamelize comments on dpNetInfo/dpTimeZone; keep the note documenting the device's "ipAdress" typo. - Update CLI Q10 setters and tests for the new trait layout. --- roborock/cli.py | 40 ++++++-- roborock/data/b01_q10/b01_q10_containers.py | 99 +++++++++++++------ roborock/devices/traits/b01/q10/__init__.py | 67 +++++++++++-- .../devices/traits/b01/q10/button_light.py | 29 ++++++ roborock/devices/traits/b01/q10/child_lock.py | 36 +++++++ roborock/devices/traits/b01/q10/common.py | 70 +++++++++++++ roborock/devices/traits/b01/q10/consumable.py | 21 ++++ .../devices/traits/b01/q10/do_not_disturb.py | 36 +++++++ .../devices/traits/b01/q10/dust_collection.py | 43 ++++++++ .../devices/traits/b01/q10/network_info.py | 21 ++++ roborock/devices/traits/b01/q10/settings.py | 62 ------------ roborock/devices/traits/b01/q10/status.py | 23 ++--- roborock/devices/traits/b01/q10/volume.py | 29 ++++++ tests/devices/traits/b01/q10/test_settings.py | 61 ++++++------ tests/devices/traits/b01/q10/test_status.py | 39 +++++--- 15 files changed, 507 insertions(+), 169 deletions(-) create mode 100644 roborock/devices/traits/b01/q10/button_light.py create mode 100644 roborock/devices/traits/b01/q10/child_lock.py create mode 100644 roborock/devices/traits/b01/q10/common.py create mode 100644 roborock/devices/traits/b01/q10/consumable.py create mode 100644 roborock/devices/traits/b01/q10/do_not_disturb.py create mode 100644 roborock/devices/traits/b01/q10/dust_collection.py create mode 100644 roborock/devices/traits/b01/q10/network_info.py delete mode 100644 roborock/devices/traits/b01/q10/settings.py create mode 100644 roborock/devices/traits/b01/q10/volume.py diff --git a/roborock/cli.py b/roborock/cli.py index 5d4252cf..c9a6725b 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -1336,7 +1336,7 @@ async def _q10_set(ctx: click.Context, device_id: str, apply: Callable[[Any], An context: RoborockContext = ctx.obj try: properties = await _q10_properties(context, device_id) - await apply(properties.settings) + await apply(properties) click.echo(message) except RoborockUnsupportedFeature: click.echo("Device does not support B01 Q10 protocol. Is it a Q10?") @@ -1351,7 +1351,7 @@ async def _q10_set(ctx: click.Context, device_id: str, apply: Callable[[Any], An @async_command async def q10_set_volume(ctx: click.Context, device_id: str, volume: int) -> None: """Set the speaker volume on a Q10 device.""" - await _q10_set(ctx, device_id, lambda s: s.set_volume(volume), f"Volume set to {volume}") + await _q10_set(ctx, device_id, lambda p: p.volume.set_volume(volume), f"Volume set to {volume}") @session.command() @@ -1361,7 +1361,12 @@ async def q10_set_volume(ctx: click.Context, device_id: str, volume: int) -> Non @async_command async def q10_set_child_lock(ctx: click.Context, device_id: str, enabled: bool) -> None: """Enable or disable the child lock on a Q10 device.""" - await _q10_set(ctx, device_id, lambda s: s.set_child_lock(enabled), f"Child lock set to {enabled}") + await _q10_set( + ctx, + device_id, + lambda p: p.child_lock.enable() if enabled else p.child_lock.disable(), + f"Child lock set to {enabled}", + ) @session.command() @@ -1371,7 +1376,12 @@ async def q10_set_child_lock(ctx: click.Context, device_id: str, enabled: bool) @async_command async def q10_set_dnd(ctx: click.Context, device_id: str, enabled: bool) -> None: """Enable or disable Do Not Disturb on a Q10 device.""" - await _q10_set(ctx, device_id, lambda s: s.set_do_not_disturb(enabled), f"Do Not Disturb set to {enabled}") + await _q10_set( + ctx, + device_id, + lambda p: p.do_not_disturb.enable() if enabled else p.do_not_disturb.disable(), + f"Do Not Disturb set to {enabled}", + ) @session.command() @@ -1381,7 +1391,12 @@ async def q10_set_dnd(ctx: click.Context, device_id: str, enabled: bool) -> None @async_command async def q10_set_led(ctx: click.Context, device_id: str, enabled: bool) -> None: """Enable or disable the indicator light (LED) on a Q10 device.""" - await _q10_set(ctx, device_id, lambda s: s.set_button_light(enabled), f"LED set to {enabled}") + await _q10_set( + ctx, + device_id, + lambda p: p.button_light.enable() if enabled else p.button_light.disable(), + f"LED set to {enabled}", + ) @session.command() @@ -1391,7 +1406,12 @@ async def q10_set_led(ctx: click.Context, device_id: str, enabled: bool) -> None @async_command async def q10_set_dust_collection(ctx: click.Context, device_id: str, enabled: bool) -> None: """Enable or disable automatic dust collection on a Q10 device.""" - await _q10_set(ctx, device_id, lambda s: s.set_dust_collection(enabled), f"Dust collection set to {enabled}") + await _q10_set( + ctx, + device_id, + lambda p: p.dust_collection.enable() if enabled else p.dust_collection.disable(), + f"Dust collection set to {enabled}", + ) @session.command() @@ -1406,9 +1426,11 @@ async def q10_set_dust_collection(ctx: click.Context, device_id: str, enabled: b @async_command async def q10_set_dust_frequency(ctx: click.Context, device_id: str, frequency: str) -> None: """Set how often the dock empties the bin (0 = daily, else every N cleans).""" - code = int(frequency) - label = "daily" if code == 0 else f"every {code} cleans" - await _q10_set(ctx, device_id, lambda s: s.set_dust_collection_frequency(code), f"Dust frequency set to {label}") + freq = YXDeviceDustCollectionFrequency.from_code(int(frequency)) + label = "daily" if freq.code == 0 else f"every {freq.code} cleans" + await _q10_set( + ctx, device_id, lambda p: p.dust_collection.set_frequency(freq), f"Dust frequency set to {label}" + ) @session.command() diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index 20203d2c..21f71afa 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -2,7 +2,7 @@ Many of these classes use the `field(metadata={"dps": ...})` convention to map dataclass fields to device Data Points (DPS). This metadata is utilized by the -`update_from_dps` helper in `roborock.devices.traits.b01.q10.common` to +`UpdatableTrait` helper in `roborock.devices.traits.b01.q10.common` to automatically update objects from raw device responses. """ @@ -15,6 +15,7 @@ YXCleanLine, YXCleanType, YXDeviceCleanTask, + YXDeviceDustCollectionFrequency, YXDeviceState, YXFanLevel, YXWaterLevel, @@ -52,10 +53,8 @@ class dpSelfIdentifyingCarpet(RoborockBase): @dataclass class dpNetInfo(RoborockBase): - # Field names are snake_case so they match the device's camelCase keys once - # `RoborockBase.from_dict` decamelizes them (e.g. "wifiName" -> "wifi_name"). - # The "ip_adress" spelling intentionally mirrors the device's "ipAdress" typo. wifi_name: str | None = None + # "ip_adress" intentionally mirrors the device's "ipAdress" key (sic). ip_adress: str | None = None mac: str | None = None signal: int | None = None @@ -81,17 +80,18 @@ class dpVoiceVersion(RoborockBase): @dataclass class dpTimeZone(RoborockBase): - # snake_case so the decamelized device keys ("timeZoneCity") map correctly. time_zone_city: str | None = None time_zone_sec: int | None = None @dataclass class Q10Status(RoborockBase): - """Status for Q10 devices. + """Core vacuum status for Q10 devices. Fields are mapped to DPS values using metadata. Objects of this class can be - automatically updated using the `update_from_dps` helper. + automatically updated using the `UpdatableTrait` helper. Settings that have + their own trait (volume, child lock, do-not-disturb, dust collection, + network info, consumables) live on those traits instead of here. """ clean_time: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TIME}) @@ -104,41 +104,84 @@ class Q10Status(RoborockBase): total_clean_area: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_AREA}) total_clean_count: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_COUNT}) total_clean_time: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_TIME}) - main_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MAIN_BRUSH_LIFE}) - side_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SIDE_BRUSH_LIFE}) - filter_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FILTER_LIFE}) - sensor_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SENSOR_LIFE}) clean_mode: YXCleanType | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_MODE}) clean_task_type: YXDeviceCleanTask | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TASK_TYPE}) back_type: YXBackType | None = field(default=None, metadata={"dps": B01_Q10_DP.BACK_TYPE}) cleaning_progress: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_PROGRESS}) fault: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FAULT}) - # Additional settings and state reported in the device's full status dump. - volume: int | None = field(default=None, metadata={"dps": B01_Q10_DP.VOLUME}) - not_disturb: int | None = field(default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB}) - not_disturb_expand: dpNotDisturbExpand | None = field(default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB_EXPAND}) - child_lock: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CHILD_LOCK}) - mop_state: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MOP_STATE}) - auto_boost: int | None = field(default=None, metadata={"dps": B01_Q10_DP.AUTO_BOOST}) - dust_switch: int | None = field(default=None, metadata={"dps": B01_Q10_DP.DUST_SWITCH}) - dust_setting: int | None = field(default=None, metadata={"dps": B01_Q10_DP.DUST_SETTING}) + # Additional state reported in the device's full status dump. + clean_line: YXCleanLine | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_LINE}) + auto_boost: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.AUTO_BOOST}) + multi_map_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MULTI_MAP_SWITCH}) map_save_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MAP_SAVE_SWITCH}) - multi_map_switch: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MULTI_MAP_SWITCH}) recent_clean_record: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.RECENT_CLEAN_RECORD}) - breakpoint_clean: int | None = field(default=None, metadata={"dps": B01_Q10_DP.BREAKPOINT_CLEAN}) valley_point_charging: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.VALLEY_POINT_CHARGING}) - carpet_clean_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CARPET_CLEAN_TYPE}) - clean_line: YXCleanLine | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_LINE}) - ground_clean: int | None = field(default=None, metadata={"dps": B01_Q10_DP.GROUND_CLEAN}) - line_laser_obstacle_avoidance: int | None = field( + line_laser_obstacle_avoidance: bool | None = field( default=None, metadata={"dps": B01_Q10_DP.LINE_LASER_OBSTACLE_AVOIDANCE} ) + robot_country_code: str | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_COUNTRY_CODE}) + time_zone: dpTimeZone | None = field(default=None, metadata={"dps": B01_Q10_DP.TIME_ZONE}) + + # TODO(#846): value mappings for these ints are not yet decoded; keep as int + # until reverse-engineered, then promote to enums. + mop_state: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MOP_STATE}) + breakpoint_clean: int | None = field(default=None, metadata={"dps": B01_Q10_DP.BREAKPOINT_CLEAN}) + carpet_clean_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CARPET_CLEAN_TYPE}) + ground_clean: int | None = field(default=None, metadata={"dps": B01_Q10_DP.GROUND_CLEAN}) add_clean_state: int | None = field(default=None, metadata={"dps": B01_Q10_DP.ADD_CLEAN_STATE}) timer_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TIMER_TYPE}) user_plan: int | None = field(default=None, metadata={"dps": B01_Q10_DP.USER_PLAN}) robot_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_TYPE}) - robot_country_code: str | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_COUNTRY_CODE}) area_unit: int | None = field(default=None, metadata={"dps": B01_Q10_DP.AREA_UNIT}) - time_zone: dpTimeZone | None = field(default=None, metadata={"dps": B01_Q10_DP.TIME_ZONE}) + + +@dataclass +class SoundVolume(RoborockBase): + """Speaker volume read-model (0-100).""" + + volume: int | None = field(default=None, metadata={"dps": B01_Q10_DP.VOLUME}) + + +@dataclass +class ChildLock(RoborockBase): + """Child-lock read-model.""" + + child_lock: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.CHILD_LOCK}) + + +@dataclass +class DoNotDisturb(RoborockBase): + """Do Not Disturb read-model.""" + + not_disturb: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB}) + not_disturb_expand: dpNotDisturbExpand | None = field( + default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB_EXPAND} + ) + + +@dataclass +class DustCollection(RoborockBase): + """Dock auto-empty (dust collection) read-model.""" + + dust_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.DUST_SWITCH}) + dust_setting: YXDeviceDustCollectionFrequency | None = field( + default=None, metadata={"dps": B01_Q10_DP.DUST_SETTING} + ) + + +@dataclass +class Consumable(RoborockBase): + """Consumable / accessory remaining-life read-model.""" + + main_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MAIN_BRUSH_LIFE}) + side_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SIDE_BRUSH_LIFE}) + filter_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FILTER_LIFE}) + sensor_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SENSOR_LIFE}) + + +@dataclass +class NetworkInfo(RoborockBase): + """Network information read-model.""" + net_info: dpNetInfo | None = field(default=None, metadata={"dps": B01_Q10_DP.NET_INFO}) diff --git a/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index 5d02af06..f803e6f4 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -8,15 +8,28 @@ from roborock.devices.traits import Trait from roborock.devices.transport.mqtt_channel import MqttChannel +from .button_light import ButtonLightTrait +from .child_lock import ChildLockTrait from .command import CommandTrait +from .consumable import ConsumableTrait +from .do_not_disturb import DoNotDisturbTrait +from .dust_collection import DustCollectionTrait +from .network_info import NetworkInfoTrait from .remote import RemoteTrait -from .settings import SettingsTrait from .status import StatusTrait from .vacuum import VacuumTrait +from .volume import SoundVolumeTrait __all__ = [ "Q10PropertiesApi", - "SettingsTrait", + "ButtonLightTrait", + "ChildLockTrait", + "ConsumableTrait", + "DoNotDisturbTrait", + "DustCollectionTrait", + "NetworkInfoTrait", + "SoundVolumeTrait", + "StatusTrait", ] _LOGGER = logging.getLogger(__name__) @@ -29,7 +42,7 @@ class Q10PropertiesApi(Trait): """Trait for sending commands to Q10 devices.""" status: StatusTrait - """Trait for managing the status of Q10 devices.""" + """Trait for managing the core status of Q10 devices.""" vacuum: VacuumTrait """Trait for sending vacuum related commands to Q10 devices.""" @@ -37,8 +50,26 @@ class Q10PropertiesApi(Trait): remote: RemoteTrait """Trait for sending remote control related commands to Q10 devices.""" - settings: SettingsTrait - """Trait for changing device settings (volume, child lock, DND, LED, dust).""" + volume: SoundVolumeTrait + """Trait for reading / setting the speaker volume.""" + + child_lock: ChildLockTrait + """Trait for reading / controlling the child lock.""" + + do_not_disturb: DoNotDisturbTrait + """Trait for reading / controlling Do Not Disturb.""" + + dust_collection: DustCollectionTrait + """Trait for reading / controlling dock auto-empty (dust collection).""" + + button_light: ButtonLightTrait + """Trait for controlling the indicator / button light (LED).""" + + network_info: NetworkInfoTrait + """Trait exposing the device's network information.""" + + consumable: ConsumableTrait + """Trait exposing remaining life of consumables.""" def __init__(self, channel: MqttChannel) -> None: """Initialize the B01Props API.""" @@ -47,7 +78,23 @@ def __init__(self, channel: MqttChannel) -> None: self.vacuum = VacuumTrait(self.command) self.remote = RemoteTrait(self.command) self.status = StatusTrait() - self.settings = SettingsTrait(self.command) + self.volume = SoundVolumeTrait(self.command) + self.child_lock = ChildLockTrait(self.command) + self.do_not_disturb = DoNotDisturbTrait(self.command) + self.dust_collection = DustCollectionTrait(self.command) + self.button_light = ButtonLightTrait(self.command) + self.network_info = NetworkInfoTrait() + self.consumable = ConsumableTrait() + # Read-model traits updated from the device's DPS push stream. + self._updatable_traits = [ + self.status, + self.volume, + self.child_lock, + self.do_not_disturb, + self.dust_collection, + self.network_info, + self.consumable, + ] self._subscribe_task: asyncio.Task[None] | None = None async def start(self) -> None: @@ -75,10 +122,10 @@ async def _subscribe_loop(self) -> None: async for decoded_dps in stream_decoded_responses(self._channel): _LOGGER.debug("Received Q10 status update: %s", decoded_dps) - # Notify all traits about a new message and each trait will - # only update what fields that it is responsible for. - # More traits can be added here below. - self.status.update_from_dps(decoded_dps) + # Notify all read-model traits about the new message; each trait + # only updates the fields that it is responsible for. + for trait in self._updatable_traits: + trait.update_from_dps(decoded_dps) def create(channel: MqttChannel) -> Q10PropertiesApi: diff --git a/roborock/devices/traits/b01/q10/button_light.py b/roborock/devices/traits/b01/q10/button_light.py new file mode 100644 index 00000000..7ff075c0 --- /dev/null +++ b/roborock/devices/traits/b01/q10/button_light.py @@ -0,0 +1,29 @@ +"""Indicator / button light (LED) trait for Q10 B01 devices.""" + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP + +from .command import CommandTrait + + +class ButtonLightTrait: + """Trait for controlling the indicator / button light (LED) of a Q10 device. + + The device does not report the button-light state in its status dump, so + this trait is write-only (no read-back). + """ + + def __init__(self, command: CommandTrait) -> None: + """Initialize the button light trait.""" + self._command = command + + async def _write(self, value: int) -> None: + """Write the button-light data point via the dpCommon (101) wrapper.""" + await self._command.send(B01_Q10_DP.COMMON, {str(B01_Q10_DP.BUTTON_LIGHT_SWITCH.code): value}) + + async def enable(self) -> None: + """Turn the indicator light on.""" + await self._write(1) + + async def disable(self) -> None: + """Turn the indicator light off.""" + await self._write(0) diff --git a/roborock/devices/traits/b01/q10/child_lock.py b/roborock/devices/traits/b01/q10/child_lock.py new file mode 100644 index 00000000..968bfe8d --- /dev/null +++ b/roborock/devices/traits/b01/q10/child_lock.py @@ -0,0 +1,36 @@ +"""Child lock trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_containers import ChildLock +from roborock.devices.traits.common import DpsDataConverter + +from .command import CommandTrait +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class ChildLockTrait(ChildLock, UpdatableTrait): + """Trait for reading and controlling the child lock of a Q10 device.""" + + _CONVERTER = DpsDataConverter.from_dataclass(ChildLock) + + def __init__(self, command: CommandTrait) -> None: + """Initialize the child lock trait.""" + ChildLock.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + + @property + def is_on(self) -> bool: + """Return whether the child lock is enabled.""" + return bool(self.child_lock) + + async def enable(self) -> None: + """Enable the child lock.""" + await self._write(B01_Q10_DP.CHILD_LOCK, 1) + + async def disable(self) -> None: + """Disable the child lock.""" + await self._write(B01_Q10_DP.CHILD_LOCK, 0) diff --git a/roborock/devices/traits/b01/q10/common.py b/roborock/devices/traits/b01/q10/common.py new file mode 100644 index 00000000..6e9fff03 --- /dev/null +++ b/roborock/devices/traits/b01/q10/common.py @@ -0,0 +1,70 @@ +"""Common helpers for Q10 B01 traits. + +Q10 devices push their full state as a single decoded DPS dictionary (see +``Q10PropertiesApi._subscribe_loop``). Each trait owns a small ``RoborockBase`` +read-model whose fields are annotated with ``field(metadata={"dps": ...})`` and +only updates the fields it is responsible for, ignoring the rest. + +The :class:`UpdatableTrait` base wires that read-model to the update lifecycle +and (for traits that also write) exposes the ``dpCommon`` (101) wrapper used by +Q10 setting writes. +""" + +import logging +from typing import Any, ClassVar, cast + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.containers import RoborockBase +from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener + +from .command import CommandTrait + + +class UpdatableTrait(TraitUpdateListener): + """Base for Q10 traits backed by a read-model updated from the DPS stream. + + Concrete traits subclass both their ``RoborockBase`` read-model and this + class, set the ``_CONVERTER`` class attribute, and initialize the read-model + explicitly in their constructor, e.g.:: + + class SoundVolumeTrait(SoundVolume, UpdatableTrait): + _CONVERTER = DpsDataConverter.from_dataclass(SoundVolume) + + def __init__(self, command: CommandTrait) -> None: + SoundVolume.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + + The read-model init is called explicitly (rather than via ``super()``) + because the read-model dataclass precedes this class in the MRO. + + Traits that also send commands receive a :class:`CommandTrait` and use the + :meth:`_write` helper, which wraps the write in the ``dpCommon`` (101) data + point as the device requires. + """ + + _CONVERTER: ClassVar[DpsDataConverter] + + def __init__(self, command: CommandTrait | None, logger: logging.Logger) -> None: + """Initialize the update listener. The read-model is initialized by the subclass.""" + TraitUpdateListener.__init__(self, logger=logger) + self._command = command + + def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: + """Update the trait's read-model from raw DPS data and notify listeners. + + Concrete traits also subclass a ``RoborockBase`` read-model, so the cast + is always valid at runtime. + """ + if self._CONVERTER.update_from_dps(cast(RoborockBase, self), decoded_dps): + self._notify_update() + + async def _write(self, dp: B01_Q10_DP, value: int) -> None: + """Write a single data point value via the dpCommon (101) wrapper. + + Q10 setting writes must be wrapped in ``dpCommon`` (101), e.g. setting + the volume sends ``{"dps": {"101": {"26": }}}``. Writing the bare + data point (without the wrapper) is silently ignored by the device. + """ + if self._command is None: + raise ValueError("Trait is read-only; no command channel was provided") + await self._command.send(B01_Q10_DP.COMMON, {str(dp.code): value}) diff --git a/roborock/devices/traits/b01/q10/consumable.py b/roborock/devices/traits/b01/q10/consumable.py new file mode 100644 index 00000000..3210d0c5 --- /dev/null +++ b/roborock/devices/traits/b01/q10/consumable.py @@ -0,0 +1,21 @@ +"""Consumable / accessory life trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_containers import Consumable +from roborock.devices.traits.common import DpsDataConverter + +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class ConsumableTrait(Consumable, UpdatableTrait): + """Trait exposing remaining life of consumables (brushes, filter, sensors).""" + + _CONVERTER = DpsDataConverter.from_dataclass(Consumable) + + def __init__(self) -> None: + """Initialize the consumable trait.""" + Consumable.__init__(self) + UpdatableTrait.__init__(self, command=None, logger=_LOGGER) diff --git a/roborock/devices/traits/b01/q10/do_not_disturb.py b/roborock/devices/traits/b01/q10/do_not_disturb.py new file mode 100644 index 00000000..1c65bb5f --- /dev/null +++ b/roborock/devices/traits/b01/q10/do_not_disturb.py @@ -0,0 +1,36 @@ +"""Do Not Disturb trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_containers import DoNotDisturb +from roborock.devices.traits.common import DpsDataConverter + +from .command import CommandTrait +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class DoNotDisturbTrait(DoNotDisturb, UpdatableTrait): + """Trait for reading and controlling Do Not Disturb on a Q10 device.""" + + _CONVERTER = DpsDataConverter.from_dataclass(DoNotDisturb) + + def __init__(self, command: CommandTrait) -> None: + """Initialize the Do Not Disturb trait.""" + DoNotDisturb.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + + @property + def is_on(self) -> bool: + """Return whether Do Not Disturb is enabled.""" + return bool(self.not_disturb) + + async def enable(self) -> None: + """Enable Do Not Disturb.""" + await self._write(B01_Q10_DP.NOT_DISTURB, 1) + + async def disable(self) -> None: + """Disable Do Not Disturb.""" + await self._write(B01_Q10_DP.NOT_DISTURB, 0) diff --git a/roborock/devices/traits/b01/q10/dust_collection.py b/roborock/devices/traits/b01/q10/dust_collection.py new file mode 100644 index 00000000..78c6fb24 --- /dev/null +++ b/roborock/devices/traits/b01/q10/dust_collection.py @@ -0,0 +1,43 @@ +"""Dust collection (dock auto-empty) trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceDustCollectionFrequency +from roborock.data.b01_q10.b01_q10_containers import DustCollection +from roborock.devices.traits.common import DpsDataConverter + +from .command import CommandTrait +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class DustCollectionTrait(DustCollection, UpdatableTrait): + """Trait for reading and controlling automatic dust collection at the dock.""" + + _CONVERTER = DpsDataConverter.from_dataclass(DustCollection) + + def __init__(self, command: CommandTrait) -> None: + """Initialize the dust collection trait.""" + DustCollection.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + + @property + def is_on(self) -> bool: + """Return whether automatic dust collection is enabled.""" + return bool(self.dust_switch) + + async def enable(self) -> None: + """Enable automatic dust collection at the dock.""" + await self._write(B01_Q10_DP.DUST_SWITCH, 1) + + async def disable(self) -> None: + """Disable automatic dust collection at the dock.""" + await self._write(B01_Q10_DP.DUST_SWITCH, 0) + + async def set_frequency(self, frequency: YXDeviceDustCollectionFrequency) -> None: + """Set how often the dock empties the bin. + + The value is the interval in cleans, with ``DAILY`` (0) meaning daily. + """ + await self._write(B01_Q10_DP.DUST_SETTING, frequency.code) diff --git a/roborock/devices/traits/b01/q10/network_info.py b/roborock/devices/traits/b01/q10/network_info.py new file mode 100644 index 00000000..b80bf532 --- /dev/null +++ b/roborock/devices/traits/b01/q10/network_info.py @@ -0,0 +1,21 @@ +"""Network information trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_containers import NetworkInfo +from roborock.devices.traits.common import DpsDataConverter + +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class NetworkInfoTrait(NetworkInfo, UpdatableTrait): + """Trait exposing the device's network information (read-only).""" + + _CONVERTER = DpsDataConverter.from_dataclass(NetworkInfo) + + def __init__(self) -> None: + """Initialize the network info trait.""" + NetworkInfo.__init__(self) + UpdatableTrait.__init__(self, command=None, logger=_LOGGER) diff --git a/roborock/devices/traits/b01/q10/settings.py b/roborock/devices/traits/b01/q10/settings.py deleted file mode 100644 index 3d80215e..00000000 --- a/roborock/devices/traits/b01/q10/settings.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Settings writer trait for Q10 B01 devices.""" - -from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceDustCollectionFrequency - -from .command import CommandTrait - - -class SettingsTrait: - """Trait for changing Q10 device settings. - - Q10 setting writes must be wrapped in the ``dpCommon`` (101) data point, e.g. - setting the volume sends ``{"dps": {"101": {"26": }}}``. Writing the - bare data point (without the ``dpCommon`` wrapper) is silently ignored by the - device. The corresponding values can be read back from ``StatusTrait`` after - a refresh. - """ - - def __init__(self, command: CommandTrait) -> None: - """Initialize the SettingsTrait.""" - self._command = command - - async def _write(self, dp: B01_Q10_DP, value: int) -> None: - """Write a single data point value via the dpCommon (101) wrapper.""" - await self._command.send(B01_Q10_DP.COMMON, {str(dp.code): value}) - - async def set_volume(self, volume: int) -> None: - """Set the speaker volume (0-100).""" - if not 0 <= volume <= 100: - raise ValueError("volume must be between 0 and 100") - await self._write(B01_Q10_DP.VOLUME, volume) - - async def set_child_lock(self, enabled: bool) -> None: - """Enable or disable the child lock.""" - await self._write(B01_Q10_DP.CHILD_LOCK, int(enabled)) - - async def set_do_not_disturb(self, enabled: bool) -> None: - """Enable or disable Do Not Disturb.""" - await self._write(B01_Q10_DP.NOT_DISTURB, int(enabled)) - - async def set_button_light(self, enabled: bool) -> None: - """Enable or disable the indicator / button light (LED).""" - await self._write(B01_Q10_DP.BUTTON_LIGHT_SWITCH, int(enabled)) - - async def set_dust_collection(self, enabled: bool) -> None: - """Enable or disable automatic dust collection at the dock.""" - await self._write(B01_Q10_DP.DUST_SWITCH, int(enabled)) - - async def set_dust_collection_frequency(self, frequency: YXDeviceDustCollectionFrequency | int) -> None: - """Set how often the dock empties the bin. - - Accepts a :class:`YXDeviceDustCollectionFrequency` (``DAILY`` or after - every 15/30/45/60 cleans) or its integer code (0, 15, 30, 45, 60). The - value is the interval in cleans, with ``0`` meaning daily. - """ - if isinstance(frequency, YXDeviceDustCollectionFrequency): - code = frequency.code - else: - valid = {member.code for member in YXDeviceDustCollectionFrequency} - if frequency not in valid: - raise ValueError(f"dust collection frequency must be one of {sorted(valid)}") - code = frequency - await self._write(B01_Q10_DP.DUST_SETTING, code) diff --git a/roborock/devices/traits/b01/q10/status.py b/roborock/devices/traits/b01/q10/status.py index e5769b09..f4b38a67 100644 --- a/roborock/devices/traits/b01/q10/status.py +++ b/roborock/devices/traits/b01/q10/status.py @@ -1,31 +1,26 @@ """Status trait for Q10 B01 devices.""" import logging -from typing import Any -from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP from roborock.data.b01_q10.b01_q10_containers import Q10Status -from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener +from roborock.devices.traits.common import DpsDataConverter -_LOGGER = logging.getLogger(__name__) +from .common import UpdatableTrait -_CONVERTER = DpsDataConverter.from_dataclass(Q10Status) +_LOGGER = logging.getLogger(__name__) -class StatusTrait(Q10Status, TraitUpdateListener): - """Trait for managing the status of Q10 Roborock devices. +class StatusTrait(Q10Status, UpdatableTrait): + """Trait for managing the core status of Q10 Roborock devices. This is a thin wrapper around Q10Status that provides the Trait interface. The current values reflect the most recently received data from the device. New values can be requested through the `Q10PropertiesApi`'s `refresh` method. """ + _CONVERTER = DpsDataConverter.from_dataclass(Q10Status) + def __init__(self) -> None: """Initialize the status trait.""" - super().__init__() - TraitUpdateListener.__init__(self, logger=_LOGGER) - - def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: - """Update the trait from raw DPS data.""" - if _CONVERTER.update_from_dps(self, decoded_dps): - self._notify_update() + Q10Status.__init__(self) + UpdatableTrait.__init__(self, command=None, logger=_LOGGER) diff --git a/roborock/devices/traits/b01/q10/volume.py b/roborock/devices/traits/b01/q10/volume.py new file mode 100644 index 00000000..43d0e9a5 --- /dev/null +++ b/roborock/devices/traits/b01/q10/volume.py @@ -0,0 +1,29 @@ +"""Sound volume trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_containers import SoundVolume +from roborock.devices.traits.common import DpsDataConverter + +from .command import CommandTrait +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class SoundVolumeTrait(SoundVolume, UpdatableTrait): + """Trait for reading and setting the speaker volume of a Q10 device.""" + + _CONVERTER = DpsDataConverter.from_dataclass(SoundVolume) + + def __init__(self, command: CommandTrait) -> None: + """Initialize the volume trait.""" + SoundVolume.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + + async def set_volume(self, volume: int) -> None: + """Set the speaker volume (0-100).""" + if not 0 <= volume <= 100: + raise ValueError("volume must be between 0 and 100") + await self._write(B01_Q10_DP.VOLUME, volume) diff --git a/tests/devices/traits/b01/q10/test_settings.py b/tests/devices/traits/b01/q10/test_settings.py index f062ab04..26de9c97 100644 --- a/tests/devices/traits/b01/q10/test_settings.py +++ b/tests/devices/traits/b01/q10/test_settings.py @@ -1,4 +1,4 @@ -"""Tests for the Q10 B01 settings writer trait.""" +"""Tests for the Q10 B01 setting writer traits.""" import json from typing import cast @@ -6,8 +6,12 @@ import pytest from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceDustCollectionFrequency +from roborock.devices.traits.b01.q10.button_light import ButtonLightTrait +from roborock.devices.traits.b01.q10.child_lock import ChildLockTrait from roborock.devices.traits.b01.q10.command import CommandTrait -from roborock.devices.traits.b01.q10.settings import SettingsTrait +from roborock.devices.traits.b01.q10.do_not_disturb import DoNotDisturbTrait +from roborock.devices.traits.b01.q10.dust_collection import DustCollectionTrait +from roborock.devices.traits.b01.q10.volume import SoundVolumeTrait from roborock.devices.transport.mqtt_channel import MqttChannel from tests.fixtures.channel_fixtures import FakeChannel @@ -18,8 +22,8 @@ def fake_channel() -> FakeChannel: @pytest.fixture -def settings(fake_channel: FakeChannel) -> SettingsTrait: - return SettingsTrait(CommandTrait(cast(MqttChannel, fake_channel))) +def command(fake_channel: FakeChannel) -> CommandTrait: + return CommandTrait(cast(MqttChannel, fake_channel)) def _sent_dps(fake_channel: FakeChannel) -> dict: @@ -29,37 +33,37 @@ def _sent_dps(fake_channel: FakeChannel) -> dict: return json.loads(payload)["dps"] -async def test_set_volume_uses_common_wrapper(fake_channel: FakeChannel, settings: SettingsTrait) -> None: +async def test_set_volume_uses_common_wrapper(fake_channel: FakeChannel, command: CommandTrait) -> None: """Volume writes are wrapped in dpCommon (101) -> {"26": value}.""" - await settings.set_volume(55) + await SoundVolumeTrait(command).set_volume(55) assert _sent_dps(fake_channel) == {"101": {"26": 55}} @pytest.mark.parametrize("volume", [-1, 101, 1000]) -async def test_set_volume_rejects_out_of_range(settings: SettingsTrait, volume: int) -> None: +async def test_set_volume_rejects_out_of_range(command: CommandTrait, volume: int) -> None: with pytest.raises(ValueError, match="between 0 and 100"): - await settings.set_volume(volume) + await SoundVolumeTrait(command).set_volume(volume) @pytest.mark.parametrize( - ("call", "code"), + ("trait_cls", "method", "code"), [ - ("set_child_lock", "47"), - ("set_do_not_disturb", "25"), - ("set_button_light", "77"), - ("set_dust_collection", "37"), + (ChildLockTrait, "enable", "47"), + (DoNotDisturbTrait, "enable", "25"), + (ButtonLightTrait, "enable", "77"), + (DustCollectionTrait, "enable", "37"), ], ) -async def test_boolean_setters_write_common_wrapped_dp( - fake_channel: FakeChannel, settings: SettingsTrait, call: str, code: str +async def test_switch_enable_writes_common_wrapped_dp( + fake_channel: FakeChannel, command: CommandTrait, trait_cls: type, method: str, code: str ) -> None: - """Each boolean setter writes its data point as int 1/0 under dpCommon.""" - await getattr(settings, call)(True) + """Each switch trait's enable() writes its data point as int 1 under dpCommon.""" + await getattr(trait_cls(command), method)() assert _sent_dps(fake_channel) == {"101": {code: 1}} -async def test_boolean_setter_disable_sends_zero(fake_channel: FakeChannel, settings: SettingsTrait) -> None: - await settings.set_child_lock(False) +async def test_switch_disable_sends_zero(fake_channel: FakeChannel, command: CommandTrait) -> None: + await ChildLockTrait(command).disable() assert _sent_dps(fake_channel) == {"101": {"47": 0}} @@ -68,19 +72,16 @@ async def test_boolean_setter_disable_sends_zero(fake_channel: FakeChannel, sett [ (YXDeviceDustCollectionFrequency.DAILY, 0), (YXDeviceDustCollectionFrequency.INTERVAL_30, 30), - (60, 60), - (15, 15), + (YXDeviceDustCollectionFrequency.INTERVAL_60, 60), + (YXDeviceDustCollectionFrequency.INTERVAL_15, 15), ], ) async def test_set_dust_frequency_writes_interval_code( - fake_channel: FakeChannel, settings: SettingsTrait, frequency: object, code: int + fake_channel: FakeChannel, + command: CommandTrait, + frequency: YXDeviceDustCollectionFrequency, + code: int, ) -> None: - """Frequency (enum or int) writes its interval code under dpDustSetting (50).""" - await settings.set_dust_collection_frequency(frequency) # type: ignore[arg-type] + """Frequency enum writes its interval code under dpDustSetting (50).""" + await DustCollectionTrait(command).set_frequency(frequency) assert _sent_dps(fake_channel) == {"101": {"50": code}} - - -@pytest.mark.parametrize("bad", [1, 7, 90, -1]) -async def test_set_dust_frequency_rejects_invalid(settings: SettingsTrait, bad: int) -> None: - with pytest.raises(ValueError, match="dust collection frequency"): - await settings.set_dust_collection_frequency(bad) diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index e62cdb04..2be501e0 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -14,6 +14,7 @@ YXCleanLine, YXCleanType, YXDeviceCleanTask, + YXDeviceDustCollectionFrequency, YXDeviceState, YXFanLevel, YXWaterLevel, @@ -117,7 +118,7 @@ async def test_status_trait_refresh( assert q10_api.status.status is None assert q10_api.status.fan_level is None assert q10_api.status.total_clean_count is None - assert q10_api.status.main_brush_life is None + assert q10_api.consumable.main_brush_life is None assert q10_api.status.cleaning_progress is None assert q10_api.status.fault is None @@ -151,36 +152,42 @@ async def test_status_trait_refresh( assert q10_api.status.total_clean_area == 0 assert q10_api.status.total_clean_count == 0 assert q10_api.status.total_clean_time == 0 - assert q10_api.status.main_brush_life == 0 - assert q10_api.status.side_brush_life == 0 - assert q10_api.status.filter_life == 0 - assert q10_api.status.sensor_life == 0 + assert q10_api.consumable.main_brush_life == 0 + assert q10_api.consumable.side_brush_life == 0 + assert q10_api.consumable.filter_life == 0 + assert q10_api.consumable.sensor_life == 0 assert q10_api.status.cleaning_progress == 100 assert q10_api.status.fault == 0 assert q10_api.status.clean_mode == YXCleanType.VAC_AND_MOP assert q10_api.status.water_level == YXWaterLevel.LOW - # Additional settings/state captured from the full status dump. - assert q10_api.status.volume == 74 - assert q10_api.status.not_disturb == 1 - assert q10_api.status.child_lock == 0 + # Settings with dedicated traits are read from those traits. + assert q10_api.volume.volume == 74 + assert q10_api.do_not_disturb.not_disturb is True + assert q10_api.do_not_disturb.is_on is True + assert q10_api.child_lock.child_lock is False + assert q10_api.child_lock.is_on is False + assert q10_api.dust_collection.dust_switch is True + assert q10_api.dust_collection.is_on is True + assert q10_api.dust_collection.dust_setting == YXDeviceDustCollectionFrequency.DAILY + + # Additional state captured on the Status trait. assert q10_api.status.mop_state == 1 - assert q10_api.status.auto_boost == 0 - assert q10_api.status.dust_switch == 1 + assert q10_api.status.auto_boost is False assert q10_api.status.map_save_switch is True assert q10_api.status.recent_clean_record is False assert q10_api.status.valley_point_charging is False assert q10_api.status.clean_line == YXCleanLine.FAST - assert q10_api.status.line_laser_obstacle_avoidance == 1 + assert q10_api.status.line_laser_obstacle_avoidance is True assert q10_api.status.robot_country_code == "us" assert q10_api.status.robot_type == 1 + assert q10_api.status.time_zone == dpTimeZone(time_zone_city="America/Los_Angeles", time_zone_sec=-28800) - # Nested containers are parsed into their dataclasses. - assert q10_api.status.not_disturb_expand == dpNotDisturbExpand( + # Nested containers are parsed into their dataclasses on their own traits. + assert q10_api.do_not_disturb.not_disturb_expand == dpNotDisturbExpand( disturb_dust_enable=1, disturb_light=1, disturb_resume_clean=1, disturb_voice=1 ) - assert q10_api.status.time_zone == dpTimeZone(time_zone_city="America/Los_Angeles", time_zone_sec=-28800) - assert q10_api.status.net_info == dpNetInfo( + assert q10_api.network_info.net_info == dpNetInfo( wifi_name="wifi-network-name", ip_adress="1.1.1.2", mac="99:AA:88:BB:77:CC", signal=-50 ) From 2ece942065088120722eea47851bc06b30be8c17 Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:54:33 +0400 Subject: [PATCH 05/12] feat: decode Q10 carpet/area/mop/floor-direction status into enums+bools Reverse-engineered against live Q10 hardware (toggling each setting in the app and diffing the status dump): - area_unit -> YXAreaUnit (square_meter=0, square_feet=1) - carpet_clean_type -> YXCarpetCleanType (rise=0, avoid=1, ignore=2, cross=3) - mop_state -> bool (mop module attached) - ground_clean -> bool ("clean along floor direction") - Rename YXDeviceDustCollectionFrequency.DAILY -> REGULAR to match the app ("regular" vs "frequent" 15/30/45/60); codes unchanged. breakpoint_clean / add_clean_state / timer_type / user_plan / robot_type remain int (no app control found, runtime-only, or constant). --- .../data/b01_q10/b01_q10_code_mappings.py | 20 ++++++++++++++++++- roborock/data/b01_q10/b01_q10_containers.py | 15 ++++++++------ tests/devices/traits/b01/q10/test_settings.py | 2 +- tests/devices/traits/b01/q10/test_status.py | 9 +++++++-- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index 8e236596..e8a5b397 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -215,13 +215,31 @@ class YXDeviceCleanTask(RoborockModeEnum): class YXDeviceDustCollectionFrequency(RoborockModeEnum): - DAILY = "daily", 0 + # The app exposes "regular" (code 0) vs "frequent", where "frequent" selects + # one of the every-N-cleans intervals below. + REGULAR = "regular", 0 INTERVAL_15 = "interval_15", 15 INTERVAL_30 = "interval_30", 30 INTERVAL_45 = "interval_45", 45 INTERVAL_60 = "interval_60", 60 +class YXAreaUnit(RoborockModeEnum): + """Unit used to report cleaned area (dpAreaUnit).""" + + SQUARE_METER = "square_meter", 0 + SQUARE_FEET = "square_feet", 1 + + +class YXCarpetCleanType(RoborockModeEnum): + """Carpet handling behavior (dpCarpetCleanType).""" + + RISE = "rise", 0 # lift the mop and boost over carpet + AVOID = "avoid", 1 + IGNORE = "ignore", 2 + CROSS = "cross", 3 + + class RemoteCommand(IntEnum): FORWARD = 0 LEFT = 2 diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index 21f71afa..c768cba0 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -11,7 +11,9 @@ from ..containers import RoborockBase from .b01_q10_code_mappings import ( B01_Q10_DP, + YXAreaUnit, YXBackType, + YXCarpetCleanType, YXCleanLine, YXCleanType, YXDeviceCleanTask, @@ -112,6 +114,8 @@ class Q10Status(RoborockBase): # Additional state reported in the device's full status dump. clean_line: YXCleanLine | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_LINE}) + carpet_clean_type: YXCarpetCleanType | None = field(default=None, metadata={"dps": B01_Q10_DP.CARPET_CLEAN_TYPE}) + area_unit: YXAreaUnit | None = field(default=None, metadata={"dps": B01_Q10_DP.AREA_UNIT}) auto_boost: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.AUTO_BOOST}) multi_map_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MULTI_MAP_SWITCH}) map_save_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MAP_SAVE_SWITCH}) @@ -120,20 +124,19 @@ class Q10Status(RoborockBase): line_laser_obstacle_avoidance: bool | None = field( default=None, metadata={"dps": B01_Q10_DP.LINE_LASER_OBSTACLE_AVOIDANCE} ) + # Whether a mop module is attached, and whether "clean along floor direction" is on. + mop_state: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MOP_STATE}) + ground_clean: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.GROUND_CLEAN}) robot_country_code: str | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_COUNTRY_CODE}) time_zone: dpTimeZone | None = field(default=None, metadata={"dps": B01_Q10_DP.TIME_ZONE}) - # TODO(#846): value mappings for these ints are not yet decoded; keep as int - # until reverse-engineered, then promote to enums. - mop_state: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MOP_STATE}) + # TODO(#846): value mappings for these ints are not yet decoded (no app + # control found / internal / constant); keep as int until reverse-engineered. breakpoint_clean: int | None = field(default=None, metadata={"dps": B01_Q10_DP.BREAKPOINT_CLEAN}) - carpet_clean_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CARPET_CLEAN_TYPE}) - ground_clean: int | None = field(default=None, metadata={"dps": B01_Q10_DP.GROUND_CLEAN}) add_clean_state: int | None = field(default=None, metadata={"dps": B01_Q10_DP.ADD_CLEAN_STATE}) timer_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TIMER_TYPE}) user_plan: int | None = field(default=None, metadata={"dps": B01_Q10_DP.USER_PLAN}) robot_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_TYPE}) - area_unit: int | None = field(default=None, metadata={"dps": B01_Q10_DP.AREA_UNIT}) @dataclass diff --git a/tests/devices/traits/b01/q10/test_settings.py b/tests/devices/traits/b01/q10/test_settings.py index 26de9c97..7b3474c5 100644 --- a/tests/devices/traits/b01/q10/test_settings.py +++ b/tests/devices/traits/b01/q10/test_settings.py @@ -70,7 +70,7 @@ async def test_switch_disable_sends_zero(fake_channel: FakeChannel, command: Com @pytest.mark.parametrize( ("frequency", "code"), [ - (YXDeviceDustCollectionFrequency.DAILY, 0), + (YXDeviceDustCollectionFrequency.REGULAR, 0), (YXDeviceDustCollectionFrequency.INTERVAL_30, 30), (YXDeviceDustCollectionFrequency.INTERVAL_60, 60), (YXDeviceDustCollectionFrequency.INTERVAL_15, 15), diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index 2be501e0..98732ce6 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -11,6 +11,8 @@ from roborock.data.b01_q10.b01_q10_code_mappings import ( B01_Q10_DP, + YXAreaUnit, + YXCarpetCleanType, YXCleanLine, YXCleanType, YXDeviceCleanTask, @@ -169,10 +171,13 @@ async def test_status_trait_refresh( assert q10_api.child_lock.is_on is False assert q10_api.dust_collection.dust_switch is True assert q10_api.dust_collection.is_on is True - assert q10_api.dust_collection.dust_setting == YXDeviceDustCollectionFrequency.DAILY + assert q10_api.dust_collection.dust_setting == YXDeviceDustCollectionFrequency.REGULAR # Additional state captured on the Status trait. - assert q10_api.status.mop_state == 1 + assert q10_api.status.mop_state is True + assert q10_api.status.ground_clean is False + assert q10_api.status.carpet_clean_type == YXCarpetCleanType.RISE + assert q10_api.status.area_unit == YXAreaUnit.SQUARE_METER assert q10_api.status.auto_boost is False assert q10_api.status.map_save_switch is True assert q10_api.status.recent_clean_record is False From 86bd888512d369c90f84906b127fdff550b1c351 Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:08:08 +0400 Subject: [PATCH 06/12] feat: decode Q10 add_clean_state as a bool Observed on live hardware: dpAddCleanState (96) pulses 0->1 while the app's "re cleaning" (draw-a-rectangle / add-area) request is in progress, then back to 0 once the robot has the area. Model it as a bool. --- roborock/data/b01_q10/b01_q10_containers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index c768cba0..759443e7 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -127,13 +127,15 @@ class Q10Status(RoborockBase): # Whether a mop module is attached, and whether "clean along floor direction" is on. mop_state: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MOP_STATE}) ground_clean: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.GROUND_CLEAN}) + # True while an "add area" / re-clean (the app's draw-a-rectangle "re cleaning") + # request is in progress; pulses back to False once the robot has the area. + add_clean_state: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.ADD_CLEAN_STATE}) robot_country_code: str | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_COUNTRY_CODE}) time_zone: dpTimeZone | None = field(default=None, metadata={"dps": B01_Q10_DP.TIME_ZONE}) # TODO(#846): value mappings for these ints are not yet decoded (no app # control found / internal / constant); keep as int until reverse-engineered. breakpoint_clean: int | None = field(default=None, metadata={"dps": B01_Q10_DP.BREAKPOINT_CLEAN}) - add_clean_state: int | None = field(default=None, metadata={"dps": B01_Q10_DP.ADD_CLEAN_STATE}) timer_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TIMER_TYPE}) user_plan: int | None = field(default=None, metadata={"dps": B01_Q10_DP.USER_PLAN}) robot_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_TYPE}) From c3f8cab2bccc89a2f644cefb273a1f4aa95e458d Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:59:43 +0400 Subject: [PATCH 07/12] fix: stop unmapped Q10 data points from logging "not a valid code" warnings ss07 hardware pushes data points this library does not model (confirmed live: DPs 112 and 113, which stay 0 across docked/charging, segment cleaning, lifted-off-ground fault, returning to dock and dustbin-removed states, and are absent from the official app's vacuum plugin). Decoding every status push ran them through `from_code_optional`, which routed through `from_code` and logged a WARNING before returning None -- so each unknown DP spammed " is not a valid code for B01_Q10_DP". - Make `from_code_optional` (and the int path of `from_any_optional`) do their own silent lookup instead of going through `from_code`. `from_code` stays strict (warns + raises) for callers that require a known code. - Remove the duplicate-coded legacy entries `JUMP_SCAN` (101) and `CLIFF_RESTRICTED_AREA` (102): they collided with the confirmed `COMMON` (101) / `REQUEST_DPS` (102) codes, shadowing them in `from_code`, and are unused with unverifiable codes. Also drop the orphaned `CLIFF_RESTRICTED_AREA_UP` (103). - Document DPs 112/113 as observed-but-unidentified. - Tests: assert `from_code_optional` is silent, `from_code` still warns, and the B01 decode drops 112/113 without logging. --- .../data/b01_q10/b01_q10_code_mappings.py | 16 +++++++++--- roborock/data/code_mappings.py | 26 ++++++++++++------- tests/data/test_code_mappings.py | 26 +++++++++++++++++++ tests/protocols/test_b01_q10_protocol.py | 20 +++++++++++--- 4 files changed, 72 insertions(+), 16 deletions(-) diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index e8a5b397..d14b3e5c 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -85,10 +85,13 @@ class B01_Q10_DP(RoborockModeEnum): SUSPECTED_THRESHOLD = ("dpSuspectedThreshold", 99) SUSPECTED_THRESHOLD_UP = ("dpSuspectedThresholdUp", 100) COMMON = ("dpCommon", 101) - JUMP_SCAN = ("dpJumpScan", 101) REQUEST_DPS = ("dpRequestDps", 102) # NOTE: typo "dpRequetdps" in source code - CLIFF_RESTRICTED_AREA = ("dpCliffRestrictedArea", 102) - CLIFF_RESTRICTED_AREA_UP = ("dpCliffRestrictedAreaUp", 103) + # NOTE: the legacy B01 source also listed dpJumpScan (101), dpCliffRestrictedArea + # (102) and dpCliffRestrictedAreaUp (103). The first two collided with the + # confirmed dpCommon/dpRequestDps codes above (verified against ss07 hardware and + # the official app plugin), shadowing them in ``from_code``. They are not used + # anywhere and their real codes could not be verified, so they were removed + # rather than left as wrong duplicates. Re-add with verified codes if needed. BREAKPOINT_CLEAN = ("dpBreakpointClean", 104) VALLEY_POINT_CHARGING = ("dpValleyPointCharging", 105) VALLEY_POINT_CHARGING_DATA_UP = ("dpValleyPointChargingDataUp", 106) @@ -96,6 +99,13 @@ class B01_Q10_DP(RoborockModeEnum): VOICE_VERSION = ("dpVoiceVersion", 108) ROBOT_COUNTRY_CODE = ("dpRobotCountryCode", 109) HEARTBEAT = ("dpHeartbeat", 110) + # NOTE: ss07 hardware also pushes data points 112 and 113 in its full status + # dump. They are absent from the official app's vacuum plugin and stayed 0 + # across every observed state (docked/charging, segment cleaning, lifted-off- + # ground fault, returning to dock, dustbin removed), so their meaning is not + # yet known. They are intentionally left unmapped; ``decode_rpc_response`` + # silently ignores unknown codes via ``from_code_optional``, so they do not + # produce "not a valid code" warnings. Map them here once identified. STATUS = ("dpStatus", 121) BATTERY = ("dpBattery", 122) FAN_LEVEL = ("dpFanLevel", 123) # NOTE: typo "dpfunLevel" in source code diff --git a/roborock/data/code_mappings.py b/roborock/data/code_mappings.py index d1f36b83..a0864408 100644 --- a/roborock/data/code_mappings.py +++ b/roborock/data/code_mappings.py @@ -76,11 +76,17 @@ def from_code(cls, code: int) -> Self: @classmethod def from_code_optional(cls, code: int) -> Self | None: - """Gracefully return None if the code does not exist.""" - try: - return cls.from_code(code) - except ValueError: - return None + """Gracefully return None if the code does not exist. + + This is the silent counterpart to :meth:`from_code`: callers use it when + an unknown code is expected and tolerable (e.g. decoding a device push + that may include data points this library does not model yet), so it must + not emit the "not a valid code" warning that ``from_code`` logs. + """ + for member in cls: + if member.code == code: + return member + return None @classmethod def from_value(cls, value: str) -> Self: @@ -115,12 +121,14 @@ def from_any_optional(cls, value: str | int) -> Self | None: return cls.from_value(str(value)) except ValueError: pass - # Try integer code lookup (e.g. "11") + # Try integer code lookup (e.g. "11"). Use the silent optional variant so + # a value that is neither a name, a DP string, nor a known code resolves + # to None without logging a spurious "not a valid code" warning. try: - return cls.from_code(int(value)) + int_code = int(value) except (ValueError, TypeError): - pass - return None + return None + return cls.from_code_optional(int_code) @classmethod def keys(cls) -> list[str]: diff --git a/tests/data/test_code_mappings.py b/tests/data/test_code_mappings.py index f7ba6321..48ef6f8e 100644 --- a/tests/data/test_code_mappings.py +++ b/tests/data/test_code_mappings.py @@ -2,10 +2,13 @@ These tests exercise the custom enum methods using arbitrary enum values. """ +import logging + import pytest from roborock import HomeDataProduct, RoborockCategory from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType +from roborock.data.code_mappings import completed_warnings def test_from_code() -> None: @@ -26,6 +29,29 @@ def test_invalid_from_code_optional() -> None: assert B01_Q10_DP.from_code_optional(999999) is None +def test_from_code_optional_does_not_warn(caplog: pytest.LogCaptureFixture) -> None: + """from_code_optional must silently return None for unknown codes. + + Regression test: ss07 hardware pushes data points this library does not model + (e.g. DPs 112 and 113); resolving them via the optional lookup must not emit + the "not a valid code" warning that the strict ``from_code`` logs. + """ + completed_warnings.discard("112 is not a valid code for B01_Q10_DP") + completed_warnings.discard("113 is not a valid code for B01_Q10_DP") + with caplog.at_level(logging.WARNING): + assert B01_Q10_DP.from_code_optional(112) is None + assert B01_Q10_DP.from_code_optional(113) is None + assert "not a valid code" not in caplog.text + + +def test_from_code_still_warns(caplog: pytest.LogCaptureFixture) -> None: + """The strict from_code keeps logging and raising on unknown codes.""" + completed_warnings.discard("87654 is not a valid code for B01_Q10_DP") + with caplog.at_level(logging.WARNING), pytest.raises(ValueError, match="not a valid code"): + B01_Q10_DP.from_code(87654) + assert "87654 is not a valid code for B01_Q10_DP" in caplog.text + + def test_from_name() -> None: """Test from_name method.""" assert B01_Q10_DP.START_CLEAN == B01_Q10_DP.from_name("START_CLEAN") diff --git a/tests/protocols/test_b01_q10_protocol.py b/tests/protocols/test_b01_q10_protocol.py index 62ee2c27..b43e348e 100644 --- a/tests/protocols/test_b01_q10_protocol.py +++ b/tests/protocols/test_b01_q10_protocol.py @@ -1,6 +1,7 @@ """Tests for the B01 protocol message encoding and decoding.""" import json +import logging import pathlib from collections.abc import Generator from typing import Any @@ -10,6 +11,7 @@ from syrupy import SnapshotAssertion from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXWaterLevel +from roborock.data.code_mappings import completed_warnings from roborock.exceptions import RoborockException from roborock.protocols.b01_q10_protocol import ( decode_rpc_response, @@ -74,21 +76,31 @@ def test_decode_invalid_rpc_payload(payload: bytes, expected_error_message: str) decode_rpc_response(message) -def test_decode_unknown_dps_code() -> None: - """Test decoding a B01 RPC response protocol message.""" +def test_decode_unknown_dps_code(caplog: pytest.LogCaptureFixture) -> None: + """Unknown data points are dropped silently, without logging warnings. + + ss07 hardware pushes DPs 112 and 113 (and occasionally others) that this + library does not model. They must be ignored without emitting "not a valid + code" warnings, which previously spammed the log on every status push. + """ + completed_warnings.discard("112 is not a valid code for B01_Q10_DP") + completed_warnings.discard("113 is not a valid code for B01_Q10_DP") + completed_warnings.discard("909090 is not a valid code for B01_Q10_DP") message = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, - payload=b'{"dps": {"909090": 123, "122":100}}', + payload=b'{"dps": {"909090": 123, "112": 0, "113": 0, "122": 100}}', seq=12750, version=b"B01", random=97431, timestamp=1652547161, ) - decoded_message = decode_rpc_response(message) + with caplog.at_level(logging.WARNING): + decoded_message = decode_rpc_response(message) assert decoded_message == { B01_Q10_DP.BATTERY: 100, } + assert "not a valid code" not in caplog.text @pytest.mark.parametrize( From d1296593af17ab269efb3f836eefa2102c4573c9 Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:59:43 +0400 Subject: [PATCH 08/12] fix: avoid Q10 Consumable/NetworkInfo shadowing v1 in roborock.data The new per-concern Q10 read-models `Consumable` and `NetworkInfo` were star-exported into the `roborock.data` namespace where v1 also exports those names, so `from roborock.data import Consumable` silently resolved to the v1 class (and mypy flagged the incompatible re-import). Rename the Q10 read-models to `Q10Consumable` / `Q10NetworkInfo`; the traits already import them by fully qualified path. --- roborock/data/b01_q10/b01_q10_containers.py | 20 ++++++++++++------- roborock/devices/traits/b01/q10/consumable.py | 8 ++++---- .../devices/traits/b01/q10/network_info.py | 8 ++++---- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index 759443e7..db57a313 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -160,9 +160,7 @@ class DoNotDisturb(RoborockBase): """Do Not Disturb read-model.""" not_disturb: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB}) - not_disturb_expand: dpNotDisturbExpand | None = field( - default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB_EXPAND} - ) + not_disturb_expand: dpNotDisturbExpand | None = field(default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB_EXPAND}) @dataclass @@ -176,8 +174,12 @@ class DustCollection(RoborockBase): @dataclass -class Consumable(RoborockBase): - """Consumable / accessory remaining-life read-model.""" +class Q10Consumable(RoborockBase): + """Consumable / accessory remaining-life read-model. + + Named with a ``Q10`` prefix to avoid shadowing the v1 ``Consumable`` when both + are star-imported into the ``roborock.data`` namespace. + """ main_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MAIN_BRUSH_LIFE}) side_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SIDE_BRUSH_LIFE}) @@ -186,7 +188,11 @@ class Consumable(RoborockBase): @dataclass -class NetworkInfo(RoborockBase): - """Network information read-model.""" +class Q10NetworkInfo(RoborockBase): + """Network information read-model. + + Named with a ``Q10`` prefix to avoid shadowing the v1 ``NetworkInfo`` when both + are star-imported into the ``roborock.data`` namespace. + """ net_info: dpNetInfo | None = field(default=None, metadata={"dps": B01_Q10_DP.NET_INFO}) diff --git a/roborock/devices/traits/b01/q10/consumable.py b/roborock/devices/traits/b01/q10/consumable.py index 3210d0c5..914acd9f 100644 --- a/roborock/devices/traits/b01/q10/consumable.py +++ b/roborock/devices/traits/b01/q10/consumable.py @@ -2,7 +2,7 @@ import logging -from roborock.data.b01_q10.b01_q10_containers import Consumable +from roborock.data.b01_q10.b01_q10_containers import Q10Consumable from roborock.devices.traits.common import DpsDataConverter from .common import UpdatableTrait @@ -10,12 +10,12 @@ _LOGGER = logging.getLogger(__name__) -class ConsumableTrait(Consumable, UpdatableTrait): +class ConsumableTrait(Q10Consumable, UpdatableTrait): """Trait exposing remaining life of consumables (brushes, filter, sensors).""" - _CONVERTER = DpsDataConverter.from_dataclass(Consumable) + _CONVERTER = DpsDataConverter.from_dataclass(Q10Consumable) def __init__(self) -> None: """Initialize the consumable trait.""" - Consumable.__init__(self) + Q10Consumable.__init__(self) UpdatableTrait.__init__(self, command=None, logger=_LOGGER) diff --git a/roborock/devices/traits/b01/q10/network_info.py b/roborock/devices/traits/b01/q10/network_info.py index b80bf532..2fd4d504 100644 --- a/roborock/devices/traits/b01/q10/network_info.py +++ b/roborock/devices/traits/b01/q10/network_info.py @@ -2,7 +2,7 @@ import logging -from roborock.data.b01_q10.b01_q10_containers import NetworkInfo +from roborock.data.b01_q10.b01_q10_containers import Q10NetworkInfo from roborock.devices.traits.common import DpsDataConverter from .common import UpdatableTrait @@ -10,12 +10,12 @@ _LOGGER = logging.getLogger(__name__) -class NetworkInfoTrait(NetworkInfo, UpdatableTrait): +class NetworkInfoTrait(Q10NetworkInfo, UpdatableTrait): """Trait exposing the device's network information (read-only).""" - _CONVERTER = DpsDataConverter.from_dataclass(NetworkInfo) + _CONVERTER = DpsDataConverter.from_dataclass(Q10NetworkInfo) def __init__(self) -> None: """Initialize the network info trait.""" - NetworkInfo.__init__(self) + Q10NetworkInfo.__init__(self) UpdatableTrait.__init__(self, command=None, logger=_LOGGER) From 5ce284f024f49243e757fdeb8e924fe0ae319030 Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:26:54 +0400 Subject: [PATCH 09/12] fix: keep Q10 CLIFF_RESTRICTED_AREA_UP (103); ss07 pushes it While removing the duplicate-coded legacy entries I also dropped DP 103, but live capture shows ss07 hardware does push data point 103 (an empty list when no cliff-restricted areas are configured). Restore CLIFF_RESTRICTED_AREA_UP at 103; only the genuinely-colliding dpJumpScan (101) and dpCliffRestrictedArea (102) stay removed. --- roborock/data/b01_q10/b01_q10_code_mappings.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index d14b3e5c..c3e1771c 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -86,12 +86,14 @@ class B01_Q10_DP(RoborockModeEnum): SUSPECTED_THRESHOLD_UP = ("dpSuspectedThresholdUp", 100) COMMON = ("dpCommon", 101) REQUEST_DPS = ("dpRequestDps", 102) # NOTE: typo "dpRequetdps" in source code - # NOTE: the legacy B01 source also listed dpJumpScan (101), dpCliffRestrictedArea - # (102) and dpCliffRestrictedAreaUp (103). The first two collided with the - # confirmed dpCommon/dpRequestDps codes above (verified against ss07 hardware and - # the official app plugin), shadowing them in ``from_code``. They are not used - # anywhere and their real codes could not be verified, so they were removed - # rather than left as wrong duplicates. Re-add with verified codes if needed. + # NOTE: the legacy B01 source also listed dpJumpScan (101) and + # dpCliffRestrictedArea (102), which collided with the confirmed dpCommon / + # dpRequestDps codes above (verified against ss07 hardware and the official app + # plugin) and shadowed them in ``from_code``. Both are unused and their real + # codes could not be verified, so they were removed rather than left as wrong + # duplicates. dpCliffRestrictedAreaUp (103) is kept: ss07 hardware does push + # data point 103 (an empty list when no cliff-restricted areas are set). + CLIFF_RESTRICTED_AREA_UP = ("dpCliffRestrictedAreaUp", 103) BREAKPOINT_CLEAN = ("dpBreakpointClean", 104) VALLEY_POINT_CHARGING = ("dpValleyPointCharging", 105) VALLEY_POINT_CHARGING_DATA_UP = ("dpValleyPointChargingDataUp", 106) From 34f94ca914bf546498a30212dc58967ab7e23368 Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:26:54 +0400 Subject: [PATCH 10/12] fix: correct Q10 vacuum command payloads, verified against ss07 hardware The VacuumTrait movement commands were unverified guesses (with TODO notes) and sent payload shapes the device ignores. Reverse-engineered the correct format from the official app's vacuum plugin and confirmed each one live against an ss07 robot (watching the DP stream react): - start_clean: {"201": 1} (was {"201": {"cmd": 1}}) -> clean_task_type 1 - pause_clean: {"204": 0} (was {"204": {}}) - resume_clean: {"205": 0} (was {"205": {}}) - stop_clean: {"206": 0} (was {"206": {}}) - return_to_dock: {"202": 5} (was {"203": {}}; dpStartBack=charge, and DP 203 is not the dock-return command) - empty_dustbin: {"203": 2} (already correct; confirmed -> status emptying) Also add spot_clean ({"201": 5} -> clean_task_type 5), confirmed live, plus a `q10-vacuum-spot` CLI command. set_fan_level / set_clean_mode were confirmed to work as bare DP writes (no dpCommon wrapper needed, unlike the settings). Segment ({"201": 2}), zone ({"201": 3}) and build-map ({"201": 4}) clean are documented but not exposed: they need a room/zone selection payload whose shape could not be verified (the broker rejects subscribing to the app's command topic, so the app's exact request can't be captured). --- roborock/cli.py | 21 +++++- roborock/devices/traits/b01/q10/vacuum.py | 74 +++++++++++---------- tests/devices/traits/b01/q10/test_vacuum.py | 12 ++-- 3 files changed, 65 insertions(+), 42 deletions(-) diff --git a/roborock/cli.py b/roborock/cli.py index c9a6725b..3fa3f54b 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -1331,6 +1331,23 @@ async def q10_vacuum_dock(ctx: click.Context, device_id: str) -> None: click.echo(f"Error: {e}") +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.pass_context +@async_command +async def q10_vacuum_spot(ctx: click.Context, device_id: str) -> None: + """Start a spot / part clean on a Q10 device.""" + context: RoborockContext = ctx.obj + try: + trait = await _q10_vacuum_trait(context, device_id) + await trait.spot_clean() + click.echo("Starting spot clean...") + except RoborockUnsupportedFeature: + click.echo("Device does not support B01 Q10 protocol. Is it a Q10?") + except RoborockException as e: + click.echo(f"Error: {e}") + + async def _q10_set(ctx: click.Context, device_id: str, apply: Callable[[Any], Any], message: str) -> None: """Run a Q10 settings write and report the result.""" context: RoborockContext = ctx.obj @@ -1428,9 +1445,7 @@ async def q10_set_dust_frequency(ctx: click.Context, device_id: str, frequency: """Set how often the dock empties the bin (0 = daily, else every N cleans).""" freq = YXDeviceDustCollectionFrequency.from_code(int(frequency)) label = "daily" if freq.code == 0 else f"every {freq.code} cleans" - await _q10_set( - ctx, device_id, lambda p: p.dust_collection.set_frequency(freq), f"Dust frequency set to {label}" - ) + await _q10_set(ctx, device_id, lambda p: p.dust_collection.set_frequency(freq), f"Dust frequency set to {label}") @session.command() diff --git a/roborock/devices/traits/b01/q10/vacuum.py b/roborock/devices/traits/b01/q10/vacuum.py index 1ed9febb..fbe447de 100644 --- a/roborock/devices/traits/b01/q10/vacuum.py +++ b/roborock/devices/traits/b01/q10/vacuum.py @@ -21,50 +21,56 @@ def __init__(self, command: CommandTrait) -> None: self._command = command async def start_clean(self) -> None: - """Start cleaning.""" - await self._command.send( - command=B01_Q10_DP.START_CLEAN, - # TODO: figure out other commands - # 1 = start cleaning - # 2 = "electoral" clean, also has "clean_parameters" - # 4 = fast create map - params={"cmd": 1}, - ) + """Start a whole-home clean. + + The ``dpStartClean`` (201) command takes a bare integer task code: + ``1`` = whole-home, ``2`` = segment/room, ``3`` = zone, ``4`` = build map, + ``5`` = spot. Whole-home and spot take no extra parameters; segment and + zone need a room/zone selection whose payload shape is not yet known, so + only whole-home (here) and spot (:meth:`spot_clean`) are exposed. + + Verified live against ss07 hardware: ``{"dps": {"201": 1}}`` starts a + whole-home clean (clean_task_type -> 1). + """ + await self._command.send(command=B01_Q10_DP.START_CLEAN, params=1) + + async def spot_clean(self) -> None: + """Start a spot / part clean around the robot's current position. + + Verified live: ``{"dps": {"201": 5}}`` (clean_task_type -> 5). + """ + await self._command.send(command=B01_Q10_DP.START_CLEAN, params=5) async def pause_clean(self) -> None: - """Pause cleaning.""" - await self._command.send( - command=B01_Q10_DP.PAUSE, - params={}, - ) + """Pause the current task. Verified live: ``{"dps": {"204": 0}}``.""" + await self._command.send(command=B01_Q10_DP.PAUSE, params=0) async def resume_clean(self) -> None: - """Resume cleaning.""" - await self._command.send( - command=B01_Q10_DP.RESUME, - params={}, - ) + """Resume a paused task. Verified live: ``{"dps": {"205": 0}}``.""" + await self._command.send(command=B01_Q10_DP.RESUME, params=0) async def stop_clean(self) -> None: - """Stop cleaning.""" - await self._command.send( - command=B01_Q10_DP.STOP, - params={}, - ) + """Stop / cancel the current task. Verified live: ``{"dps": {"206": 0}}``.""" + await self._command.send(command=B01_Q10_DP.STOP, params=0) async def return_to_dock(self) -> None: - """Return to dock.""" - await self._command.send( - command=B01_Q10_DP.START_DOCK_TASK, - params={}, - ) + """Send the robot back to the dock to charge. + + Uses ``dpStartBack`` (202) with the back-dock task code ``5`` (charge), + matching the official app. Verified live: ``{"dps": {"202": 5}}`` puts the + robot into the returning state. (The other back-dock codes are ``1`` = + wash mop en route and ``4`` = collect dust en route.) + """ + await self._command.send(command=B01_Q10_DP.START_BACK, params=5) async def empty_dustbin(self) -> None: - """Empty the dustbin at the dock.""" - await self._command.send( - command=B01_Q10_DP.START_DOCK_TASK, - params=2, # 2 = dock task type for "empty dustbin" - ) + """Empty the dustbin at the dock. + + Verified live: ``{"dps": {"203": 2}}`` triggers dust collection + (status -> emptying_the_bin). This is a dock task (``dpStartDockTask``), + distinct from the en-route collect-dust back-dock code. + """ + await self._command.send(command=B01_Q10_DP.START_DOCK_TASK, params=2) async def set_clean_mode(self, mode: YXCleanType) -> None: """Set the cleaning mode (vacuum, mop, or both).""" diff --git a/tests/devices/traits/b01/q10/test_vacuum.py b/tests/devices/traits/b01/q10/test_vacuum.py index 499b9bbc..e736d28c 100644 --- a/tests/devices/traits/b01/q10/test_vacuum.py +++ b/tests/devices/traits/b01/q10/test_vacuum.py @@ -28,11 +28,13 @@ def vacuumm_fixture(q10_api: Q10PropertiesApi) -> VacuumTrait: @pytest.mark.parametrize( ("command_fn", "expected_payload"), [ - (lambda x: x.start_clean(), {"201": {"cmd": 1}}), - (lambda x: x.pause_clean(), {"204": {}}), - (lambda x: x.resume_clean(), {"205": {}}), - (lambda x: x.stop_clean(), {"206": {}}), - (lambda x: x.return_to_dock(), {"203": {}}), + # Payloads verified live against ss07 hardware. + (lambda x: x.start_clean(), {"201": 1}), + (lambda x: x.spot_clean(), {"201": 5}), + (lambda x: x.pause_clean(), {"204": 0}), + (lambda x: x.resume_clean(), {"205": 0}), + (lambda x: x.stop_clean(), {"206": 0}), + (lambda x: x.return_to_dock(), {"202": 5}), (lambda x: x.empty_dustbin(), {"203": 2}), (lambda x: x.set_clean_mode(YXCleanType.VAC_AND_MOP), {"137": 1}), (lambda x: x.set_fan_level(YXFanLevel.BALANCED), {"123": 2}), From e0290223323b9970bbec12bb3a37eabd20ebd2f5 Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:50:20 +0400 Subject: [PATCH 11/12] fix: show all Q10 read-model traits in status, wait for fresh push The Q10 status command only printed properties.status, omitting the other read-model traits that refresh() populates (volume, child lock, do-not-disturb, dust collection, network info, consumables). It also gated on properties.status.status is not None, which the persistent subscribe loop can satisfy with stale data pushed before the command ran. Register update listeners on all refreshed traits, fire refresh(), and wait for a fresh update (with a short settle window for multi-packet pushes) before dumping every trait. Verified live against ss07 (R1). --- roborock/cli.py | 58 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/roborock/cli.py b/roborock/cli.py index 3fa3f54b..bb06e2f7 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -460,22 +460,52 @@ async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumT async def _display_q10_status(context: RoborockContext, device_id: str) -> None: - """Refresh and display the status of a B01 Q10 device. - - Unlike V1 devices, the Q10 reports its status asynchronously: ``refresh()`` - sends a request and the device streams the values back, so we poll the - status trait briefly until it has been populated. + """Refresh and display the full status of a B01 Q10 device. + + Unlike V1 devices, the Q10 reports its state asynchronously: ``refresh()`` + sends a request and the device streams the values back over the persistent + subscribe loop. That loop also delivers unsolicited pushes, so the read-model + traits may already hold (possibly stale) values from before this command ran + -- checking that a field is merely populated isn't enough. To display data + the device sent *in response to this refresh*, we register update listeners, + fire the refresh, and wait for a fresh update before reading the traits. + + All read-model traits refreshed by :meth:`Q10PropertiesApi.refresh` are shown, + not just ``status`` (volume, child lock, do-not-disturb, dust collection, + network info and consumables are part of the device's reported state too). """ properties = await _q10_properties(context, device_id) - await properties.refresh() - for _ in range(50): - if properties.status.status is not None: - break - await asyncio.sleep(0.1) - else: - click.echo("Timed out waiting for status from device") - return - click.echo(dump_json(properties.status.as_dict())) + + # Read-model traits populated from the device's DPS push stream. + traits = { + "status": properties.status, + "volume": properties.volume, + "child_lock": properties.child_lock, + "do_not_disturb": properties.do_not_disturb, + "dust_collection": properties.dust_collection, + "network_info": properties.network_info, + "consumable": properties.consumable, + } + + updated = asyncio.Event() + unsubscribes = [trait.add_update_listener(updated.set) for trait in traits.values()] + try: + await properties.refresh() + try: + await asyncio.wait_for(updated.wait(), timeout=5) + except TimeoutError: + click.echo("Timed out waiting for status from device") + return + # The device streams its DPS across several pushes; give the remaining + # ones a brief window to arrive after the first fresh update. + await asyncio.sleep(0.5) + finally: + for unsubscribe in unsubscribes: + unsubscribe() + + # Each concrete trait also subclasses a RoborockBase read-model, so it has + # ``as_dict``; the cast satisfies the typed UpdatableTrait view above. + click.echo(dump_json({name: cast(RoborockBase, trait).as_dict() for name, trait in traits.items()})) @session.command() From 0cf8f7c2e794137e9b74b2bf1466042355950160 Mon Sep 17 00:00:00 2001 From: Vincent <2070309+tubededentifrice@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:04:50 +0400 Subject: [PATCH 12/12] feat: Q10 (B01/ss07) room/segment cleaning Add VacuumTrait.clean_segments(segment_ids) for the Q10. dpStartClean (201) carries the room selection as an object: {"cmd": 2, "clean_paramters": [ids]} (cmd 2 = segment clean; "clean_paramters" mirrors the firmware's misspelling). Segment ids are the same room ids reported by the map (map.rooms). Captured from the official app and verified live against ss07 hardware (clean_task_type -> 2 / electoral). Adds a q10-clean-segments CLI command and trait payload tests. --- roborock/cli.py | 33 +++++++++++++++++++++ roborock/devices/traits/b01/q10/vacuum.py | 31 +++++++++++++++---- tests/devices/traits/b01/q10/test_vacuum.py | 2 ++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/roborock/cli.py b/roborock/cli.py index bb06e2f7..61b492c2 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -1378,6 +1378,39 @@ async def q10_vacuum_spot(ctx: click.Context, device_id: str) -> None: click.echo(f"Error: {e}") +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option( + "--segments", + required=True, + help="Comma-separated room/segment ids to clean (see the `rooms` command), e.g. 9,2", +) +@click.pass_context +@async_command +async def q10_clean_segments(ctx: click.Context, device_id: str, segments: str) -> None: + """Start a room / segment clean on a Q10 device. + + Room ids come from the `rooms` command (the device's map rooms). + """ + context: RoborockContext = ctx.obj + try: + segment_ids = [int(s) for s in segments.split(",") if s.strip()] + except ValueError: + click.echo("--segments must be comma-separated integers, e.g. 9,2") + return + if not segment_ids: + click.echo("No segment ids provided") + return + try: + trait = await _q10_vacuum_trait(context, device_id) + await trait.clean_segments(segment_ids) + click.echo(f"Starting room clean of segments {segment_ids}...") + except RoborockUnsupportedFeature: + click.echo("Device does not support B01 Q10 protocol. Is it a Q10?") + except RoborockException as e: + click.echo(f"Error: {e}") + + async def _q10_set(ctx: click.Context, device_id: str, apply: Callable[[Any], Any], message: str) -> None: """Run a Q10 settings write and report the result.""" context: RoborockContext = ctx.obj diff --git a/roborock/devices/traits/b01/q10/vacuum.py b/roborock/devices/traits/b01/q10/vacuum.py index fbe447de..68dca096 100644 --- a/roborock/devices/traits/b01/q10/vacuum.py +++ b/roborock/devices/traits/b01/q10/vacuum.py @@ -23,17 +23,38 @@ def __init__(self, command: CommandTrait) -> None: async def start_clean(self) -> None: """Start a whole-home clean. - The ``dpStartClean`` (201) command takes a bare integer task code: - ``1`` = whole-home, ``2`` = segment/room, ``3`` = zone, ``4`` = build map, - ``5`` = spot. Whole-home and spot take no extra parameters; segment and - zone need a room/zone selection whose payload shape is not yet known, so - only whole-home (here) and spot (:meth:`spot_clean`) are exposed. + The ``dpStartClean`` (201) command selects a task by code: ``1`` = + whole-home, ``2`` = segment/room (see :meth:`clean_segments`), ``3`` = + zone, ``4`` = build map, ``5`` = spot. Whole-home and spot accept the + bare integer code; segment cleaning needs a room selection (an object + payload) instead. Verified live against ss07 hardware: ``{"dps": {"201": 1}}`` starts a whole-home clean (clean_task_type -> 1). """ await self._command.send(command=B01_Q10_DP.START_CLEAN, params=1) + async def clean_segments(self, segment_ids: list[int]) -> None: + """Start a room / segment clean for the given segment (room) ids. + + The ids are the same room ids the device reports on its map (see the Q10 + ``MapContentTrait`` -- ``map.rooms``, each with an ``id``). + + Unlike whole-home and spot, ``dpStartClean`` (201) carries the room + selection as an object: ``{"cmd": 2, "clean_paramters": [, ...]}``, + where ``cmd`` ``2`` is the segment-clean task code. ``clean_paramters`` + intentionally mirrors the device's misspelling of "parameters" -- the + firmware only accepts that exact key. + + Verified live against ss07 hardware: sending + ``{"dps": {"201": {"cmd": 2, "clean_paramters": [9]}}}`` starts cleaning + room 9 (clean_task_type -> 2 / electoral). Captured from the official app. + """ + await self._command.send( + command=B01_Q10_DP.START_CLEAN, + params={"cmd": 2, "clean_paramters": segment_ids}, + ) + async def spot_clean(self) -> None: """Start a spot / part clean around the robot's current position. diff --git a/tests/devices/traits/b01/q10/test_vacuum.py b/tests/devices/traits/b01/q10/test_vacuum.py index e736d28c..17f6b3de 100644 --- a/tests/devices/traits/b01/q10/test_vacuum.py +++ b/tests/devices/traits/b01/q10/test_vacuum.py @@ -30,6 +30,8 @@ def vacuumm_fixture(q10_api: Q10PropertiesApi) -> VacuumTrait: [ # Payloads verified live against ss07 hardware. (lambda x: x.start_clean(), {"201": 1}), + (lambda x: x.clean_segments([9]), {"201": {"cmd": 2, "clean_paramters": [9]}}), + (lambda x: x.clean_segments([1, 2]), {"201": {"cmd": 2, "clean_paramters": [1, 2]}}), (lambda x: x.spot_clean(), {"201": 5}), (lambda x: x.pause_clean(), {"204": 0}), (lambda x: x.resume_clean(), {"205": 0}),