diff --git a/roborock/data/mower/mower_code_mappings.py b/roborock/data/mower/mower_code_mappings.py index 33a194aeb..3dc907b6c 100644 --- a/roborock/data/mower/mower_code_mappings.py +++ b/roborock/data/mower/mower_code_mappings.py @@ -1,72 +1,398 @@ """Code mappings for Roborock mower devices.""" -from roborock.data.code_mappings import RoborockEnum - - -class RoborockMowerStateCode(RoborockEnum): - """Detailed mower operating state reported by DPS 123.""" - - unknown = -999 - idle = 0 - - map_initializing = 1 - map_undocking = 2 - map_undock_fault = 3 - map_locating = 4 - map_prepare_boundary = 5 - map_prepare_island = 6 - map_prepare_path = 7 - map_boundary = 8 - map_island = 9 - map_path = 10 - map_boundary_auto = 11 - map_erasing = 12 - map_save = 13 - map_wait = 14 - map_recoverable_fault = 15 - map_fault = 16 - map_emergency_stop = 17 - map_waiting_fault = 18 - - mow_initializing = 51 - mow_undocking = 52 - mow_locating = 53 - mow_adjust_cutter = 54 - mow_zig_zag = 55 - mow_edge = 56 - mow_goto = 57 - mow_suspend = 58 - mow_recoverable_fault = 59 - mow_fault = 60 - mow_docked_rainfall = 61 - mow_docked_do_not_disturb = 62 - mow_docked_low_battery = 63 - mow_wait = 64 - mow_prepare_remote = 65 - mow_remote = 66 - mow_emergency_stop = 67 - mow_docked_manual = 68 - mow_dock_fault = 69 - mow_remote_undocking = 70 - mow_to_dock_initializing = 71 - mow_to_dock_locating = 72 - mow_to_dock_recoverable_fault = 73 - mow_to_dock_fault = 74 - mow_to_dock_emergency_stop = 75 - mow_to_dock_charging = 76 - mow_to_dock_charge_completed = 77 - - free = 101 - free_initializing = 102 - free_locating = 103 - free_docked_manual = 104 - free_docked_mow_end = 105 - free_docked_plan_end = 106 - free_emergency_stop = 107 - free_recoverable_fault = 108 - free_fault = 109 - - charge_charging = 151 - charge_completed = 152 - charge_waiting = 153 - charge_fault = 154 +from typing import Self, cast + +from roborock.data.code_mappings import RoborockModeEnum + +__all__ = [ + "MowerAfsStatus", + "MowerChargeState", + "MowerChargeType", + "MowerConfMode", + "MowerDetailState", + "MowerDockState", + "MowerEffMode", + "MowerEnum", + "MowerErrorCode", + "MowerFcState", + "MowerMappingState", + "MowerMappingType", + "MowerMowState", + "MowerMowType", + "MowerNetworkChannel", + "MowerOffDockNoTaskStatus", + "MowerOfflineStatus", + "MowerOtaState", + "MowerPattern", + "MowerPendType", + "MowerRemoteState", + "MowerStartType", +] + + +class MowerEnum(RoborockModeEnum): + """Base for mower enums.""" + + @classmethod + def from_code(cls, code: int | str) -> Self: + """Resolve a wire value by name, string value, or integer code.""" + if (member := cls.from_any_optional(code)) is not None: + return member + return cast(Self, cls.UNKNOWN) # type: ignore[attr-defined] + + +class MowerMowType(MowerEnum): + """Mowing task selection reported by DPS 122.""" + + UNKNOWN = ("unknown", -1) + NONE = ("none", 0) + GLOBAL = ("global", 1) + EDGE = ("edge", 2) + SELECTION = ("selection", 3) + ZONING = ("zoning", 4) + REMOTE = ("remote", 5) + RANDOM = ("random", 6) + + +class MowerMowState(MowerEnum): + """Compact mowing state (MowStateDpValue.Type).""" + + UNKNOWN = ("unknown", -1) + IDLE = ("idle", 0) + ZIGZAG_MOWING = ("zigzag_mowing", 1) + EDGE_MOWING = ("edge_mowing", 2) + TRANSITION = ("transition", 3) + SUSPENDED = ("suspended", 4) + REMOTE_WALKING = ("remote_walking", 5) + REMOTE_MOWING = ("remote_mowing", 6) + + +class MowerMappingType(MowerEnum): + """Mapping operation reported by DPS 124.""" + + UNKNOWN = ("unknown", -1) + NONE = ("none", 0) + LAWN_MAPPING = ("lawn_mapping", 1) + FORBIDDEN_ZONE_MAPPING = ("forbidden_zone_mapping", 2) + CHANNEL_MAPPING = ("channel_mapping", 3) + SPLIT_WORKSPACE = ("split_workspace", 4) + MERGE_WORKSPACE = ("merge_workspace", 5) + BOUNDARY_MODIFY = ("boundary_modify", 6) + + +class MowerMappingState(MowerEnum): + """Mapping activity reported by DPS 125.""" + + UNKNOWN = ("unknown", -1) + IDLE = ("idle", 0) + AUTO_MAPPING = ("auto_mapping", 1) + TRACK_ERASE = ("track_erase", 2) + SUSPENDED = ("suspended", 3) + SAVING_MAP = ("saving_map", 4) + REMOTE_WALKING = ("remote_walking", 5) + REMOTE_MAPPING = ("remote_mapping", 6) + + +class MowerOtaState(MowerEnum): + """Firmware update state reported by DPS 126.""" + + UNKNOWN = ("unknown", -1) + IDLE = ("idle", 0) + DOWNLOADING = ("downloading", 1) + INSTALLING = ("installing", 2) + REBOOTING = ("rebooting", 3) + + +class MowerChargeState(MowerEnum): + """Charging state reported by DPS 127.""" + + UNKNOWN = ("unknown", -1) + NOT_CHARGING = ("not_charging", 0) + CHARGING = ("charging", 1) + CHARGE_COMPLETED = ("charge_completed", 2) + WAITING_CHARGE = ("waiting_charge", 3) + LOW_POWER = ("low_power", 4) + CHARGE_ERROR = ("charge_error", 5) + + +class MowerDockState(MowerEnum): + """Return-to-dock state reported by DPS 128.""" + + UNKNOWN = ("unknown", -1) + IDLE = ("idle", 0) + MOVING_TO_TARGET = ("moving_to_target", 1) + DOCKING = ("docking", 2) + SUSPENDED = ("suspended", 3) + + +class MowerChargeType(MowerEnum): + """Reason for returning to charge reported by DPS 129.""" + + UNKNOWN = ("unknown", 0) + MANUAL_DOCK = ("manual_dock", 1) + RAIN_DOCK = ("rain_dock", 2) + DO_NOT_DISTURB_DOCK = ("do_not_disturb_dock", 3) + STANDBY_TIMEOUT_DOCK = ("standby_timeout_dock", 4) + LOW_BATTERY_DOCK = ("low_battery_dock", 5) + TASK_FINISHED_DOCK = ("task_finished_dock", 6) + MOW_FAULT_DOCK = ("mow_fault_dock", 7) + + +class MowerPendType(MowerEnum): + """Pause or interruption reason reported by DPS 130.""" + + UNKNOWN = ("unknown", -1) + NOT_SUSPENDED = ("not_suspended", 0) + APP_PAUSE = ("app_pause", 1) + BUTTON_EMERGENCY_STOP = ("button_emergency_stop", 2) + FAULT = ("fault", 3) + BREAKPOINT_PAUSE = ("breakpoint_pause", 4) + OTHER = ("other", 5) + + +class MowerRemoteState(MowerEnum): + """Remote control state reported by DPS 131.""" + + UNKNOWN = ("unknown", -1) + IDLE = ("idle", 0) + VIDEO_REMOTE = ("video_remote", 1) + REMOTE_WALKING = ("remote_walking", 2) + + +class MowerStartType(MowerEnum): + """Task start trigger reported by DPS 132.""" + + UNKNOWN = ("unknown", -1) + DEFAULT = ("default", 0) + APP_START = ("app_start", 1) + BUTTON_START = ("button_start", 2) + SCHEDULE_START = ("schedule_start", 3) + + +class MowerEffMode(MowerEnum): + """Reported efficiency mode on DPS 133.""" + + UNKNOWN = ("unknown", 0) + DAILY = ("daily", 1) + PRECISE = ("precise", 2) + FAST = ("fast", 3) + + +class MowerPattern(MowerEnum): + """Reported mowing pattern on DPS 136.""" + + UNKNOWN = ("unknown", 0) + ZIGZAG = ("zigzag", 1) + WELL = ("well", 2) + DIAMOND = ("diamond", 3) + TRIANGLE = ("triangle", 4) + AUTO_DEFLECT = ("auto_deflect", 5) + RANDOM = ("random", 6) + + +class MowerConfMode(MowerEnum): + """Preference configuration scope on DPS 137.""" + + UNKNOWN = ("unknown", 0) + GLOBAL_CONFIG = ("global_config", 1) + ZONE_CUSTOM = ("zone_custom", 2) + + +class MowerOfflineStatus(MowerEnum): + """Shutdown or reboot reason on DPS 138.""" + + UNKNOWN = ("unknown", -1) + ONLINE = ("online", 0) + MANUAL_SHUTDOWN = ("manual_shutdown", 1) + SLEEP_SHUTDOWN = ("sleep_shutdown", 2) + LOW_BATTERY_SHUTDOWN = ("low_battery_shutdown", 3) + UPGRADE_REBOOT = ("upgrade_reboot", 4) + + +class MowerFcState(MowerEnum): + """Factory or test mode indicator on DPS 141.""" + + UNKNOWN = ("unknown", -1) + NOT_FC = ("not_fc", 0) + FC = ("fc", 1) + + +class MowerOffDockNoTaskStatus(MowerEnum): + """Mower off-dock status without an active task on DPS 143.""" + + UNKNOWN = ("unknown", 0) + WAITING = ("waiting", 1) + SUSPENDING = ("suspending", 2) + DOCKING = ("docking", 3) + REMOTING = ("remoting", 4) + + +class MowerAfsStatus(MowerEnum): + """Service status on DPS 144.""" + + UNKNOWN = ("unknown", -1) + IDLE = ("idle", 0) + BIT = ("bit", 1) + MMT_OBA = ("mmt_oba", 2) + CALIBRATION = ("calibration", 3) + ACTIVE = ("active", 4) + INACTIVE = ("inactive", 5) + + +class MowerNetworkChannel(MowerEnum): + """Current network route on DPS 145.""" + + UNKNOWN = ("unknown", 0) + WLAN0 = ("wlan0", 1) + WLAN1 = ("wlan1", 2) + MOBILE_4G = ("mobile_4g", 3) + + +class MowerDetailState(MowerEnum): + """Detailed task state reported on DPS 123 and in GET_ROBOT_INFO replies.""" + + UNKNOWN = ("unknown", 0) + + MAP_INITIALIZING = ("map_initializing", 1) + MAP_UNDOCKING = ("map_undocking", 2) + MAP_UNDOCK_FAULT = ("map_undock_fault", 3) + MAP_LOCATING = ("map_locating", 4) + MAP_PREPARE_BOUNDARY = ("map_prepare_boundary", 5) + MAP_PREPARE_ISLAND = ("map_prepare_island", 6) + MAP_PREPARE_PATH = ("map_prepare_path", 7) + MAP_BOUNDARY = ("map_boundary", 8) + MAP_ISLAND = ("map_island", 9) + MAP_PATH = ("map_path", 10) + MAP_BOUNDARY_AUTO = ("map_boundary_auto", 11) + # The protocol spelling "MAP_ERASEING" is intentional. + MAP_ERASING = ("map_eraseing", 12) + MAP_SAVE = ("map_save", 13) + MAP_WAIT = ("map_wait", 14) + MAP_RECOVERABLE_FAULT = ("map_recoverable_fault", 15) + MAP_FAULT = ("map_fault", 16) + MAP_EMERGENCY_STOP = ("map_emergency_stop", 17) + MAP_WAITING_FAULT = ("map_waiting_fault", 18) + MAP_NO_AVOID = ("map_no_avoid", 19) + MAP_PREPARE_NO_AVOID = ("map_prepare_no_avoid", 20) + MAP_RESET_PASSAGE = ("map_reset_passage", 21) + MAP_PREPARE_RESET_PASSAGE = ("map_prepare_reset_passage", 22) + MAP_EDIT_BOUNDARY = ("map_edit_boundary", 23) + MAP_PREPARE_EDIT_BOUNDARY = ("map_prepare_edit_boundary", 24) + MAP_CONFIRM_EDIT_BOUNDARY = ("map_confirm_edit_boundary", 25) + + MOW_INITIALIZING = ("mow_initializing", 51) + MOW_UNDOCKING = ("mow_undocking", 52) + MOW_LOCATING = ("mow_locating", 53) + MOW_ADJUST_CUTTER = ("mow_adjust_cutter", 54) + MOW_ZIG_ZAG = ("mow_zig_zag", 55) + MOW_EDGE = ("mow_edge", 56) + MOW_GOTO = ("mow_goto", 57) + MOW_SUSPEND = ("mow_suspend", 58) + MOW_RECOVERABLE_FAULT = ("mow_recoverable_fault", 59) + MOW_FAULT = ("mow_fault", 60) + MOW_DOCK_RAINFALL = ("mow_dock_rainfall", 61) + MOW_DOCK_NOTDISTURB = ("mow_dock_notdisturb", 62) + MOW_DOCK_LOW_BATTERY = ("mow_dock_low_battery", 63) + MOW_WAIT = ("mow_wait", 64) + MOW_PREPARE_REMOTE = ("mow_prepare_remote", 65) + MOW_REMOTE = ("mow_remote", 66) + MOW_EMERGENCY_STOP = ("mow_emergency_stop", 67) + MOW_DOCK_MANUAL = ("mow_dock_manual", 68) + MOW_DOCK_FAULT = ("mow_dock_fault", 69) + MOW_REMOTE_UNDOCKING = ("mow_remote_undocking", 70) + MOW_TO_DOCK_INITIALIZING = ("mow_to_dock_initializing", 71) + MOW_TO_DOCK_LOCATING = ("mow_to_dock_locating", 72) + MOW_TO_DOCK_RECOVERABLE_FAULT = ("mow_to_dock_recoverable_fault", 73) + MOW_TO_DOCK_FAULT = ("mow_to_dock_fault", 74) + MOW_TO_DOCK_EMERGENCY_STOP = ("mow_to_dock_emergency_stop", 75) + MOW_TO_DOCK_CHARGING = ("mow_to_dock_charging", 76) + MOW_TO_DOCK_CHARGE_COMPLETED = ("mow_to_dock_charge_completed", 77) + MOW_PAUSE = ("mow_pause", 78) + + FREE = ("free", 101) + FREE_INITIALIZING = ("free_initializing", 102) + FREE_LOCATING = ("free_locating", 103) + FREE_DOCK_MANUAL = ("free_dock_manual", 104) + FREE_DOCK_MOW_END = ("free_dock_mow_end", 105) + FREE_DOCK_PLAN_END = ("free_dock_plan_end", 106) + FREE_EMERGENCY_STOP = ("free_emergency_stop", 107) + FREE_RECOVERABLE_FAULT = ("free_recoverable_fault", 108) + FREE_FAULT = ("free_fault", 109) + FREE_BATTERY_HOLD_MODE = ("free_battery_hold_mode", 110) + + CHARGING = ("charging", 151) + CHARGE_COMPLETED = ("charge_completed", 152) + CHARGE_WAITING = ("charge_waiting", 153) + CHARGE_FAULT = ("charge_fault", 154) + + +class MowerErrorCode(MowerEnum): + """User-facing error code reported by DPS 120.""" + + UNKNOWN = ("unknown", 0) + MAIN_CUTTER_OVER_CURRENT = ("main_cutter_over_current", 1) + MAIN_CUTTER_OVER_TEMPERATURE = ("main_cutter_over_temperature", 2) + MAIN_CUTTER_DYNAMIC_UNBALANCE = ("main_cutter_dynamic_unbalance", 3) + EDGE_CUTTER_OVER_CURRENT = ("edge_cutter_over_current", 4) + EDGE_CUTTER_OVER_TEMPERATURE = ("edge_cutter_over_temperature", 5) + EDGE_CUTTER_DYNAMIC_UNBALANCE = ("edge_cutter_dynamic_unbalance", 6) + HEIGHT_MODULE_ADJUSTING_INCOMPLETE = ("height_module_adjusting_incomplete", 7) + TRAVEL_MOTOR_LEFT_FRONT_OVER_CURRENT = ("travel_motor_left_front_over_current", 8) + TRAVEL_MOTOR_RIGHT_FRONT_OVER_CURRENT = ("travel_motor_right_front_over_current", 9) + TRAVEL_MOTOR_LEFT_REAR_OVER_CURRENT = ("travel_motor_left_rear_over_current", 10) + TRAVEL_MOTOR_RIGHT_REAR_OVER_CURRENT = ("travel_motor_right_rear_over_current", 11) + TRAVEL_MOTOR_OVER_TEMPERATURE = ("travel_motor_over_temperature", 12) + STEERING_MOTOR_LEFT_FRONT_OVER_CURRENT = ("steering_motor_left_front_over_current", 13) + STEERING_MOTOR_RIGHT_FRONT_OVER_CURRENT = ("steering_motor_right_front_over_current", 14) + STEERING_MOTOR_OVER_TEMPERATURE = ("steering_motor_over_temperature", 15) + STOP = ("stop", 16) + TILT = ("tilt", 17) + LIFT = ("lift", 18) + FLIP = ("flip", 19) + OUT_OF_BOUND = ("out_of_bound", 20) + BUMPER = ("bumper", 21) + BATTERY_DISCHARGE_OVER_TEMPERATURE = ("battery_discharge_over_temperature", 22) + BATTERY_DISCHARGE_LOW_TEMPERATURE = ("battery_discharge_low_temperature", 23) + BATTERY_UNOFFICIAL = ("battery_unofficial", 24) + DIRTY_CHARGING_CLIPS = ("dirty_charging_clips", 25) + CHARGE_STATION_NO_POWER = ("charge_station_no_power", 26) + CAMERA_DIRTY_OR_OBSTRUCTED = ("camera_dirty_or_obstructed", 27) + CAMERA_OVER_TEMPERATURE = ("camera_over_temperature", 28) + CAMERA_LED_OBSTRUCTED = ("camera_led_obstructed", 29) + LIDAR_DIRTY = ("lidar_dirty", 30) + LIDAR_OVER_TEMPERATURE = ("lidar_over_temperature", 31) + LOCATION_FAILED = ("location_failed", 32) + LOCATION_AT_ISLAND = ("location_at_island", 33) + LOCATION_AT_NON_COMPLIANT_SLOPE = ("location_at_non_compliant_slope", 34) + BE_TRAPPED = ("be_trapped", 35) + CHARGE_STATION_OR_RTK_SLIGHT_MOVEMENT_DOCK = ("charge_station_or_rtk_slight_movement_dock", 36) + N_RTK_SIGNIFICANT_MOVEMENT = ("n_rtk_significant_movement", 37) + RTK_SIGNIFICANT_MOVEMENT = ("rtk_significant_movement", 38) + DOCK_CHARGE_STATION_SEARCH_FAIL = ("dock_charge_station_search_fail", 39) + DOCK_QRCODE_SCAN_FAIL = ("dock_qrcode_scan_fail", 40) + DOCK_ADJUST_ERROR = ("dock_adjust_error", 41) + UNDOCK_ERROR = ("undock_error", 42) + BATTERY_CHARGE_OVER_TEMPERATURE = ("battery_charge_over_temperature", 43) + BATTERY_CHARGE_LOW_TEMPERATURE = ("battery_charge_low_temperature", 44) + CAMERA_SEVERELY_DIRTY = ("camera_severely_dirty", 45) + NO_PLUGIN_KEY = ("no_plugin_key", 46) + CAMERA_REAR_SIDE_DIRTY = ("camera_rear_side_dirty", 48) + N_RTK_NETWORK_ERROR = ("n_rtk_network_error", 55) + MAIN_CUTTER_FAULT = ("main_cutter_fault", 101) + EDGE_CUTTER_FAULT = ("edge_cutter_fault", 102) + HEIGHT_MODULE_FAULT = ("height_module_fault", 103) + BATTERY_FAULT = ("battery_fault", 104) + CAMERA_FAULT = ("camera_fault", 105) + CAMERA_LED_FAULT = ("camera_led_fault", 106) + MAIN_BOARD_FAULT = ("main_board_fault", 107) + LIDAR_FAULT = ("lidar_fault", 108) + TRAVEL_MOTOR_LEFT_FRONT_FAULT = ("travel_motor_left_front_fault", 109) + TRAVEL_MOTOR_RIGHT_FRONT_FAULT = ("travel_motor_right_front_fault", 110) + TRAVEL_MOTOR_LEFT_REAR_FAULT = ("travel_motor_left_rear_fault", 111) + TRAVEL_MOTOR_RIGHT_REAR_FAULT = ("travel_motor_right_rear_fault", 112) + STEERING_MOTOR_LEFT_FRONT_FAULT = ("steering_motor_left_front_fault", 113) + STEERING_MOTOR_RIGHT_FRONT_FAULT = ("steering_motor_right_front_fault", 114) + LED_SCREEN_FAULT = ("led_screen_fault", 115) + LEFT_SIDE_CAMERA_FAULT = ("left_side_camera_fault", 116) + RIGHT_SIDE_CAMERA_FAULT = ("right_side_camera_fault", 117) + REAR_SIDE_CAMERA_FAULT = ("rear_side_camera_fault", 118) diff --git a/roborock/data/mower/mower_containers.py b/roborock/data/mower/mower_containers.py index d7d4b3822..3413bb399 100644 --- a/roborock/data/mower/mower_containers.py +++ b/roborock/data/mower/mower_containers.py @@ -6,53 +6,97 @@ from roborock.data.containers import RoborockBase from roborock.roborock_message import RoborockMowerDataProtocol -from .mower_code_mappings import RoborockMowerStateCode +from .mower_code_mappings import ( + MowerAfsStatus, + MowerChargeState, + MowerChargeType, + MowerConfMode, + MowerDetailState, + MowerDockState, + MowerEffMode, + MowerErrorCode, + MowerFcState, + MowerMappingState, + MowerMappingType, + MowerMowType, + MowerNetworkChannel, + MowerOffDockNoTaskStatus, + MowerOfflineStatus, + MowerOtaState, + MowerPattern, + MowerPendType, + MowerRemoteState, + MowerStartType, +) + +__all__ = [ + "MowerGpsCoordinate", + "MowerStatus", +] + + +@dataclass +class MowerGpsCoordinate(RoborockBase): + """Mower GPS position.""" + + latitude: float | None = None + longitude: float | None = None @dataclass class MowerStatus(RoborockBase): - """Core mower status backed by mower DPS updates.""" + """Core mower status backed by mower DPS updates (data points 120-145).""" - error_code: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.ERROR_CODE}) + error_code: MowerErrorCode | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.ERROR_CODE}) battery: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.BATTERY}) - mow_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_TYPE}) - mow_state: RoborockMowerStateCode | None = field( - default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_STATE} + mow_type: MowerMowType | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_TYPE}) + mow_state: MowerDetailState | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_STATE}) + mapping_type: MowerMappingType | None = field( + default=None, metadata={"dps": RoborockMowerDataProtocol.MAPPING_TYPE} + ) + mapping_state: MowerMappingState | None = field( + default=None, metadata={"dps": RoborockMowerDataProtocol.MAPPING_STATE} + ) + ota_state: MowerOtaState | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.OTA_STATE}) + charge_state: MowerChargeState | None = field( + default=None, metadata={"dps": RoborockMowerDataProtocol.CHARGE_STATE} ) - mapping_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MAPPING_TYPE}) - mapping_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MAPPING_STATE}) - ota_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.OTA_STATE}) - charge_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.CHARGE_STATE}) - dock_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.DOCK_STATE}) - charge_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.CHARGE_TYPE}) - pend_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.PEND_TYPE}) - remote_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.REMOTE_STATE}) - mow_start_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_START_TYPE}) - mow_eff_mode: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_EFF_MODE}) + dock_state: MowerDockState | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.DOCK_STATE}) + charge_type: MowerChargeType | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.CHARGE_TYPE}) + pend_type: MowerPendType | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.PEND_TYPE}) + remote_state: MowerRemoteState | None = field( + default=None, metadata={"dps": RoborockMowerDataProtocol.REMOTE_STATE} + ) + mow_start_type: MowerStartType | None = field( + default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_START_TYPE} + ) + mow_eff_mode: MowerEffMode | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_EFF_MODE}) mow_height: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_HEIGHT}) mow_direction_angle: int | None = field( default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_DIRECTION_ANGLE} ) - mow_pattern: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_PATTERN}) - mow_conf_mode: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_CONF_MODE}) - offline_status: Any | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.OFFLINE_STATUS}) + mow_pattern: MowerPattern | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_PATTERN}) + mow_conf_mode: MowerConfMode | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_CONF_MODE}) + offline_status: MowerOfflineStatus | None = field( + default=None, metadata={"dps": RoborockMowerDataProtocol.OFFLINE_STATUS} + ) mow_progress: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_PROGRESS}) blade_lifespan: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.BLADE_LIFESPAN}) - fc_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.FC_STATE}) - gps_coordinate: Any | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.GPS_COORDINATE}) - off_dock_no_task_status: int | None = field( + fc_state: MowerFcState | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.FC_STATE}) + gps_coordinate: MowerGpsCoordinate | None = field( + default=None, metadata={"dps": RoborockMowerDataProtocol.GPS_COORDINATE} + ) + off_dock_no_task_status: MowerOffDockNoTaskStatus | None = field( default=None, metadata={"dps": RoborockMowerDataProtocol.OFF_DOCK_NO_TASK_STATUS} ) - afs_status: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.AFS_STATUS}) - network_channel: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.NETWORK_CHANNEL}) + afs_status: MowerAfsStatus | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.AFS_STATUS}) + network_channel: MowerNetworkChannel | None = field( + default=None, metadata={"dps": RoborockMowerDataProtocol.NETWORK_CHANNEL} + ) @classmethod def from_dps(cls, dps: dict[int | str, Any] | None) -> Self: - """Create mower status from raw DPS data. - - Cloud snapshots use string DPS keys while decoded MQTT messages may use - integer keys. Unknown and malformed keys are ignored. - """ + """Create typed status from integer or string DPS keys.""" field_by_dps = { int(field_info.metadata["dps"]): field_info.name for field_info in fields(cls) diff --git a/tests/data/mower/test_mower_code_mappings.py b/tests/data/mower/test_mower_code_mappings.py new file mode 100644 index 000000000..4f36bfe26 --- /dev/null +++ b/tests/data/mower/test_mower_code_mappings.py @@ -0,0 +1,129 @@ +"""Tests for mower code mappings.""" + +import pytest + +from roborock.data import mower +from roborock.data.code_mappings import RoborockModeEnum +from roborock.data.mower import ( + MowerChargeState, + MowerDetailState, + MowerEffMode, + MowerEnum, + MowerErrorCode, + MowerFcState, + MowerMowState, + MowerMowType, + MowerNetworkChannel, + MowerOfflineStatus, + MowerPendType, +) + +ALL_MOWER_ENUMS: list[type[RoborockModeEnum]] = sorted( + ( + obj + for obj in vars(mower).values() + if isinstance(obj, type) and issubclass(obj, MowerEnum) and obj is not MowerEnum + ), + key=lambda cls: cls.__name__, +) + + +def test_all_mower_enums_are_exported() -> None: + """Every mower enum is exported from its module.""" + assert ALL_MOWER_ENUMS + assert all(cls.__name__ in mower.mower_code_mappings.__all__ for cls in ALL_MOWER_ENUMS) + + +@pytest.mark.parametrize("enum_cls", ALL_MOWER_ENUMS, ids=lambda cls: cls.__name__) +def test_mower_enum_values_are_lowercase_and_round_trip(enum_cls: type[RoborockModeEnum]) -> None: + """String values are lowercase and round-trip by value, uppercase protobuf name, and integer code.""" + for member in enum_cls: + assert member.value == member.value.lower() + assert enum_cls.from_value(member.value) is member + assert enum_cls.from_value(member.value.upper()) is member + assert enum_cls.from_code(member.code) is member + + +@pytest.mark.parametrize("enum_cls", ALL_MOWER_ENUMS, ids=lambda cls: cls.__name__) +def test_mower_enum_codes_are_unique(enum_cls: type[RoborockModeEnum]) -> None: + """Each wire code maps to exactly one member.""" + codes = [member.code for member in enum_cls] + assert len(codes) == len(set(codes)) + + +@pytest.mark.parametrize( + ("enum_cls", "code", "expected"), + [ + # Values from the live RockNeo Q105 status capture. + (MowerMowType, 1, MowerMowType.GLOBAL), + (MowerDetailState, 58, MowerDetailState.MOW_SUSPEND), + (MowerEffMode, 1, MowerEffMode.DAILY), + (MowerNetworkChannel, 1, MowerNetworkChannel.WLAN0), + # Zero has a named meaning in every compact enum. + (MowerMowState, 0, MowerMowState.IDLE), + (MowerChargeState, 0, MowerChargeState.NOT_CHARGING), + (MowerPendType, 0, MowerPendType.NOT_SUSPENDED), + (MowerOfflineStatus, 0, MowerOfflineStatus.ONLINE), + (MowerFcState, 0, MowerFcState.NOT_FC), + # Boundaries of the detailed state domain. + (MowerDetailState, 25, MowerDetailState.MAP_CONFIRM_EDIT_BOUNDARY), + (MowerDetailState, 78, MowerDetailState.MOW_PAUSE), + (MowerDetailState, 110, MowerDetailState.FREE_BATTERY_HOLD_MODE), + (MowerDetailState, 154, MowerDetailState.CHARGE_FAULT), + # Error codes have gaps in their numbering. + (MowerErrorCode, 48, MowerErrorCode.CAMERA_REAR_SIDE_DIRTY), + (MowerErrorCode, 55, MowerErrorCode.N_RTK_NETWORK_ERROR), + (MowerErrorCode, 118, MowerErrorCode.REAR_SIDE_CAMERA_FAULT), + ], +) +def test_mower_enum_code_lookup(enum_cls: type[RoborockModeEnum], code: int, expected: RoborockModeEnum) -> None: + assert enum_cls.from_code(code) is expected + + +@pytest.mark.parametrize( + ("enum_cls", "code"), + [ + # Gaps inside the detailed-state and error-code domains. + (MowerDetailState, 26), + (MowerDetailState, 50), + (MowerDetailState, 79), + (MowerErrorCode, 47), + (MowerErrorCode, 56), + # The detailed value seen on DPS 123 is not part of the compact domain. + (MowerMowState, 58), + ], +) +def test_mower_enum_unknown_codes_resolve_to_none(enum_cls: type[RoborockModeEnum], code: int) -> None: + assert enum_cls.from_code_optional(code) is None + + +@pytest.mark.parametrize( + ("member", "wire_name"), + [ + (MowerDetailState.MAP_ERASING, "MAP_ERASEING"), + ], +) +def test_misspelled_wire_names_are_preserved(member: RoborockModeEnum, wire_name: str) -> None: + """Protocol misspellings are kept as the lowercased value so the JSON route still matches.""" + assert member.value == wire_name.lower() + assert type(member).from_value(wire_name) is member + + +@pytest.mark.parametrize( + ("enum_cls", "raw", "expected"), + [ + (MowerEffMode, "DAILY", MowerEffMode.DAILY), + (MowerEffMode, "daily", MowerEffMode.DAILY), + (MowerEffMode, 1, MowerEffMode.DAILY), + (MowerEffMode, "1", MowerEffMode.DAILY), + (MowerDetailState, "MAP_ERASEING", MowerDetailState.MAP_ERASING), + # Unknown values resolve to the explicit unknown member, synthetic or protocol-defined. + (MowerMowType, 9999, MowerMowType.UNKNOWN), + (MowerMowType, "FUTURE_TYPE", MowerMowType.UNKNOWN), + (MowerDetailState, 9999, MowerDetailState.UNKNOWN), + ], +) +def test_from_code_accepts_wire_forms_and_falls_back( + enum_cls: type[MowerEnum], raw: int | str, expected: MowerEnum +) -> None: + assert enum_cls.from_code(raw) is expected diff --git a/tests/data/mower/test_mower_containers.py b/tests/data/mower/test_mower_containers.py new file mode 100644 index 000000000..5cbbb943a --- /dev/null +++ b/tests/data/mower/test_mower_containers.py @@ -0,0 +1,115 @@ +"""Tests for mower data containers.""" + +import dataclasses + +import pytest + +from roborock.data import ( + MowerDetailState, + MowerEffMode, + MowerErrorCode, + MowerGpsCoordinate, + MowerMowType, + MowerNetworkChannel, + MowerStatus, +) +from roborock.roborock_message import RoborockMowerDataProtocol + + +def test_mower_status_from_captured_values() -> None: + """The values from the captured snapshot parse to their typed fields.""" + status = MowerStatus.from_dict( + {"battery": 78, "mow_type": 1, "mow_state": 58, "mow_eff_mode": 1, "mow_progress": 55, "network_channel": 1} + ) + + assert status.battery == 78 + assert status.mow_type is MowerMowType.GLOBAL + assert status.mow_state is MowerDetailState.MOW_SUSPEND + assert status.mow_eff_mode is MowerEffMode.DAILY + assert status.mow_progress == 55 + assert status.network_channel is MowerNetworkChannel.WLAN0 + # Fields absent from a report are unknown, not zero. + assert status.error_code is None + assert status.mow_height is None + + +def test_mower_status_from_dict_accepts_camel_case_and_nested_gps() -> None: + status = MowerStatus.from_dict( + { + "errorCode": 18, + "battery": 82, + "mowState": 55, + "mowHeight": 70, + "gpsCoordinate": {"latitude": 1.5, "longitude": -2.25}, + "unknown": "ignored", + } + ) + + assert status.error_code is MowerErrorCode.LIFT + assert status.battery == 82 + assert status.mow_state is MowerDetailState.MOW_ZIG_ZAG + assert status.mow_height == 70 + assert status.gps_coordinate == MowerGpsCoordinate(latitude=1.5, longitude=-2.25) + + +def test_mower_status_round_trips_through_as_dict() -> None: + status = MowerStatus.from_dict({"battery": 78, "mow_state": 58, "mow_type": 1}) + + serialized = status.as_dict() + + assert serialized["mowState"] == "mow_suspend" + assert serialized["mowType"] == "global" + assert MowerStatus.from_dict(serialized) == status + + +def test_mower_status_resolves_unknown_firmware_codes_to_unknown() -> None: + """An unmapped code resolves to the explicit UNKNOWN member instead of raising.""" + status = MowerStatus.from_dict({"mow_state": 9999, "battery": 50, "mow_type": 9999}) + + assert status.mow_state is MowerDetailState.UNKNOWN + assert status.mow_type is MowerMowType.UNKNOWN + assert status.battery == 50 + + +def test_mower_status_dps_metadata_matches_protocol() -> None: + dps_by_field = { + field.name: field.metadata["dps"] for field in dataclasses.fields(MowerStatus) if "dps" in field.metadata + } + status_protocols = {protocol for protocol in RoborockMowerDataProtocol if 120 <= protocol.value <= 145} + + assert set(dps_by_field.values()) == status_protocols + assert len(dps_by_field) == len(status_protocols) + + +def test_mower_status_from_dps() -> None: + """The existing factory accepts mixed keys and returns typed status fields.""" + status = MowerStatus.from_dps( + { + "121": 78, + 122: 1, + "123": 58, + "133": 1, + "142": {"latitude": 1.5, "longitude": -2.25}, + "145": 1, + "battery": 0, + "123.0": 55, + "999": "ignored", + } + ) + + assert status.battery == 78 + assert status.mow_type is MowerMowType.GLOBAL + assert status.mow_state is MowerDetailState.MOW_SUSPEND + assert status.mow_eff_mode is MowerEffMode.DAILY + assert status.gps_coordinate == MowerGpsCoordinate(latitude=1.5, longitude=-2.25) + assert status.network_channel is MowerNetworkChannel.WLAN0 + assert status.error_code is None + + +@pytest.mark.parametrize("dps", [None, {}]) +def test_mower_status_from_empty_dps(dps: dict[int | str, object] | None) -> None: + assert MowerStatus.from_dps(dps) == MowerStatus() + + +def test_mower_status_from_dps_unknown_state() -> None: + assert MowerStatus.from_dps({"123": 9999}).mow_state is MowerDetailState.UNKNOWN diff --git a/tests/data/test_mower_containers.py b/tests/data/test_mower_containers.py deleted file mode 100644 index 423d4ce04..000000000 --- a/tests/data/test_mower_containers.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Test mower data containers.""" - -import dataclasses - -from roborock.data import MowerStatus, RoborockMowerStateCode -from roborock.roborock_message import RoborockMowerDataProtocol - - -def test_mower_status_from_dict() -> None: - status = MowerStatus.from_dict( - { - "error_code": 0, - "battery": 82, - "mow_state": 3, - "mowHeight": 70, - "gpsCoordinate": {"latitude": 1, "longitude": 2}, - "unknown": "ignored", - } - ) - - assert status.error_code == 0 - assert status.battery == 82 - assert status.mow_state is RoborockMowerStateCode.map_undock_fault - assert status.mow_height == 70 - assert status.gps_coordinate == {"latitude": 1, "longitude": 2} - - -def test_mower_status_from_dps() -> None: - status = MowerStatus.from_dps( - { - "121": 78, - 122: 1, - "123": 58, - "133": 1, - "139": 55, - "142": "", - "145": 1, - "unknown": "ignored", - "999": "ignored", - } - ) - - assert status.battery == 78 - assert status.mow_type == 1 - assert status.mow_state is RoborockMowerStateCode.mow_suspend - assert status.mow_eff_mode == 1 - assert status.mow_progress == 55 - assert status.gps_coordinate == "" - assert status.network_channel == 1 - assert status.error_code is None - assert status.mow_height is None - - -def test_mower_status_from_empty_dps() -> None: - assert MowerStatus.from_dps(None) == MowerStatus() - - -def test_mower_status_unknown_state_uses_unknown() -> None: - status = MowerStatus.from_dps({"123": 9999}) - - assert status.mow_state is RoborockMowerStateCode.unknown - - -def test_mower_state_code_mappings() -> None: - assert RoborockMowerStateCode(0) is RoborockMowerStateCode.idle - assert RoborockMowerStateCode(55) is RoborockMowerStateCode.mow_zig_zag - assert RoborockMowerStateCode(56) is RoborockMowerStateCode.mow_edge - assert RoborockMowerStateCode(71) is RoborockMowerStateCode.mow_to_dock_initializing - assert RoborockMowerStateCode(151) is RoborockMowerStateCode.charge_charging - assert RoborockMowerStateCode(154) is RoborockMowerStateCode.charge_fault - - -def test_mower_status_dps_metadata() -> None: - dps_by_field = { - field.name: field.metadata["dps"] - for field in dataclasses.fields(MowerStatus) - if field.metadata and "dps" in field.metadata - } - - assert dps_by_field == { - "error_code": RoborockMowerDataProtocol.ERROR_CODE, - "battery": RoborockMowerDataProtocol.BATTERY, - "mow_type": RoborockMowerDataProtocol.MOW_TYPE, - "mow_state": RoborockMowerDataProtocol.MOW_STATE, - "mapping_type": RoborockMowerDataProtocol.MAPPING_TYPE, - "mapping_state": RoborockMowerDataProtocol.MAPPING_STATE, - "ota_state": RoborockMowerDataProtocol.OTA_STATE, - "charge_state": RoborockMowerDataProtocol.CHARGE_STATE, - "dock_state": RoborockMowerDataProtocol.DOCK_STATE, - "charge_type": RoborockMowerDataProtocol.CHARGE_TYPE, - "pend_type": RoborockMowerDataProtocol.PEND_TYPE, - "remote_state": RoborockMowerDataProtocol.REMOTE_STATE, - "mow_start_type": RoborockMowerDataProtocol.MOW_START_TYPE, - "mow_eff_mode": RoborockMowerDataProtocol.MOW_EFF_MODE, - "mow_height": RoborockMowerDataProtocol.MOW_HEIGHT, - "mow_direction_angle": RoborockMowerDataProtocol.MOW_DIRECTION_ANGLE, - "mow_pattern": RoborockMowerDataProtocol.MOW_PATTERN, - "mow_conf_mode": RoborockMowerDataProtocol.MOW_CONF_MODE, - "offline_status": RoborockMowerDataProtocol.OFFLINE_STATUS, - "mow_progress": RoborockMowerDataProtocol.MOW_PROGRESS, - "blade_lifespan": RoborockMowerDataProtocol.BLADE_LIFESPAN, - "fc_state": RoborockMowerDataProtocol.FC_STATE, - "gps_coordinate": RoborockMowerDataProtocol.GPS_COORDINATE, - "off_dock_no_task_status": RoborockMowerDataProtocol.OFF_DOCK_NO_TASK_STATUS, - "afs_status": RoborockMowerDataProtocol.AFS_STATUS, - "network_channel": RoborockMowerDataProtocol.NETWORK_CHANNEL, - } - - -def test_mower_command_dps_are_not_status_fields() -> None: - status_dps = { - field.metadata["dps"] for field in dataclasses.fields(MowerStatus) if field.metadata and "dps" in field.metadata - } - - assert RoborockMowerDataProtocol.START not in status_dps - assert RoborockMowerDataProtocol.DOCK not in status_dps - assert RoborockMowerDataProtocol.PAUSE not in status_dps - assert RoborockMowerDataProtocol.RESUME not in status_dps - assert RoborockMowerDataProtocol.STOP not in status_dps