diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index 78fc1b2c..203df3f2 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -289,6 +289,70 @@ class YXCarpetCleanType(RoborockModeEnum): CROSS = "cross", 3 +class YXFault(RoborockModeEnum): + """Q10 (B01/ss07) ``dpFault`` (90) codes, from the ss07 fault spec. + + ``dpFault`` is *overloaded*: several values are lifecycle/status rather than + errors (e.g. 400 = scheduled clean starting, 501 = returning to dock, + 502 = recharge). A non-zero fault is not necessarily a blocking error. + The converse also holds: the device can sit in its error state (``dpStatus`` + 12) with ``dpFault`` still 0 (observed live with the dust-bin module + removed), so a ``None``/``NONE`` fault does not imply the absence of an + error condition. + + These labels differ from the Q7 ``B01Fault`` for several shared numbers + (500, 501, 503, 569, 570) -- so this is a Q10-specific map, not a reuse of + ``B01Fault``. Codes marked "hw-confirmed" were observed firing live on a + physical ss07 in a context matching the label; the rest are from the spec + only, not yet observed live (single device). + """ + + UNKNOWN = "unknown", -1 + NONE = "none", 0 + LIDAR_BLOCKED = "lidar_blocked", 1 + BUMPER_STUCK = "bumper_stuck", 2 # hw-confirmed (bumper held in during commanded motion) + ROBOT_SUSPENDED = "robot_suspended", 3 # hw-confirmed (wheels lifted off the floor mid-task) + CLIFF_SENSOR_ERROR = "cliff_sensor_error", 4 # hw-confirmed (one side lifted mid-task) + MAIN_BRUSH_STUCK = "main_brush_stuck", 5 # hw-confirmed (two physical brush jams) + MAIN_WHEELS_STUCK = "main_wheels_stuck", 7 + ROBOT_TRAPPED = "robot_trapped", 8 # hw-confirmed + CHECK_DUSTBIN_FILTER = "check_dustbin_filter", 9 + LOW_BATTERY = "low_battery", 12 # hw-confirmed (fired at 14% mid clean) + TEMPERATURE_THRESHOLD = "temperature_threshold", 14 + ROBOT_TILTED = "robot_tilted", 16 + LIDAR_COVER_OBSTRUCTED = "lidar_cover_obstructed", 21 + NO_GO_ZONE_DETECTED = "no_go_zone_detected", 24 + MOPPING_MODULE_STUCK = "mopping_module_stuck", 27 + CARPET_AVOIDANCE = "carpet_avoidance", 28 + CANNOT_CROSS_CARPET = "cannot_cross_carpet", 29 + INSTALL_DUST_BAG = "install_dust_bag", 46 # hw-confirmed (docked with bag removed; fires at dock contact) + MOP_MOUNT_FELL_OFF = "mop_mount_fell_off", 54 + LIDAR_DIRTY = "lidar_dirty", 58 + FILTER_SERVICE_LIFE = "filter_service_life", 301 + MAIN_BRUSH_SERVICE_LIFE = "main_brush_service_life", 302 + SIDE_BRUSH_SERVICE_LIFE = "side_brush_service_life", 303 + SENSOR_NEEDS_CLEANING = "sensor_needs_cleaning", 304 # hw-confirmed (fired during auto-empty) + DUST_BAG_FULL = "dust_bag_full", 310 # inferred from auto-empty context; not hw-confirmed + STARTING_SCHEDULED_CLEAN = "starting_scheduled_clean", 400 # hw-confirmed x3; lifecycle, not an error + # hw-confirmed (a due scheduled clean fired mid-clean and was ignored); lifecycle, not an error + CLEANING_IN_PROGRESS = "cleaning_in_progress", 407 + EMPTY_DUSTBIN = "empty_dustbin", 500 # ss07 != Q7 B01Fault (lidar_blocked); spec-only + # hw-confirmed, fires per completed task; ss07 != Q7 (robot_suspended) + CLEANING_COMPLETED_RETURNING = "cleaning_completed_returning", 501 + LOW_BATTERY_RESUME = "low_battery_resume", 502 # hw-confirmed; lifecycle + DOCKING_ERROR = "docking_error", 503 # hw-confirmed; ss07 != Q7 (dustbin_not_installed) + POSITIONING_FAILED = "positioning_failed", 556 # hw-confirmed; relocalization + # hw-confirmed (3rd auto-empty in ~15 min: dock refuses to run the cycle); ss07 != Q7 (main_wheels_entangled) + TOO_FREQUENT_EMPTYING = "too_frequent_emptying", 569 + CANNOT_REACH_TARGET = "cannot_reach_target", 570 # hw-confirmed; ss07 != Q7 (main_brush_entangled) + OFFLINE_WARNING_ASLEEP = "offline_warning_asleep", 588 + OFFLINE_WARNING_LOW_BATTERY = "offline_warning_low_battery", 589 + DND_AUTO_TOPUP_DISABLED = "dnd_auto_topup_disabled", 591 + CLEAN_CARPET_ULTRASONIC_SENSORS = "clean_carpet_ultrasonic_sensors", 707 + ROBOT_ERROR_RESET = "robot_error_reset", 1002 + VOICE_PACK_UPDATE_AVAILABLE = "voice_pack_update_available", 3001 + + 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 7b0351a6..2223cc86 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -23,6 +23,7 @@ YXDeviceDustCollectionFrequency, YXDeviceState, YXFanLevel, + YXFault, YXStartMethod, YXWaterLevel, ) @@ -165,7 +166,7 @@ class Q10Status(RoborockBase): 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}) + fault: YXFault | None = field(default=None, metadata={"dps": B01_Q10_DP.FAULT}) # Additional state reported in the device's full status dump. clean_line: YXCleanLine | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_LINE}) @@ -203,6 +204,11 @@ class Q10Status(RoborockBase): 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}) + @property + def fault_name(self) -> str | None: + """Returns the name of the current fault.""" + return self.fault.value if self.fault is not None else None + @dataclass class SoundVolume(RoborockBase): diff --git a/tests/data/b01_q10/test_b01_q10_code_mappings.py b/tests/data/b01_q10/test_b01_q10_code_mappings.py index 63fdd14d..06789a23 100644 --- a/tests/data/b01_q10/test_b01_q10_code_mappings.py +++ b/tests/data/b01_q10/test_b01_q10_code_mappings.py @@ -2,7 +2,7 @@ import pytest -from roborock.data.b01_q10 import YXDeviceState +from roborock.data.b01_q10 import Q10Status, YXDeviceState, YXFault @pytest.mark.parametrize( @@ -33,3 +33,40 @@ def test_q10_status_values_are_canonical(state: YXDeviceState, string: str) -> N def test_q10_status_codes_map_to_canonical_values(code: int, expected_state: YXDeviceState) -> None: """Code-based mapping should return canonical status values.""" assert YXDeviceState.from_code(code) is expected_state + + +@pytest.mark.parametrize( + "code, expected", + [ + (0, YXFault.NONE), + (503, YXFault.DOCKING_ERROR), # ss07 docking error, not the Q7 "dustbin_not_installed" + (570, YXFault.CANNOT_REACH_TARGET), # ss07 cannot-reach, not the Q7 "main_brush_entangled" + (556, YXFault.POSITIONING_FAILED), + ], +) +def test_q10_fault_codes_map_to_ss07_labels(code: int, expected: YXFault) -> None: + """dpFault codes should decode to the ss07-correct labels.""" + assert YXFault.from_code(code) is expected + + +def test_q10_status_fault_field_and_name() -> None: + """``fault`` is a typed ``YXFault`` field; ``fault_name`` mirrors the Q7 accessor.""" + status = Q10Status(fault=YXFault.CANNOT_REACH_TARGET) + assert status.fault is YXFault.CANNOT_REACH_TARGET + assert status.fault_name == "cannot_reach_target" + + assert Q10Status(fault=None).fault_name is None + + +def test_q10_status_fault_decodes_from_dict() -> None: + """A raw dpFault int decodes to the enum through the standard conversion path.""" + status = Q10Status.from_dict({"fault": 503}) + assert status is not None + assert status.fault is YXFault.DOCKING_ERROR + + # An unmapped code follows the library-wide enum-field behavior: + # the failed conversion is logged and the field is left None. + unmapped = Q10Status.from_dict({"fault": 99999}) + assert unmapped is not None + assert unmapped.fault is None + assert unmapped.fault_name is None diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index 8105142a..edd4bcb1 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -18,6 +18,7 @@ YXDeviceDustCollectionFrequency, YXDeviceState, YXFanLevel, + YXFault, YXWaterLevel, ) from roborock.data.b01_q10.b01_q10_containers import dpNetInfo, dpNotDisturbExpand, dpTimeZone @@ -150,7 +151,7 @@ async def test_status_trait_refresh( 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.fault is YXFault.NONE assert q10_api.status.clean_mode == YXCleanType.VAC_AND_MOP assert q10_api.status.water_level == YXWaterLevel.LOW