From 397c7c10adba84c8a8d724fba3dc0aee371b12db Mon Sep 17 00:00:00 2001 From: eggleader Date: Thu, 15 Oct 2026 11:55:56 +0800 Subject: [PATCH] feat: register P30 Pro and decode water and dock status Register the confirmed roborock.vacuum.a306 model and dock type 49. Decode smart-water status code 210 through the existing slider helper. Reuse device and product fixtures for status tests and capability snapshots. AI disclosure: These code and test changes were generated and edited by OpenAI Codex using user-provided device data and app observations. Validation: Pre-commit checks passed. The default test suite passed with 1473 tests and 99 snapshots; 11 real-broker MQTT tests were excluded. --- roborock/const.py | 2 + roborock/data/v1/v1_clean_modes.py | 3 + roborock/data/v1/v1_code_mappings.py | 1 + .../test_supported_features.ambr | 179 ++++++++++++++++++ .../__snapshots__/test_device_features.ambr | 27 +++ tests/devices/traits/v1/test_status.py | 33 +++- tests/mock_data.py | 2 + tests/test_supported_features.py | 4 +- tests/testdata/home_data_device_p30_pro.json | 17 ++ tests/testdata/home_data_product_a306.json | 173 +++++++++++++++++ 10 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 tests/testdata/home_data_device_p30_pro.json create mode 100644 tests/testdata/home_data_product_a306.json diff --git a/roborock/const.py b/roborock/const.py index 643d5570b..a5f72e963 100644 --- a/roborock/const.py +++ b/roborock/const.py @@ -47,6 +47,7 @@ ROBOROCK_S8_PRO_ULTRA = "roborock.vacuum.a70" ROBOROCK_S8 = "roborock.vacuum.a51" ROBOROCK_P10 = "roborock.vacuum.a75" # also known as q_revo +ROBOROCK_P30_PRO = "roborock.vacuum.a306" ROBOROCK_S8_MAXV_ULTRA = "roborock.vacuum.a97" ROBOROCK_QREVO_S = "roborock.vacuum.a104" ROBOROCK_QREVO_PRO = "roborock.vacuum.a101" @@ -77,6 +78,7 @@ ROBOROCK_S4_MAX, ROBOROCK_S7, ROBOROCK_P10, + ROBOROCK_P30_PRO, ROBOROCK_Q10, ROCKROBO_G10_SG, ] diff --git a/roborock/data/v1/v1_clean_modes.py b/roborock/data/v1/v1_clean_modes.py index f4b12c08e..5d4db5050 100644 --- a/roborock/data/v1/v1_clean_modes.py +++ b/roborock/data/v1/v1_clean_modes.py @@ -55,6 +55,7 @@ class WaterModes(RoborockModeEnum): CUSTOM = ("custom_water_flow", 207) EXTREME = ("extreme", 208) SMART_MODE = ("smart_mode", 209) + SMART_WATER = ("smart_water", 210) PURE_WATER_FLOW_START = ("slight", 221) PURE_WATER_FLOW_SMALL = ("low", 225) PURE_WATER_FLOW_GENTLE = ("gentle", 230) @@ -201,6 +202,8 @@ def get_water_slide_mode_name(code: int) -> str | None: """Get the app's range name for a water slide code, or None if out of range.""" if code == 200: return WaterModes.OFF.value + if code == WaterModes.SMART_WATER.code: + return WaterModes.SMART_WATER.value position = code - 220 if position < 1: return None diff --git a/roborock/data/v1/v1_code_mappings.py b/roborock/data/v1/v1_code_mappings.py index ce2d384ee..c5d5037f7 100644 --- a/roborock/data/v1/v1_code_mappings.py +++ b/roborock/data/v1/v1_code_mappings.py @@ -386,6 +386,7 @@ class RoborockDockTypeCode(RoborockEnum): f1s_dock = 44 o7h_dock = 45 f1c_dock = 46 + type_49_dock = 49 class RoborockDockDustCollectionModeCode(RoborockEnum): diff --git a/tests/__snapshots__/test_supported_features.ambr b/tests/__snapshots__/test_supported_features.ambr index 95450787b..dfd9f8ac4 100644 --- a/tests/__snapshots__/test_supported_features.ambr +++ b/tests/__snapshots__/test_supported_features.ambr @@ -1,4 +1,183 @@ # serializer version: 1 +# name: test_device_features_from_home_data[home_data_device_p30_pro.json] + dict({ + 'is_activate_video_charging_and_standby_supported': True, + 'is_ai_recognition_obstacle_supported': True, + 'is_ai_recognition_setting_supported': True, + 'is_analysis_supported': False, + 'is_any_state_transit_goto_supported': True, + 'is_auto_collection_2_supported': True, + 'is_auto_delivery_field_in_global_status_supported': True, + 'is_auto_tear_down_mop_supported': False, + 'is_avoid_collision_mode_supported': True, + 'is_avoid_collision_supported': True, + 'is_back_charge_auto_wash_supported': True, + 'is_back_wash_new_smart_supported': True, + 'is_careful_slow_mop_supported': True, + 'is_carpet_custom_clean_supported': True, + 'is_carpet_deep_clean_supported': True, + 'is_carpet_long_haired_ex_supported': False, + 'is_carpet_long_haired_supported': False, + 'is_carpet_pressure_use_origin_paras_supported': False, + 'is_carpet_shape_type_supported': True, + 'is_carpet_show_on_map': True, + 'is_carpet_supported': True, + 'is_ces_2022_supported': False, + 'is_clean_count_setting_supported': True, + 'is_clean_direct_status_supported': True, + 'is_clean_efficiency_supported': True, + 'is_clean_fluid_delivery_supported': True, + 'is_clean_history_time_line_supported': True, + 'is_clean_route_deep_slow_plus_supported': False, + 'is_clean_route_fast_mode_supported': True, + 'is_clean_route_setting_supported': True, + 'is_clean_then_mop_mode_supported': True, + 'is_clean_time_line_supported': True, + 'is_collect_dust_count_show_supported': True, + 'is_collect_dust_mode_supported': True, + 'is_corner_clean_mode_supported': False, + 'is_corner_mop_stretch_supported': True, + 'is_ctm_with_repeat_supported': True, + 'is_current_map_restore_enabled': True, + 'is_custom_clean_mode_count_supported': True, + 'is_custom_mode_supported': True, + 'is_custom_water_box_distance_supported': True, + 'is_customized_clean_supported': True, + 'is_detect_wire_carpet_supported': True, + 'is_dirty_object_detect_supported': True, + 'is_dirty_replenish_clean_supported': True, + 'is_dry_interval_timer_supported': True, + 'is_dss_believable': True, + 'is_dual_band_wi_fi_supported': True, + 'is_dust_collection_setting_supported': True, + 'is_dynamically_add_clean_zones_supported': True, + 'is_dynamically_skip_clean_zone_supported': True, + 'is_egg_dance_mode_supported': True, + 'is_egg_mode_supported_from_new_features': False, + 'is_exact_custom_mode_supported': True, + 'is_exhibition_function_supported': False, + 'is_floor_dir_clean_any_time_supported': True, + 'is_flow_led_setting_supported': False, + 'is_follow_low_obs_supported': False, + 'is_full_duples_switch_supported': True, + 'is_fw_filter_obstacle_supported': True, + 'is_gap_deep_clean_supported': False, + 'is_goto_pure_clean_path_supported': True, + 'is_hot_wash_towel_supported': True, + 'is_identify_room_supported': True, + 'is_ignore_unknown_map_object_supported': True, + 'is_lds_lifting_supported': True, + 'is_led_status_switch_supported': False, + 'is_left_water_drain_supported': True, + 'is_low_area_access_supported': True, + 'is_main_brush_up_down_supported_from_str': True, + 'is_map_beautify_internal_debug_supported': True, + 'is_map_carpet_add_support': True, + 'is_map_eraser_supported': True, + 'is_matter_supported': True, + 'is_max_plus_mode_supported': True, + 'is_max_zone_opened_supported': True, + 'is_mechanical_arm_mode_supported': False, + 'is_midway_back_to_dock_supported': True, + 'is_min_battery_15_to_clean_task_supported': True, + 'is_mop_forbidden_supported': False, + 'is_mop_path_supported': True, + 'is_mop_shake_module_supported': False, + 'is_mop_shake_water_max_supported': True, + 'is_multi_floor_supported': False, + 'is_multi_map_segment_timer_supported': True, + 'is_new_ai_recognition_supported': True, + 'is_new_data_for_clean_history': True, + 'is_new_data_for_clean_history_detail': True, + 'is_new_endpoint_supported': True, + 'is_new_remote_view_supported': True, + 'is_no_need_carpet_press_set_supported': True, + 'is_none_pure_clean_mop_with_max_plus': False, + 'is_object_detect_check_supported': True, + 'is_offline_map_supported': True, + 'is_optimize_battery_supported': False, + 'is_order_clean_supported': False, + 'is_over_sea_ctm_supported': True, + 'is_pet_snapshot_supported': True, + 'is_pet_supplies_deep_clean_supported': True, + 'is_program_mode_supported': False, + 'is_pumping_water_supported': False, + 'is_pure_clean_mop_supported': True, + 'is_re_segment_supported': True, + 'is_record_allowed': True, + 'is_remote_supported': False, + 'is_right_brush_stretch_supported': False, + 'is_roller_mop_supported': True, + 'is_room_name_supported': True, + 'is_rpc_retry_supported': True, + 'is_rubber_brush_carpet_supported': False, + 'is_set_child_supported': True, + 'is_setting_carpet_first_supported': True, + 'is_shake_mop_set_supported': True, + 'is_should_show_arm_over_load_supported': True, + 'is_show_clean_finish_reason_supported': True, + 'is_show_general_obstacle_supported': False, + 'is_show_obstacle_photo_supported': False, + 'is_side_brush_lift_carpet_supported': True, + 'is_small_side_mop_supported': False, + 'is_smart_clean_mode_set_supported': True, + 'is_soak_and_wash_supported': True, + 'is_soft_clean_mode_supported': False, + 'is_sr_map_supported': False, + 'is_super_deep_wash_supported': True, + 'is_support_api_app_stop_grasp_supported': True, + 'is_support_backup_map': True, + 'is_support_clean_estimate': True, + 'is_support_cliff_zone': True, + 'is_support_custom_carpet': False, + 'is_support_custom_dnd': True, + 'is_support_custom_door_sill': True, + 'is_support_custom_mode_in_cleaning': True, + 'is_support_fetch_timer_summary': False, + 'is_support_floor_direction': True, + 'is_support_floor_edit': True, + 'is_support_furniture': True, + 'is_support_get_particular_status_supported': True, + 'is_support_incremental_map': True, + 'is_support_main_brush_up_down_supported': True, + 'is_support_mop_back_pwm_set': False, + 'is_support_quick_map_builder': True, + 'is_support_remote_control_in_call': True, + 'is_support_room_tag': True, + 'is_support_set_switch_map_mode': True, + 'is_support_set_volume_in_call': True, + 'is_support_side_brush_up_down_supported': False, + 'is_support_smart_door_sill': True, + 'is_support_smart_global_clean_with_custom_mode': True, + 'is_support_smart_scene': True, + 'is_support_stuck_zone': True, + 'is_support_voice_control_debug': False, + 'is_support_water_mode': True, + 'is_supported_download_test_voice': True, + 'is_supported_drying': True, + 'is_supported_valley_electricity': True, + 'is_sync_server_name_supported': True, + 'is_three_d_mapping_inner_test_supported': False, + 'is_tidyup_zones_supported': False, + 'is_two_gears_no_collision_supported': True, + 'is_two_key_real_time_video_supported': True, + 'is_two_key_rtv_in_charging_supported': True, + 'is_type_identify_supported': False, + 'is_unsave_map_reason_supported': True, + 'is_uvc_sterilize_supported': False, + 'is_video_monitor_supported': True, + 'is_video_patrol_supported': True, + 'is_video_setting_supported': True, + 'is_voice_control_led_supported': False, + 'is_voice_control_supported': True, + 'is_wash_then_charge_cmd_supported': True, + 'is_water_leak_check_supported': True, + 'is_water_slide_mode_supported': True, + 'is_water_up_down_drain_supported': True, + 'is_wifi_manage_supported': True, + 'is_workday_holiday_supported': True, + }) +# --- # name: test_device_features_from_home_data[home_data_device_pearls.json] dict({ 'is_activate_video_charging_and_standby_supported': False, diff --git a/tests/devices/traits/v1/__snapshots__/test_device_features.ambr b/tests/devices/traits/v1/__snapshots__/test_device_features.ambr index 5a8ffbac6..a6eddf961 100644 --- a/tests/devices/traits/v1/__snapshots__/test_device_features.ambr +++ b/tests/devices/traits/v1/__snapshots__/test_device_features.ambr @@ -1,4 +1,24 @@ # serializer version: 1 +# name: test_is_attribute_supported[home_data_device_p30_pro.json] + dict({ + 'battery': True, + 'charge_status': True, + 'clean_fluid_status': False, + 'clean_percent': True, + 'clear_water_box_status': True, + 'dirty_water_box_status': True, + 'dock_error_status': True, + 'dry_status': True, + 'error_code': True, + 'fan_power': True, + 'rdt': True, + 'state': True, + 'water_box_carriage_status': True, + 'water_box_mode': True, + 'water_box_status': True, + 'water_shortage_status': True, + }) +# --- # name: test_is_attribute_supported[home_data_device_pearls.json] dict({ 'battery': True, @@ -139,6 +159,13 @@ 'water_shortage_status': True, }) # --- +# name: test_is_consumable_field_supported[home_data_device_p30_pro.json] + dict({ + 'filter_work_time': True, + 'main_brush_work_time': True, + 'side_brush_work_time': True, + }) +# --- # name: test_is_consumable_field_supported[home_data_device_pearls.json] dict({ 'filter_work_time': True, diff --git a/tests/devices/traits/v1/test_status.py b/tests/devices/traits/v1/test_status.py index 5749ae313..a0ebae319 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -16,11 +16,12 @@ get_current_cleaning_mode, resolve_cleaning_mode, ) -from roborock.data import SHORT_MODEL_TO_ENUM, RoborockProductNickname +from roborock.data import SHORT_MODEL_TO_ENUM, HomeDataDevice, RoborockProductNickname from roborock.data.v1 import ( RoborockChargeStatus, RoborockDockErrorCode, RoborockDockState, + RoborockDockTypeCode, RoborockErrorCode, RoborockStateCode, ) @@ -87,6 +88,35 @@ async def test_refresh_status_dict_response(status_trait: StatusTrait, mock_rpc_ mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_STATUS) +@pytest.mark.parametrize("device_info", [HomeDataDevice.from_dict(mock_data.P30_PRO_DEVICE_DATA)]) +@pytest.mark.parametrize( + ("water_box_mode", "expected_name"), + [(210, "smart_water"), (230, "gentle")], +) +async def test_refresh_status_p30_pro( + device_info: HomeDataDevice, + discover_features_fixture: None, + status_trait: StatusTrait, + mock_rpc_channel: AsyncMock, + water_box_mode: int, + expected_name: str, +) -> None: + """Test refreshing P30 Pro status with smart water or a manual slider value.""" + mock_rpc_channel.send_command.return_value = [ + {**STATUS, "fan_power": 103, "water_box_mode": water_box_mode, "mop_mode": 300, "dock_type": 49} + ] + + await status_trait.refresh() + + assert status_trait.fan_speed_name == "turbo" + assert status_trait.water_box_mode == water_box_mode + assert status_trait.water_mode_name == expected_name + assert status_trait.mop_route_name == "standard" + assert status_trait.current_cleaning_mode == CleaningMode.VAC_AND_MOP + assert status_trait.dock_type == RoborockDockTypeCode.type_49_dock + mock_rpc_channel.send_command.assert_awaited_once_with(RoborockCommand.GET_STATUS) + + async def test_refresh_status_propagates_exception(status_trait: StatusTrait, mock_rpc_channel: AsyncMock) -> None: """Test that exceptions from RPC channel are propagated.""" mock_rpc_channel.send_command.side_effect = RoborockException("Communication error") @@ -651,6 +681,7 @@ def test_water_slide_mode_mapping() -> None: ("water_box_mode", "expected_name"), [ (200, "off"), + (210, "smart_water"), (221, "slight"), (223, "slight"), (224, "low"), diff --git a/tests/mock_data.py b/tests/mock_data.py index 871e9c92b..353d8b7fe 100644 --- a/tests/mock_data.py +++ b/tests/mock_data.py @@ -147,6 +147,7 @@ Q10_DEVICE_DATA = DEVICES["home_data_device_q10.json"] ZEO_ONE_DEVICE_DATA = DEVICES["home_data_device_zeo_one.json"] SAROS_10R_DEVICE_DATA = DEVICES["home_data_device_saros_10r.json"] +P30_PRO_DEVICE_DATA = DEVICES["home_data_device_p30_pro.json"] # All testdata devices joined with their matching product (keyed by device filename). # Devices whose productId has no corresponding product file are omitted. @@ -176,6 +177,7 @@ "product-id-a123": RoborockDockTypeCode.pearl_plus_dock, # PearlS Lite / Q Revo (Wash/Empty) "product-saros-10": RoborockDockTypeCode.o4_dock, # Saros 10 / Q Revo Pro (Wash/Empty/Auto Fluid) "product-saros-10r": RoborockDockTypeCode.o6_dock, # JC / Q Revo Master (Wash/Empty/Hot water) + "product-id-a306": RoborockDockTypeCode.type_49_dock, # P30 Pro (Wash/Empty/Dry) } diff --git a/tests/test_supported_features.py b/tests/test_supported_features.py index 387eeb335..b427ccc5d 100644 --- a/tests/test_supported_features.py +++ b/tests/test_supported_features.py @@ -298,6 +298,7 @@ async def test_from_dict_rejects_legacy_missing_capabilities(caplog: pytest.LogC (RoborockDockTypeCode.f1s_dock, True, True), (RoborockDockTypeCode.o7h_dock, True, True), (RoborockDockTypeCode.f1c_dock, True, True), + (RoborockDockTypeCode.type_49_dock, True, True), ], ) def test_dock_features(dock_type: RoborockDockTypeCode, is_collectable: bool, is_washable: bool) -> None: @@ -318,7 +319,8 @@ def test_all_rr_api_dock_types_are_mapped() -> None: if dock_type is not RoborockDockTypeCode.unknown } - assert mapped_docks == RR_API_DOCK_TYPES + observed_docks = {49: "type_49_dock"} # P30 Pro; not present in the older RR_API snapshot. + assert mapped_docks == RR_API_DOCK_TYPES | observed_docks def test_dock_feature_flags_from_rr_api() -> None: diff --git a/tests/testdata/home_data_device_p30_pro.json b/tests/testdata/home_data_device_p30_pro.json new file mode 100644 index 000000000..c38c74b2e --- /dev/null +++ b/tests/testdata/home_data_device_p30_pro.json @@ -0,0 +1,17 @@ +{ + "duid": "device-id-a306", + "name": "P30 Pro", + "localKey": "key123key123key1", + "productId": "product-id-a306", + "fv": "02.23.52", + "online": true, + "pv": "1.0", + "featureSet": "4499197267967999", + "newFeatureSet": "000002637C3FBF1DCEDD7FFDA8E8B6FD5BBFAF7F7EFEFFFF", + "deviceStatus": { + "121": 8, + "122": 100, + "123": 103, + "124": 210 + } +} diff --git a/tests/testdata/home_data_product_a306.json b/tests/testdata/home_data_product_a306.json new file mode 100644 index 000000000..8b9aba9be --- /dev/null +++ b/tests/testdata/home_data_product_a306.json @@ -0,0 +1,173 @@ +{ + "id": "product-id-a306", + "name": "P30 Pro", + "model": "roborock.vacuum.a306", + "category": "robot.vacuum.cleaner", + "capability": 0, + "schema": [ + { + "id": 101, + "name": "rpc_request", + "code": "rpc_request", + "mode": "rw", + "type": "RAW" + }, + { + "id": 102, + "name": "rpc_response", + "code": "rpc_response", + "mode": "rw", + "type": "RAW" + }, + { + "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": "ENUM", + "property": "{\"range\": [\"\"]}" + }, + { + "id": 122, + "name": "\u8bbe\u5907\u7535\u91cf", + "code": "battery", + "mode": "ro", + "type": "ENUM", + "property": "{\"range\": [\"\"]}" + }, + { + "id": 123, + "name": "\u6e05\u626b\u6a21\u5f0f", + "code": "fan_power", + "mode": "rw", + "type": "ENUM", + "property": "{\"range\": [\"\"]}" + }, + { + "id": 124, + "name": "\u62d6\u5730\u6a21\u5f0f", + "code": "water_box_mode", + "mode": "rw", + "type": "ENUM", + "property": "{\"range\": [\"\"]}" + }, + { + "id": 125, + "name": "\u4e3b\u5237\u5bff\u547d", + "code": "main_brush_life", + "mode": "rw", + "type": "VALUE", + "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": \"null\", \"scale\": 1}" + }, + { + "id": 126, + "name": "\u8fb9\u5237\u5bff\u547d", + "code": "side_brush_life", + "mode": "rw", + "type": "VALUE", + "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": \"null\", \"scale\": 1}" + }, + { + "id": 127, + "name": "\u6ee4\u7f51\u5bff\u547d", + "code": "filter_life", + "mode": "rw", + "type": "VALUE", + "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": \"null\", \"scale\": 1}" + }, + { + "id": 128, + "name": "\u989d\u5916\u72b6\u6001", + "code": "additional_props", + "mode": "ro", + "type": "RAW" + }, + { + "id": 130, + "name": "\u5b8c\u6210\u4e8b\u4ef6", + "code": "task_complete", + "mode": "ro", + "type": "RAW" + }, + { + "id": 131, + "name": "\u7535\u91cf\u4e0d\u8db3\u4efb\u52a1\u53d6\u6d88", + "code": "task_cancel_low_power", + "mode": "ro", + "type": "RAW" + }, + { + "id": 132, + "name": "\u8fd0\u52a8\u4e2d\u4efb\u52a1\u53d6\u6d88", + "code": "task_cancel_in_motion", + "mode": "ro", + "type": "RAW" + }, + { + "id": 133, + "name": "\u5145\u7535\u72b6\u6001", + "code": "charge_status", + "mode": "ro", + "type": "RAW" + }, + { + "id": 134, + "name": "\u70d8\u5e72\u72b6\u6001", + "code": "drying_status", + "mode": "ro", + "type": "RAW" + }, + { + "id": 135, + "name": "\u79bb\u7ebf\u539f\u56e0\u7ec6\u5206", + "code": "offline_status", + "mode": "ro", + "type": "RAW" + }, + { + "id": 138, + "name": "\u5de5\u4f5c\u4efb\u52a1\u7c7b\u578b", + "code": "clean_task_type", + "mode": "ro", + "type": "ENUM", + "property": "{\"range\": [\"0\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\"]}" + }, + { + "id": 139, + "name": "\u56de\u57fa\u7ad9\u76ee\u7684", + "code": "back_type", + "mode": "ro", + "type": "RAW" + }, + { + "id": 141, + "name": "\u6e05\u6d01\u8fdb\u5ea6", + "code": "cleaning_progress", + "mode": "ro", + "type": "VALUE", + "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": \"\u767e\u5206\u6bd4\", \"scale\": 1}" + }, + { + "id": 142, + "name": "publish_dsp", + "code": "publish_dsp", + "mode": "ro", + "type": "RAW" + }, + { + "id": 143, + "name": "\u5730\u56fe\u53d8\u5316\u6570\u636e", + "code": "map_diff", + "mode": "ro", + "type": "RAW" + } + ] +}