From c9349b2401fe79f636d659445fb1b22ae32e8534 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 31 Jul 2026 07:59:50 -0700 Subject: [PATCH] fix: support Q10 diagnostics display --- roborock/data/containers.py | 5 +- roborock/devices/device.py | 2 + roborock/devices/traits/b01/q10/__init__.py | 12 + roborock/devices/traits/b01/q10/map.py | 15 + .../b01/q10/__snapshots__/test_status.ambr | 499 ++++++++++++++++++ tests/devices/traits/b01/q10/test_status.py | 48 +- 6 files changed, 578 insertions(+), 3 deletions(-) create mode 100644 tests/devices/traits/b01/q10/__snapshots__/test_status.ambr diff --git a/roborock/data/containers.py b/roborock/data/containers.py index b0735852..4926b1c5 100644 --- a/roborock/data/containers.py +++ b/roborock/data/containers.py @@ -162,13 +162,14 @@ def convert_dict(types_map: dict[Any, type], data: dict[Any, Any]) -> dict[Any, return result - def as_dict(self) -> dict: + def as_dict(self, exclude: set[str] | None = None) -> dict: + exclude_set = exclude or set() return asdict( self, dict_factory=lambda _fields: { _camelize(key): value.value if isinstance(value, Enum) else value for (key, value) in _fields - if value is not None + if value is not None and key not in exclude_set }, ) diff --git a/roborock/devices/device.py b/roborock/devices/device.py index f6226f07..291e4d2e 100644 --- a/roborock/devices/device.py +++ b/roborock/devices/device.py @@ -243,6 +243,8 @@ def diagnostic_data(self) -> dict[str, Any]: extra: dict[str, Any] = {} if self.v1_properties: extra["traits"] = self.v1_properties.as_dict() + elif self.b01_q10_properties: + extra["traits"] = self.b01_q10_properties.as_dict() return redact_device_data( { "device": self.device_info.as_dict(), diff --git a/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index 3c8c73ff..c2d16258 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -2,8 +2,10 @@ import asyncio import logging +from typing import Any from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.containers import RoborockBase from roborock.devices.rpc.b01_q10_channel import B01Q10Channel from roborock.devices.traits import Trait from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket @@ -160,6 +162,16 @@ def _handle_message(self, message: Q10Message) -> None: for trait in self._updatable_traits: trait.update_from_dps(message.dps) + def as_dict(self) -> dict[str, Any]: + """Return the trait data as a dictionary.""" + result: dict[str, Any] = {} + for name, value in self.__dict__.items(): + if isinstance(value, RoborockBase): + result[name] = value.as_dict() + if hasattr(self, "map") and hasattr(self.map, "as_dict"): + result["map"] = self.map.as_dict() + return result + def create(channel: B01Q10Channel) -> Q10PropertiesApi: """Create traits for B01 devices.""" diff --git a/roborock/devices/traits/b01/q10/map.py b/roborock/devices/traits/b01/q10/map.py index ee51352a..cdd3f619 100644 --- a/roborock/devices/traits/b01/q10/map.py +++ b/roborock/devices/traits/b01/q10/map.py @@ -149,3 +149,18 @@ def _render(self) -> None: except RoborockException as ex: _LOGGER.debug("Failed to render Q10 map packet: %s", ex) self._image_content = None + + def as_dict(self, exclude: set[str] | None = None) -> dict[str, Any]: + """Return the trait data as a dictionary, excluding large binary data.""" + import dataclasses + + exclude_set = exclude or set() + data = { + "rooms": [dataclasses.asdict(room) for room in self.rooms], + "path": [dataclasses.asdict(point) for point in self.path], + "robotPosition": dataclasses.asdict(self.robot_position) if self.robot_position is not None else None, + "robotHeading": self.robot_heading, + } + for key in exclude_set: + data.pop(key, None) + return data diff --git a/tests/devices/traits/b01/q10/__snapshots__/test_status.ambr b/tests/devices/traits/b01/q10/__snapshots__/test_status.ambr new file mode 100644 index 00000000..b154c985 --- /dev/null +++ b/tests/devices/traits/b01/q10/__snapshots__/test_status.ambr @@ -0,0 +1,499 @@ +# serializer version: 1 +# name: test_q10_properties_as_dict_and_diagnostic_data + dict({ + '_map_dps': dict({ + }), + 'child_lock': dict({ + 'childLock': False, + }), + 'consumable': dict({ + 'filterLife': 0, + 'mainBrushLife': 0, + 'sensorLife': 0, + 'sideBrushLife': 0, + }), + 'do_not_disturb': dict({ + 'notDisturb': True, + 'notDisturbExpand': dict({ + 'disturbDustEnable': 1, + 'disturbLight': 1, + 'disturbResumeClean': 1, + 'disturbVoice': 1, + }), + }), + 'dust_collection': dict({ + 'dustSetting': 'regular', + 'dustSwitch': True, + }), + 'map': dict({ + 'path': list([ + ]), + 'robotHeading': None, + 'robotPosition': None, + 'rooms': list([ + ]), + }), + 'network_info': dict({ + 'netInfo': dict({ + 'ipAdress': '1.1.1.2', + 'mac': '99:AA:88:BB:77:CC', + 'signal': -50, + 'wifiName': 'wifi-network-name', + }), + }), + 'status': dict({ + 'addCleanState': False, + 'areaUnit': 'square_meter', + 'autoBoost': False, + 'backType': 'backcharging', + 'battery': 100, + 'breakpointClean': 0, + 'carpetCleanType': 'rise', + 'cleanArea': 0, + 'cleanCount': 1, + 'cleanLine': 'fast', + 'cleanMode': 'vac_and_mop', + 'cleanTaskType': 'idle', + 'cleanTime': 0, + 'cleaningProgress': 100, + 'fanLevel': 'balanced', + 'fault': 'none', + 'filterLife': 0, + 'groundClean': False, + 'lineLaserObstacleAvoidance': True, + 'mainBrushLife': 0, + 'mapSaveSwitch': True, + 'mopState': True, + 'multiMapSwitch': True, + 'recentCleanRecord': False, + 'robotCountryCode': 'us', + 'robotType': 1, + 'sensorLife': 0, + 'sideBrushLife': 0, + 'status': 'charging', + 'timeZone': dict({ + 'timeZoneCity': 'America/Los_Angeles', + 'timeZoneSec': -28800, + }), + 'timerType': 1, + 'totalCleanArea': 0, + 'totalCleanCount': 0, + 'totalCleanTime': 0, + 'userPlan': 0, + 'valleyPointCharging': False, + 'waterLevel': 'low', + }), + 'volume': dict({ + 'volume': 74, + }), + }) +# --- +# name: test_q10_properties_as_dict_and_diagnostic_data.1 + dict({ + 'device': dict({ + 'activeTime': 1767044247, + 'cid': '4C', + 'createTime': 1767044139, + 'deviceStatus': dict({ + '101': dict({ + '104': 0, + '105': False, + '109': 'us', + '207': 0, + '25': 1, + '26': 74, + '29': 7, + '30': 1, + '31': 16, + '37': 1, + '40': 1, + '45': 0, + '47': 0, + '50': 0, + '51': True, + '53': True, + '6': 0, + '60': 1, + '67': 0, + '7': 0, + '76': 0, + '78': 0, + '79': dict({ + 'timeZoneCity': 'America/Los_Angeles', + 'timeZoneSec': -28800, + }), + '80': 0, + '81': dict({ + 'ipAdress': '192.168.1.52', + 'mac': '**REDACTED**', + 'signal': -42, + 'wifiName': '**REDACTED**', + }), + '83': 1, + '86': 1, + '87': 100, + '88': 0, + '90': 0, + '92': dict({ + 'disturb_dust_enable': 1, + 'disturb_light': 1, + 'disturb_resume_clean': 1, + 'disturb_voice': 1, + }), + '93': 1, + '96': 0, + }), + '121': 8, + '122': 100, + '123': 2, + '124': 1, + '125': 0, + '126': 0, + '127': 0, + '135': 0, + '136': 1, + '137': 1, + '138': 0, + '139': 5, + }), + 'duid': '******ef456', + 'f': False, + 'fv': '03.10.0', + 'iconUrl': '', + 'localKey': '**REDACTED**', + 'name': '**REDACTED**', + 'online': True, + 'productId': '**REDACTED**', + 'pv': 'B01', + 'share': False, + 'silentOtaSwitch': False, + 'sn': '**REDACTED**', + 'timeZoneId': 'America/Los_Angeles', + 'tuyaMigrated': False, + }), + 'product': dict({ + 'capability': 0, + 'category': 'robot.vacuum.cleaner', + 'id': 'product-id-q10-ss07', + 'model': 'roborock.vacuum.ss07', + 'name': '**REDACTED**', + 'schema': list([ + dict({ + 'code': 'rpc_request', + 'id': 101, + 'mode': 'rw', + 'name': 'RPC Request', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'rpc_response', + 'id': 102, + 'mode': 'rw', + 'name': 'RPC Response', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'error_code', + 'id': 120, + 'mode': 'ro', + 'name': '错误代码', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'state', + 'id': 121, + 'mode': 'ro', + 'name': '设备状态', + 'property': 'null', + 'type': 'VALUE', + }), + dict({ + 'code': 'battery', + 'id': 122, + 'mode': 'ro', + 'name': '设备电量', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'fan_power', + 'id': 123, + 'mode': 'rw', + 'name': '吸力档位', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'water_box_mode', + 'id': 124, + 'mode': 'rw', + 'name': '拖地档位', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'main_brush_life', + 'id': 125, + 'mode': 'ro', + 'name': '主刷寿命', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'side_brush_life', + 'id': 126, + 'mode': 'ro', + 'name': '边刷寿命', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'filter_life', + 'id': 127, + 'mode': 'ro', + 'name': '滤网寿命', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'offline_status', + 'id': 135, + 'mode': 'ro', + 'name': '离线原因', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'clean_times', + 'id': 136, + 'mode': 'rw', + 'name': '清洁次数', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'cleaning_preference', + 'id': 137, + 'mode': 'rw', + 'name': '扫拖模式', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'clean_task_type', + 'id': 138, + 'mode': 'ro', + 'name': '清洁任务类型', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'back_type', + 'id': 139, + 'mode': 'ro', + 'name': '返回基站类型', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'dock_task_type', + 'id': 140, + 'mode': 'ro', + 'name': '基站任务类型', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'cleaning_progress', + 'id': 141, + 'mode': 'ro', + 'name': '清洁进度', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'fc_state', + 'id': 142, + 'mode': 'ro', + 'name': '窜货信息', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'start_clean_task', + 'id': 201, + 'mode': 'wo', + 'name': '启动清洁任务', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'start_back_dock_task', + 'id': 202, + 'mode': 'wo', + 'name': '返回基站任务', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'start_dock_task', + 'id': 203, + 'mode': 'wo', + 'name': '启动基站任务', + 'property': '{"range": []}', + 'type': 'ENUM', + }), + dict({ + 'code': 'pause', + 'id': 204, + 'mode': 'wo', + 'name': '暂停任务', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'resume', + 'id': 205, + 'mode': 'wo', + 'name': '继续任务', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'stop', + 'id': 206, + 'mode': 'wo', + 'name': '结束任务', + 'property': 'null', + 'type': 'RAW', + }), + dict({ + 'code': 'ceip', + 'id': 207, + 'mode': 'rw', + 'name': '用户改善计划', + 'property': '{"range": ["0,1"]}', + 'type': 'ENUM', + }), + ]), + }), + 'traits': dict({ + '_map_dps': dict({ + }), + 'child_lock': dict({ + 'childLock': False, + }), + 'consumable': dict({ + 'filterLife': 0, + 'mainBrushLife': 0, + 'sensorLife': 0, + 'sideBrushLife': 0, + }), + 'do_not_disturb': dict({ + 'notDisturb': True, + 'notDisturbExpand': dict({ + 'disturbDustEnable': 1, + 'disturbLight': 1, + 'disturbResumeClean': 1, + 'disturbVoice': 1, + }), + }), + 'dust_collection': dict({ + 'dustSetting': 'regular', + 'dustSwitch': True, + }), + 'map': dict({ + 'path': list([ + ]), + 'robotHeading': None, + 'robotPosition': None, + 'rooms': list([ + ]), + }), + 'network_info': dict({ + 'netInfo': dict({ + 'ipAdress': '1.1.1.2', + 'mac': '**REDACTED**', + 'signal': -50, + 'wifiName': '**REDACTED**', + }), + }), + 'status': dict({ + 'addCleanState': False, + 'areaUnit': 'square_meter', + 'autoBoost': False, + 'backType': 'backcharging', + 'battery': 100, + 'breakpointClean': 0, + 'carpetCleanType': 'rise', + 'cleanArea': 0, + 'cleanCount': 1, + 'cleanLine': 'fast', + 'cleanMode': 'vac_and_mop', + 'cleanTaskType': 'idle', + 'cleanTime': 0, + 'cleaningProgress': 100, + 'fanLevel': 'balanced', + 'fault': 'none', + 'filterLife': 0, + 'groundClean': False, + 'lineLaserObstacleAvoidance': True, + 'mainBrushLife': 0, + 'mapSaveSwitch': True, + 'mopState': True, + 'multiMapSwitch': True, + 'recentCleanRecord': False, + 'robotCountryCode': 'us', + 'robotType': 1, + 'sensorLife': 0, + 'sideBrushLife': 0, + 'status': 'charging', + 'timeZone': dict({ + 'timeZoneCity': 'America/Los_Angeles', + 'timeZoneSec': -28800, + }), + 'timerType': 1, + 'totalCleanArea': 0, + 'totalCleanCount': 0, + 'totalCleanTime': 0, + 'userPlan': 0, + 'valleyPointCharging': False, + 'waterLevel': 'low', + }), + 'volume': dict({ + 'volume': 74, + }), + }), + }) +# --- +# name: test_q10_properties_as_dict_empty + dict({ + '_map_dps': dict({ + }), + 'child_lock': dict({ + }), + 'consumable': dict({ + }), + 'do_not_disturb': dict({ + }), + 'dust_collection': dict({ + }), + 'map': dict({ + 'path': list([ + ]), + 'robotHeading': None, + 'robotPosition': None, + 'rooms': list([ + ]), + }), + 'network_info': dict({ + }), + 'status': dict({ + }), + 'volume': dict({ + }), + }) +# --- diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index edd4bcb1..1cf19884 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -4,10 +4,11 @@ import pathlib from collections.abc import AsyncGenerator from typing import Any -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock import pytest +from roborock.data import HomeDataDevice, HomeDataProduct from roborock.data.b01_q10.b01_q10_code_mappings import ( B01_Q10_DP, YXAreaUnit, @@ -22,9 +23,11 @@ YXWaterLevel, ) from roborock.data.b01_q10.b01_q10_containers import dpNetInfo, dpNotDisturbExpand, dpTimeZone +from roborock.devices.device import RoborockDevice from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create from roborock.protocols.b01_q10_protocol import Q10Message, decode_message from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from tests import mock_data from .conftest import FakeB01Q10Channel @@ -238,3 +241,46 @@ def test_status_trait_update_listener_ignores_value(q10_api: Q10PropertiesApi) - assert not event.is_set() unsubscribe() + + +async def test_q10_properties_as_dict_empty( + q10_api: Q10PropertiesApi, + snapshot: Any, +) -> None: + """Test Q10PropertiesApi.as_dict() when traits are empty.""" + assert q10_api.as_dict() == snapshot + + +async def test_q10_properties_as_dict_and_diagnostic_data( + q10_api: Q10PropertiesApi, + message_queue: asyncio.Queue[Q10Message], + snapshot: Any, +) -> None: + """Test Q10PropertiesApi.as_dict() and diagnostic_data().""" + # Mock the response to refresh + message = build_q10_message(TESTDATA_DP_REQUEST_DPS) + message_queue.put_nowait(message) + + # Wait for the update (e.g. wait for battery to be 100) + await wait_for_attribute_value(q10_api.status, "battery", 100) + + # Now as_dict() should return traits data + data = q10_api.as_dict() + assert data == snapshot + assert "map" in data + assert "imageContent" not in data["map"] + assert "mapData" not in data["map"] + + # Instantiate RoborockDevice with this Q10 trait using mock fixtures + device_info = HomeDataDevice.from_dict(mock_data.Q10_DEVICE_DATA) + product = HomeDataProduct.from_dict(mock_data.SS07_PRODUCT_DATA) + device = RoborockDevice( + device_info=device_info, + product=product, + channel=AsyncMock(), + trait=q10_api, + ) + + # Verify diagnostic_data extracts the properties correctly + diagnostics = device.diagnostic_data() + assert diagnostics == snapshot