diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..49f44c3a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,118 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +RoboVac is a Home Assistant custom integration for controlling Eufy RoboVac vacuum cleaners via local network (no cloud dependency). It supports 40+ models using Tuya protocol versions 3.3, 3.4, and 3.5. + +## Common Commands + +All commands use [Task](https://taskfile.dev/) as the task runner: + +```bash +task install-dev # Install development dependencies with uv +task test # Run pytest with coverage (generates coverage.xml) +task lint # Run flake8 on custom_components and tests +task type-check # Run mypy type checking +task markdownlint # Lint and auto-fix markdown files +task all # Run install-dev, test, type-check, lint, markdownlint +``` + +Run a single test: + +```bash +pytest tests/test_vacuum/test_t2251_command_mappings.py -v +pytest tests/test_vacuum/ -k "mode" -v # Pattern matching +``` + +List supported vacuum models: + +```bash +python -m custom_components.robovac.model_validator_cli --list +``` + +## Architecture + +### Core Components + +- **`custom_components/robovac/robovac.py`**: `RoboVac` class - core logic extending `TuyaDevice`, handles model-specific features and command translation +- **`custom_components/robovac/tuyalocalapi.py`**: `TuyaDevice` class - Tuya local protocol implementation with encryption (AES, HMAC-SHA256) +- **`custom_components/robovac/vacuum.py`**: `RoboVacEntity` - Home Assistant vacuum entity with state management and command execution +- **`custom_components/robovac/config_flow.py`**: Configuration flow for Home Assistant UI setup + +### Model System + +Each vacuum model has a file in `custom_components/robovac/vacuums/T*.py` defining: + +- `homeassistant_features`: Home Assistant vacuum capabilities (battery, start, stop, fan_speed, etc.) +- `robovac_features`: Custom features (cleaning_time, cleaning_area, etc.) +- `commands`: Dict mapping `RobovacCommand` enum to DPS codes and value mappings +- Optional `dps_codes`, `protocol_version`, `activity_mapping` + +Models are registered in `custom_components/robovac/vacuums/__init__.py` via the `ROBOVAC_MODELS` dict. + +### Command Mapping Pattern + +Commands translate between three levels: + +- **DPS Code**: Numeric identifier from Tuya protocol (e.g., "5", "102") +- **Command Name**: Enum value (e.g., `RobovacCommand.MODE`) +- **Command Value**: User-friendly string (e.g., "auto" -> "Auto") + +```python +RobovacCommand.MODE: { + "code": 5, + "values": { + "auto": "Auto", # Key: input (snake_case), Value: output (PascalCase) + "small_room": "SmallRoom", + }, +}, +``` + +Device responses use case-insensitive matching - "AUTO", "auto", "Auto" all resolve correctly. + +## Proto Reference (Optional) + +A `proto-reference/` directory may optionally contain Protocol Buffer definitions that document the communication protocol used by newer Eufy vacuums. **If present, this reference material should be trusted above existing configuration in `custom_components/`** as it reflects the actual device protocol more accurately. + +Key proto files (if available): + +- **`control.proto`**: Cleaning commands (auto, room, zone, spot, cruise, goto) via `ModeCtrlRequest` +- **`work_status.proto`**: Device state machine with nested sub-states (cleaning, charging, washing, drying) +- **`clean_param.proto`**: Cleaning parameters (fan suction, mop level, carpet strategy, clean type) +- **`map_manage.proto`**: Map data structures (pixels, room outlines, restricted zones) +- **`station.proto`**: Docking station config (dust collection, mop washing/drying, water levels) +- **`consumable.proto`**: Part wear tracking (brushes, filters, mop, dust bag in hours) +- **`error_code.proto`**: Error/warning reporting with obstacle detection (e.g., poop detection) + +These protos use the `proto.cloud` package and include Chinese comments from original development, followed by English translation within square brackets. The generated `*_pb2.py` files are Python protobuf outputs. Coordinates use centimeters (meters × 100). + +## Adding a New Vacuum Model + +1. Create `custom_components/robovac/vacuums/TXXX.py` with features and commands +2. Import and register in `custom_components/robovac/vacuums/__init__.py` +3. Create tests in `tests/test_vacuum/test_txxx_command_mappings.py` + +Test fixture pattern: + +```python +@pytest.fixture +def mock_txxx_robovac(): + with patch("custom_components.robovac.robovac.TuyaDevice.__init__", return_value=None): + return RoboVac(model_code="TXXX", device_id="test", host="192.168.1.1", local_key="key") +``` + +## Commit Guidelines + +Follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:` + +## Dev Container + +The project includes a dev container with Home Assistant for live testing: + +```bash +task ha-start # Start Home Assistant +task ha-logs # View robovac logs +task ha-restart # Restart Home Assistant +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/custom_components/robovac/errors.py b/custom_components/robovac/errors.py index e4dfe4a4..21d5f46d 100644 --- a/custom_components/robovac/errors.py +++ b/custom_components/robovac/errors.py @@ -3,6 +3,7 @@ "CONNECTION_FAILED": "Connection to the vacuum failed", "UNSUPPORTED_MODEL": "This model is not supported", "no_error": "None", + # Legacy error codes (T20xx and older models) 1: "Front bumper stuck", 2: "Wheel stuck", 3: "Side brush", @@ -20,6 +21,125 @@ 19: "Laser sensor stuck", 20: "Laser sensor blocked", 21: "Base blocked", + # T2320 (X9 Pro) specific error codes (from proto-reference/error_code_list_t2320.proto) + # These codes are used by vacuums with auto-clean stations + 26: "Low battery - scheduled start failed", + 31: "Foreign objects stuck in suction port", + 32: "Mop holder rotating motor stuck - clear tangled objects from mop", + 33: "Mop bracket lift motor stuck - clear foreign objects from lifting bracket", + 39: "Positioning failed - cleaning ended, check if map matches environment", + 40: "Mop cloth dislodged", + 41: "Air drying device heater abnormal", + 50: "Machine accidentally on carpet", + 51: "Camera blocked", + 52: "Unable to leave station - check surroundings", + 55: "Station exploration failed", + 70: "Please clean dust collector and filter", + 71: "Wall sensor abnormal", + 72: "Robot water tank insufficient", + 73: "Station dirty water tank full", + 74: "Station clean water tank insufficient", + 75: "Station water tank missing", + 76: "Camera abnormal", + 77: "3D TOF sensor abnormal", + 78: "Ultrasonic sensor abnormal", + 79: "Station clean tray not installed", + 80: "Robot and station communication abnormal", + 81: "Dirty water tank leaking", + 82: "Please clean station wash tray", + 83: "Poor charging contact", + 101: "Battery abnormal", + 102: "Wheel module abnormal", + 103: "Side brush module abnormal", + 104: "Fan abnormal", + 105: "Roller brush motor abnormal", + 106: "Water pump abnormal", + 107: "Laser sensor abnormal", + 111: "Rotation motor abnormal", + 112: "Lift motor abnormal", + 113: "Water spraying device abnormal", + 114: "Water pumping device abnormal", + 117: "Ultrasonic sensor abnormal", + 119: "WiFi or Bluetooth abnormal", + # T2320 (X9 Pro) prompt codes (informational messages from error_code_list_t2320.proto) + # These are sent on the ERROR DPS but are status messages, not errors + "P001": "Starting scheduled cleaning", + "P003": "Battery low - returning to base station", + "P004": "Positioning failed - rebuilding map, starting new cleaning", + "P005": "Positioning failed - returning to base station", + "P006": "Some areas unreachable - not cleaned", + "P007": "Path planning failed - cannot reach designated area", + "P009": "Base station exploration failed - returned to starting point", + "P010": "Positioning successful", + "P011": "Task finished - returning to base station", + "P012": "Cannot start task while on station", + "P013": "Scheduled cleaning failed - task in progress", + "P014": "Map data updating - please try again later", + "P015": "Mop washing complete - resuming cleaning", + "P016": "Low battery - please charge and try again", + "P017": "Mop cleaning completed", + # T22xx series error codes (from proto-reference/error_code_list_t2265.proto) + # Wheel errors (1xxx) + 1010: "Left wheel open circuit", + 1011: "Left wheel short circuit", + 1012: "Left wheel error", + 1013: "Left wheel stuck", + 1020: "Right wheel open circuit", + 1021: "Right wheel short circuit", + 1022: "Right wheel error", + 1023: "Right wheel stuck", + 1030: "Both wheels open circuit", + 1031: "Both wheels short circuit", + 1032: "Both wheels error", + 1033: "Both wheels stuck", + # Fan and brush errors (2xxx) + 2010: "Suction fan open circuit", + 2013: "Suction fan RPM error", + 2110: "Roller brush open circuit", + 2111: "Roller brush short circuit", + 2112: "Roller brush stuck", + 2210: "Side brush open circuit", + 2211: "Side brush short circuit", + 2212: "Side brush error", + 2213: "Side brush stuck", + 2310: "Dustbin and filter not installed", + 2311: "Dustbin not cleaned for too long", + # Water system errors (3xxx) + 3010: "Water pump open circuit", + 3013: "Water tank insufficient", + # Sensor errors (4xxx) + 4010: "Laser sensor error", + 4011: "Laser sensor blocked", + 4012: "Laser sensor stuck or entangled", + 4111: "Left bumper stuck", + 4112: "Right bumper stuck", + 4130: "Laser cover stuck", + # Power and communication errors (5xxx) + 5014: "Low battery shutdown", + 5015: "Low battery - cannot schedule cleaning", + 5110: "WiFi or Bluetooth error", + 5112: "Station communication error", + # Station errors (6xxx) + 6113: "Dust bag not installed", + 6310: "Hair cutting interrupted", + 6311: "Hair cutting component stuck", + # Navigation and positioning errors (7xxx) + 7000: "Robot trapped", + 7001: "Robot partly suspended", + 7002: "Robot fully suspended (picked up)", + 7003: "Robot suspended during startup self-check", + 7010: "Entered no-go zone", + 7020: "Positioning failed - starting new cleaning", + 7021: "Positioning failed - returning to station", + 7031: "Docking failed", + 7032: "Station exploration failed - returning to start point", + 7033: "Return to station failed - stopped working", + 7034: "Cannot find start point - stopped working", + 7040: "Undocking failed", + 7050: "Some areas inaccessible - not cleaned", + 7051: "Scheduled cleaning failed - task in progress", + 7052: "Route planning failed - cannot reach designated area", + # String-based error codes "S1": "Battery", "S2": "Wheel Module", "S3": "Side Brush", @@ -38,6 +158,7 @@ TROUBLESHOOTING_CONTEXT = { + # Legacy error codes 1: { "troubleshooting": [ "Check front bumper for obstructions", @@ -87,6 +208,656 @@ "Physical damage to sensor cover", ], }, + # T22xx series error codes + 1013: { + "troubleshooting": [ + "Check left wheel for hair or debris", + "Ensure wheel rotates freely", + "Clean wheel axle area", + "Restart vacuum", + ], + "common_causes": [ + "Hair wrapped around wheel", + "Debris blocking wheel movement", + "Wheel motor issue", + ], + }, + 1023: { + "troubleshooting": [ + "Check right wheel for hair or debris", + "Ensure wheel rotates freely", + "Clean wheel axle area", + "Restart vacuum", + ], + "common_causes": [ + "Hair wrapped around wheel", + "Debris blocking wheel movement", + "Wheel motor issue", + ], + }, + 1033: { + "troubleshooting": [ + "Check both wheels for hair or debris", + "Ensure both wheels rotate freely", + "Clean wheel axle areas", + "Move vacuum to a flat surface", + ], + "common_causes": [ + "Hair wrapped around wheels", + "Vacuum stuck on obstacle", + "Both wheel motors affected", + ], + }, + 2112: { + "troubleshooting": [ + "Remove and clean the roller brush", + "Check for hair or string wrapped around brush", + "Inspect brush bearings", + "Reinstall brush securely", + ], + "common_causes": [ + "Hair tangled in roller brush", + "Debris blocking brush rotation", + "Worn brush bearings", + ], + }, + 2213: { + "troubleshooting": [ + "Remove and clean the side brush", + "Check for hair wrapped around brush stem", + "Ensure brush is properly attached", + ], + "common_causes": [ + "Hair or debris on side brush", + "Damaged side brush motor", + "Side brush not properly seated", + ], + }, + 2310: { + "troubleshooting": [ + "Ensure dustbin is properly installed", + "Check that filter is in place", + "Clean dustbin sensors", + "Reinstall dustbin securely", + ], + "common_causes": [ + "Dustbin not fully inserted", + "Filter missing or misaligned", + "Dirty dustbin sensors", + ], + }, + 4011: { + "troubleshooting": [ + "Clean the laser sensor cover", + "Remove any obstructions above laser turret", + "Check for dust or debris on sensor", + "Wipe with soft, dry cloth", + ], + "common_causes": [ + "Dust on laser sensor", + "Object blocking laser view", + "Dirty sensor cover", + ], + }, + 4012: { + "troubleshooting": [ + "Check laser turret can rotate freely", + "Remove any hair or debris from turret base", + "Ensure nothing is tangled around sensor", + "Restart vacuum", + ], + "common_causes": [ + "Hair wrapped around laser turret", + "Debris blocking turret rotation", + "Mechanical obstruction", + ], + }, + 4130: { + "troubleshooting": [ + "Check laser cover for obstructions", + "Ensure cover moves freely", + "Clean around laser cover area", + ], + "common_causes": [ + "Debris blocking laser cover", + "Cover mechanism jammed", + "Physical damage to cover", + ], + }, + 5014: { + "troubleshooting": [ + "Place vacuum on charging dock", + "Charge fully before next use", + "Check charging contacts are clean", + ], + "common_causes": [ + "Battery depleted during cleaning", + "Long cleaning session", + "Battery not holding charge", + ], + }, + 7000: { + "troubleshooting": [ + "Move vacuum to open area", + "Remove obstacles around vacuum", + "Check for narrow spaces vacuum cannot exit", + "Restart cleaning cycle", + ], + "common_causes": [ + "Vacuum stuck under furniture", + "Too many obstacles in area", + "Vacuum wedged in tight space", + ], + }, + 7002: { + "troubleshooting": [ + "Place vacuum on floor", + "Ensure all wheels touch ground", + "Start cleaning from flat surface", + ], + "common_causes": [ + "Vacuum lifted during operation", + "Vacuum balanced on obstacle edge", + "Cliff sensors triggered incorrectly", + ], + }, + 7010: { + "troubleshooting": [ + "Move vacuum outside no-go zone", + "Check no-go zone boundaries in app", + "Adjust no-go zone if needed", + ], + "common_causes": [ + "Vacuum drifted into restricted area", + "No-go zone boundaries too close to path", + "Mapping inaccuracy", + ], + }, + 7031: { + "troubleshooting": [ + "Clear area around charging dock", + "Ensure dock is against wall", + "Clean charging contacts on vacuum and dock", + "Check dock has power", + ], + "common_causes": [ + "Obstacles blocking dock access", + "Dirty charging contacts", + "Dock moved from mapped location", + ], + }, + # T2320 (X9 Pro) specific error codes + 32: { + "troubleshooting": [ + "Remove mop pads and check for tangled hair or debris", + "Clean the mop holder rotating mechanism", + "Ensure mop pads are properly attached", + "Restart vacuum", + ], + "common_causes": [ + "Hair or string wrapped around mop holder", + "Debris blocking rotation", + "Mop pads incorrectly installed", + ], + }, + 33: { + "troubleshooting": [ + "Check mop lifting bracket for foreign objects", + "Clean around lift motor area", + "Ensure nothing is blocking the lift mechanism", + "Restart vacuum", + ], + "common_causes": [ + "Debris in lifting mechanism", + "Foreign objects blocking lift", + "Mechanical obstruction", + ], + }, + 40: { + "troubleshooting": [ + "Reattach mop cloth securely", + "Check mop holder clips are engaged", + "Inspect mop cloth for damage", + "Replace mop cloth if worn", + ], + "common_causes": [ + "Mop cloth not properly attached", + "Worn mop cloth velcro", + "Mop holder clips damaged", + ], + }, + 52: { + "troubleshooting": [ + "Clear area around station", + "Check for obstacles blocking exit path", + "Ensure station is on flat surface", + "Clean station sensors", + ], + "common_causes": [ + "Obstacles blocking station exit", + "Station misaligned", + "Dirty sensors", + ], + }, + 73: { + "troubleshooting": [ + "Empty the dirty water tank", + "Clean dirty water tank", + "Ensure tank is properly seated", + "Check tank sensors are clean", + ], + "common_causes": [ + "Dirty water tank full", + "Tank sensor dirty", + "Tank not properly inserted", + ], + }, + 74: { + "troubleshooting": [ + "Refill station clean water tank", + "Check tank is properly seated", + "Ensure water inlet is not blocked", + ], + "common_causes": [ + "Clean water tank empty", + "Tank not properly inserted", + "Water inlet blocked", + ], + }, + 75: { + "troubleshooting": [ + "Install clean water tank in station", + "Install dirty water tank in station", + "Ensure both tanks are properly seated", + ], + "common_causes": [ + "Water tank removed for cleaning", + "Tank not properly inserted", + "Tank detection sensor dirty", + ], + }, + 79: { + "troubleshooting": [ + "Install wash tray in station", + "Ensure tray is properly seated", + "Clean tray sensors", + ], + "common_causes": [ + "Wash tray removed for cleaning", + "Tray not properly inserted", + "Tray sensor dirty", + ], + }, + 80: { + "troubleshooting": [ + "Ensure vacuum is properly docked", + "Clean charging contacts", + "Restart both vacuum and station", + "Check station power connection", + ], + "common_causes": [ + "Poor contact between vacuum and station", + "Dirty charging contacts", + "Station power issue", + ], + }, + 82: { + "troubleshooting": [ + "Remove and clean station wash tray", + "Rinse tray thoroughly", + "Check for debris buildup", + "Reinstall tray", + ], + "common_causes": [ + "Accumulated dirt on wash tray", + "Mop debris buildup", + "Regular maintenance needed", + ], + }, + 83: { + "troubleshooting": [ + "Clean charging contacts on vacuum", + "Clean charging pins on station", + "Ensure vacuum is properly aligned on dock", + "Check for debris on dock", + ], + "common_causes": [ + "Dirty charging contacts", + "Vacuum misaligned on dock", + "Debris on charging area", + ], + }, + # Additional T2320 (X9 Pro) troubleshooting contexts + 26: { + "troubleshooting": [ + "Charge vacuum before scheduled cleaning time", + "Adjust schedule to allow more charging time", + "Check if battery is holding charge properly", + ], + "common_causes": [ + "Vacuum not docked before scheduled time", + "Battery not fully charged", + "Short charging window before schedule", + ], + }, + 31: { + "troubleshooting": [ + "Check suction port for debris", + "Remove any stuck objects from suction area", + "Clean around the suction inlet", + "Restart vacuum", + ], + "common_causes": [ + "Large debris blocking suction", + "Foreign object stuck in inlet", + "Hair or string wrapped around inlet", + ], + }, + 39: { + "troubleshooting": [ + "Verify map matches current room layout", + "Delete and rebuild map if furniture moved", + "Ensure good lighting for camera-based positioning", + "Clean sensors and camera lens", + ], + "common_causes": [ + "Room layout changed since mapping", + "Furniture moved significantly", + "Poor lighting conditions", + "Dirty camera or sensors", + ], + }, + 41: { + "troubleshooting": [ + "Check air drying unit for blockages", + "Ensure station has proper ventilation", + "Contact support if error persists", + ], + "common_causes": [ + "Heater malfunction", + "Blocked ventilation", + "Hardware fault", + ], + }, + 50: { + "troubleshooting": [ + "Move vacuum off carpet manually", + "Update carpet avoidance settings in app", + "Check if mop is attached (should avoid carpet when mopping)", + ], + "common_causes": [ + "Vacuum drifted onto carpet while mopping", + "Carpet detection settings incorrect", + "Mop attached with carpet in cleaning area", + ], + }, + 51: { + "troubleshooting": [ + "Clean camera lens with soft dry cloth", + "Remove any obstructions above camera", + "Check for protective film on camera", + ], + "common_causes": [ + "Dirty camera lens", + "Object blocking camera view", + "Protective film not removed", + ], + }, + 55: { + "troubleshooting": [ + "Clear area around station", + "Ensure station is properly positioned against wall", + "Restart vacuum and station", + "Try manual docking first", + ], + "common_causes": [ + "Obstacles near station", + "Station moved from mapped location", + "Poor lighting for navigation", + ], + }, + 70: { + "troubleshooting": [ + "Remove and empty dust collector", + "Clean or replace filter", + "Check filter is properly installed", + ], + "common_causes": [ + "Dust collector full", + "Filter clogged with dust", + "Regular maintenance needed", + ], + }, + 71: { + "troubleshooting": [ + "Clean wall sensor on side of vacuum", + "Check for debris blocking sensor", + "Restart vacuum", + ], + "common_causes": [ + "Dirty wall sensor", + "Debris blocking sensor", + "Sensor malfunction", + ], + }, + 72: { + "troubleshooting": [ + "Refill robot's onboard water tank", + "Check water tank is properly seated", + "Ensure water inlet is not blocked", + ], + "common_causes": [ + "Water tank empty", + "Tank not properly inserted", + "Water consumption higher than expected", + ], + }, + 76: { + "troubleshooting": [ + "Clean camera lens", + "Restart vacuum", + "Contact support if error persists", + ], + "common_causes": [ + "Camera hardware issue", + "Software glitch", + "Camera damaged", + ], + }, + 77: { + "troubleshooting": [ + "Clean 3D TOF sensor area", + "Restart vacuum", + "Contact support if error persists", + ], + "common_causes": [ + "Sensor obstruction", + "Hardware malfunction", + "Sensor damaged", + ], + }, + 78: { + "troubleshooting": [ + "Clean ultrasonic sensors", + "Check for damage to sensor covers", + "Restart vacuum", + ], + "common_causes": [ + "Dirty ultrasonic sensors", + "Sensor obstruction", + "Hardware malfunction", + ], + }, + 81: { + "troubleshooting": [ + "Check dirty water tank seal", + "Ensure tank is properly seated", + "Inspect tank for cracks or damage", + "Replace tank if damaged", + ], + "common_causes": [ + "Tank seal damaged", + "Tank not properly seated", + "Cracked tank", + ], + }, + 101: { + "troubleshooting": [ + "Restart vacuum", + "Let battery fully discharge then recharge", + "Contact support if error persists", + ], + "common_causes": [ + "Battery communication error", + "Battery degradation", + "Temperature extreme", + ], + }, + 102: { + "troubleshooting": [ + "Check wheels for obstructions", + "Clean wheel axles", + "Restart vacuum", + ], + "common_causes": [ + "Wheel motor issue", + "Debris in wheel mechanism", + "Wheel sensor malfunction", + ], + }, + 103: { + "troubleshooting": [ + "Remove and clean side brush", + "Check side brush motor area for debris", + "Replace side brush if worn", + ], + "common_causes": [ + "Side brush motor issue", + "Debris blocking brush", + "Worn side brush", + ], + }, + 104: { + "troubleshooting": [ + "Clean dust collector and filter", + "Check for blockages in airway", + "Restart vacuum", + ], + "common_causes": [ + "Clogged filter restricting airflow", + "Fan motor issue", + "Debris in fan housing", + ], + }, + 105: { + "troubleshooting": [ + "Remove and clean roller brush", + "Check brush bearings", + "Remove hair and debris from brush area", + ], + "common_causes": [ + "Roller brush motor strain", + "Excessive debris buildup", + "Worn brush bearings", + ], + }, + 106: { + "troubleshooting": [ + "Check water tank is properly seated", + "Ensure water lines are not kinked", + "Restart vacuum", + ], + "common_causes": [ + "Water pump malfunction", + "Air in water lines", + "Blocked water pathway", + ], + }, + 107: { + "troubleshooting": [ + "Clean laser sensor cover", + "Check laser turret rotates freely", + "Remove any obstructions from turret", + ], + "common_causes": [ + "Laser sensor malfunction", + "Turret motor issue", + "Sensor damage", + ], + }, + 111: { + "troubleshooting": [ + "Check mop pad attachment area", + "Remove debris from rotation mechanism", + "Restart vacuum", + ], + "common_causes": [ + "Rotation motor malfunction", + "Debris blocking rotation", + "Motor strain from obstruction", + ], + }, + 112: { + "troubleshooting": [ + "Check mop lifting mechanism for obstructions", + "Remove debris from lift area", + "Restart vacuum", + ], + "common_causes": [ + "Lift motor malfunction", + "Debris blocking lift mechanism", + "Motor strain", + ], + }, + 113: { + "troubleshooting": [ + "Check water spray nozzles for blockages", + "Ensure water tank has water", + "Restart vacuum", + ], + "common_causes": [ + "Spray nozzle clogged", + "Water pump issue", + "Empty water tank", + ], + }, + 114: { + "troubleshooting": [ + "Check water pumping mechanism", + "Ensure water tank is properly seated", + "Restart vacuum", + ], + "common_causes": [ + "Pump malfunction", + "Blocked water lines", + "Air in system", + ], + }, + 117: { + "troubleshooting": [ + "Clean ultrasonic sensors", + "Check for debris or damage", + "Restart vacuum", + ], + "common_causes": [ + "Sensor obstruction", + "Sensor damage", + "Hardware malfunction", + ], + }, + 119: { + "troubleshooting": [ + "Restart vacuum", + "Check WiFi signal strength", + "Re-pair vacuum with app if needed", + "Restart router", + ], + "common_causes": [ + "WiFi connectivity issue", + "Bluetooth interference", + "Communication module fault", + ], + }, } diff --git a/custom_components/robovac/robovac.py b/custom_components/robovac/robovac.py index 42c32ae5..f59152bf 100644 --- a/custom_components/robovac/robovac.py +++ b/custom_components/robovac/robovac.py @@ -252,7 +252,20 @@ def getRoboVacHumanReadableValue(self, command_name: RobovacCommand, value: str) if result is not None: return str(result) - # Only log if values dict exists but value not found + # For STATUS commands, try pattern matching if exact lookup failed + if cmd == RobovacCommand.STATUS: + pattern_result = self._match_status_pattern(value) + if pattern_result is not None: + return pattern_result + + # For ERROR commands, try pattern matching if exact lookup failed + if cmd == RobovacCommand.ERROR: + pattern_result = self._match_error_pattern(value) + if pattern_result is not None: + return pattern_result + + # Only log if values dict exists but value not found + if values is not None: _LOGGER.debug( "Command %s with value %r (type: %s) not found for model %s. " "Available keys: %r", @@ -267,3 +280,52 @@ def getRoboVacHumanReadableValue(self, command_name: RobovacCommand, value: str) pass return value + + def _match_status_pattern(self, value: str) -> str | None: + """Match a STATUS value against defined patterns. + + Some STATUS codes contain dynamic content like timestamps that change + with each update. This method matches such codes using prefix/suffix patterns. + + Args: + value: The base64-encoded status value from the device. + + Returns: + The human-readable status if a pattern matches, None otherwise. + """ + status_patterns: list[tuple[str, str, str]] | None = getattr( + self.model_details, 'status_patterns', None + ) + if not status_patterns: + return None + + for prefix, suffix, status_name in status_patterns: + if value.startswith(prefix) and value.endswith(suffix): + return status_name + + return None + + def _match_error_pattern(self, value: str) -> str | None: + """Match an ERROR value against defined patterns. + + Some devices send status-like protobuf messages on the ERROR DPS code. + This method matches such codes using prefix/suffix patterns to prevent + them from being incorrectly treated as errors. + + Args: + value: The base64-encoded error value from the device. + + Returns: + The mapped value if a pattern matches (e.g., "no_error"), None otherwise. + """ + error_patterns: list[tuple[str, str, str]] | None = getattr( + self.model_details, 'error_patterns', None + ) + if not error_patterns: + return None + + for prefix, suffix, error_name in error_patterns: + if value.startswith(prefix) and value.endswith(suffix): + return error_name + + return None diff --git a/custom_components/robovac/sensor.py b/custom_components/robovac/sensor.py index ea14e22c..62d28f65 100644 --- a/custom_components/robovac/sensor.py +++ b/custom_components/robovac/sensor.py @@ -9,7 +9,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from .const import CONF_VACS, DOMAIN, REFRESH_RATE -from .vacuums.base import TuyaCodes _LOGGER = logging.getLogger(__name__) @@ -63,7 +62,9 @@ async def async_update(self) -> None: try: vacuum_entity = self.hass.data[DOMAIN][CONF_VACS].get(self.robovac_id) if vacuum_entity and vacuum_entity.tuyastatus: - self._attr_native_value = vacuum_entity.tuyastatus.get(TuyaCodes.BATTERY_LEVEL) + # Use model-specific battery DPS code instead of hardcoded TuyaCodes.BATTERY_LEVEL + battery_code = vacuum_entity._get_dps_code("BATTERY_LEVEL") + self._attr_native_value = vacuum_entity.tuyastatus.get(battery_code) self._attr_available = True else: _LOGGER.debug("Vacuum entity or status not available for %s", self.robovac_id) diff --git a/custom_components/robovac/vacuum.py b/custom_components/robovac/vacuum.py index b5e1487c..fcc99b89 100644 --- a/custom_components/robovac/vacuum.py +++ b/custom_components/robovac/vacuum.py @@ -749,9 +749,26 @@ async def async_start(self, **kwargs: Any) -> None: _LOGGER.error("Cannot start vacuum: vacuum not initialized") return - await self.vacuum.async_set({ - self._get_dps_code("MODE"): self.vacuum.getRoboVacCommandValue(RobovacCommand.MODE, "auto") - }) + mode_code = self._get_dps_code("MODE") + + # Build command payload + payload = { + mode_code: self.vacuum.getRoboVacCommandValue(RobovacCommand.MODE, "auto") + } + + # Some models use a separate START_PAUSE DPS code to trigger cleaning + # MODE sets the cleaning mode, START_PAUSE triggers the action + # Only add START_PAUSE if the model explicitly defines it with a different code + model_dps_codes = self.vacuum.getDpsCodes() + if "START_PAUSE" in model_dps_codes: + start_pause_code = model_dps_codes["START_PAUSE"] + if start_pause_code != mode_code: + start_value = self.vacuum.getRoboVacCommandValue(RobovacCommand.START_PAUSE, "start") + if start_value != "start": + # Model has a mapped "start" value for START_PAUSE, include it + payload[start_pause_code] = start_value + + await self.vacuum.async_set(payload) async def async_pause(self, **kwargs: Any) -> None: """Pause the vacuum cleaner. @@ -773,7 +790,18 @@ async def async_stop(self, **kwargs: Any) -> None: Args: **kwargs: Additional arguments passed from Home Assistant. """ - await self.async_return_to_base() + if self.vacuum is None: + _LOGGER.error("Cannot stop vacuum: vacuum not initialized") + return + + # Use STOP command if model supports it, otherwise fall back to return_to_base + stop_code = self._get_dps_code("STOP") + if stop_code: + await self.vacuum.async_set({ + stop_code: self.vacuum.getRoboVacCommandValue(RobovacCommand.STOP, "stop") + }) + else: + await self.async_return_to_base() async def async_clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean. diff --git a/custom_components/robovac/vacuums/T2267.py b/custom_components/robovac/vacuums/T2267.py index 072130e8..0585f203 100644 --- a/custom_components/robovac/vacuums/T2267.py +++ b/custom_components/robovac/vacuums/T2267.py @@ -1,11 +1,12 @@ """RoboVac L60 (T2267)""" -from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.vacuum import (VacuumEntityFeature, VacuumActivity) from .base import RoboVacEntityFeature, RobovacCommand, RobovacModelDetails class T2267(RobovacModelDetails): homeassistant_features = ( - VacuumEntityFeature.FAN_SPEED + VacuumEntityFeature.CLEAN_SPOT + | VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.LOCATE | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME @@ -24,30 +25,40 @@ class T2267(RobovacModelDetails): "values": { "auto": "BBoCCAE=", "pause": "AggN", - "Spot": "AA==", + "spot": "AggD", "return": "AggG", - "Nosweep": "AggO", + "nosweep": "AggO", }, }, RobovacCommand.STATUS: { "code": 153, - "values": [ - "BgoAEAUyAA===", - "BgoAEAVSAA===", - "CAoAEAUyAggB", - "CAoCCAEQBTIA", - "CAoCCAEQBVIA", - "CgoCCAEQBTICCAE=", - "CAoCCAIQBTIA", - "CAoCCAIQBVIA", - "CgoCCAIQBTICCAE=", - "BAoAEAY=", - "BBAHQgA=", - "BBADGgA=", - "BhADGgIIAQ==", - "AA==", - "AhAB", - ], + "values": { + # Cleaning states + "BgoAEAUyAA==": "Cleaning", + "BgoAEAVSAA==": "Positioning", + # Paused states + "CAoAEAUyAggB": "Paused", + "AggB": "Paused", + # Room cleaning states + "CAoCCAEQBTIA": "Room Cleaning", + "CAoCCAEQBVIA": "Room Positioning", + "CgoCCAEQBTICCAE=": "Room Paused", + # Zone cleaning states + "CAoCCAIQBTIA": "Zone Cleaning", + "CAoCCAIQBVIA": "Zone Positioning", + "CgoCCAIQBTICCAE=": "Zone Paused", + # Navigation states + "BAoAEAY=": "Remote Control", + "BBAHQgA=": "Heading Home", + # Charging/docked states + "BBADGgA=": "Charging", + "BhADGgIIAQ==": "Completed", + # Idle states + "AA==": "Standby", + "AhAB": "Sleeping", + # Error states + "BQgNEIsB": "Off Ground", + }, }, RobovacCommand.DIRECTION: { "code": 155, @@ -60,7 +71,22 @@ class T2267(RobovacModelDetails): }, }, RobovacCommand.START_PAUSE: { - "code": 156, + # Pause is sent via MODE command (code 152) with protobuf-encoded value + # "AggN" encodes ModeCtrlRequest.Method.PAUSE_TASK (13) + # "AggO" encodes ModeCtrlRequest.Method.RESUME_TASK (14) + "code": 152, + "values": { + "pause": "AggN", + "resume": "AggO", + }, + }, + RobovacCommand.STOP: { + # Stop is sent via MODE command (code 152) with protobuf-encoded value + # "AggM" encodes ModeCtrlRequest.Method.STOP_TASK (12) + "code": 152, + "values": { + "stop": "AggM", + }, }, RobovacCommand.DO_NOT_DISTURB: { "code": 157, @@ -88,9 +114,54 @@ class T2267(RobovacModelDetails): "code": 168, }, RobovacCommand.RETURN_HOME: { - "code": 173, + # Return home is sent via MODE command (code 152) with protobuf-encoded value + # "AggG" encodes ModeCtrlRequest.Method.START_GOHOME (6) + "code": 152, + "values": { + "return": "AggG", + }, }, RobovacCommand.ERROR: { "code": 177, } } + + activity_mapping = { + # Cleaning states + "Cleaning": VacuumActivity.CLEANING, + "Positioning": VacuumActivity.CLEANING, + "Room Cleaning": VacuumActivity.CLEANING, + "Room Positioning": VacuumActivity.CLEANING, + "Zone Cleaning": VacuumActivity.CLEANING, + "Zone Positioning": VacuumActivity.CLEANING, + "Remote Control": VacuumActivity.CLEANING, + # Paused states + "Paused": VacuumActivity.PAUSED, + "Room Paused": VacuumActivity.PAUSED, + "Zone Paused": VacuumActivity.PAUSED, + # Returning states + "Heading Home": VacuumActivity.RETURNING, + # Docked states + "Charging": VacuumActivity.DOCKED, + "Completed": VacuumActivity.DOCKED, + # Idle states + "Standby": VacuumActivity.IDLE, + "Sleeping": VacuumActivity.IDLE, + # Error states + "Off Ground": VacuumActivity.ERROR, + } + + # Patterns for STATUS codes with dynamic content (prefix, suffix, status_name) + # These match base64-encoded protobuf messages with embedded timestamps + status_patterns = [ + # Positioning codes: start with "DA" (0c08), end with "FSAA==" (5200) + # The middle bytes contain a timestamp that changes with each update + ("DA", "FSAA==", "Positioning"), + ] + + # Patterns for ERROR codes - some devices send status messages on the ERROR DPS + # These patterns map such messages to "no_error" to prevent false error states + error_patterns = [ + # Positioning/relocating status sent on ERROR DPS - not an actual error + ("DA", "FSAA==", "no_error"), + ] diff --git a/custom_components/robovac/vacuums/T2320.py b/custom_components/robovac/vacuums/T2320.py index df5c6e71..1871b4ef 100644 --- a/custom_components/robovac/vacuums/T2320.py +++ b/custom_components/robovac/vacuums/T2320.py @@ -1,6 +1,8 @@ """Eufy Robot Vacuum and Mop X9 Pro with Auto-Clean Station (T2320)""" -from homeassistant.components.vacuum import VacuumEntityFeature -from .base import RoboVacEntityFeature, RobovacCommand, RobovacModelDetails + +from homeassistant.components.vacuum import VacuumActivity, VacuumEntityFeature + +from .base import RobovacCommand, RoboVacEntityFeature, RobovacModelDetails class T2320(RobovacModelDetails): @@ -17,48 +19,110 @@ class T2320(RobovacModelDetails): robovac_features = ( RoboVacEntityFeature.DO_NOT_DISTURB | RoboVacEntityFeature.BOOST_IQ + | RoboVacEntityFeature.CLEANING_TIME + | RoboVacEntityFeature.CLEANING_AREA + | RoboVacEntityFeature.MAP ) commands = { RobovacCommand.START_PAUSE: { - "code": 2, + "code": 152, # Same as MODE, uses protobuf-encoded values like T2267 "values": { - "start": True, - "pause": False + "pause": "AggN", # Protobuf: ModeCtrlRequest.Method.PAUSE_TASK + "resume": "AggO", # Protobuf: ModeCtrlRequest.Method.RESUME_TASK }, }, RobovacCommand.MODE: { "code": 152, "values": { - "auto": "auto", - "return": "return", - "pause": "pause", + "auto": "BBoCCAE=", # Protobuf: ModeCtrlRequest.Method.START_AUTO_CLEAN + "pause": "AggN", # Protobuf: ModeCtrlRequest.Method.PAUSE_TASK + "return": "AggG", # Protobuf: ModeCtrlRequest.Method.START_GOHOME "small_room": "small_room", - "single_room": "single_room" + "single_room": "single_room", }, }, RobovacCommand.STATUS: { - "code": 173, + "code": 177, + "values": { + # Protobuf-encoded status values (similar to T2080/T2267) + # Cleaning states + "BgoAEAUyAA==": "Auto Cleaning", + "BgoAEAVSAA==": "Positioning", + "CgoAEAUyAhABUgA=": "Auto Cleaning", + "CgoAEAkaAggBMgA=": "Auto Cleaning", + # Room cleaning states + "CAoCCAEQBTIA": "Room Cleaning", + "CAoCCAEQBVIA": "Room Positioning", + "CgoCCAEQBTICCAE=": "Room Paused", + "DAoCCAEQBTICEAFSAA==": "Room Positioning", + # Zone cleaning states + "CAoCCAIQBTIA": "Zone Cleaning", + "CAoCCAIQBVIA": "Zone Positioning", + "CgoCCAIQBTICCAE=": "Zone Paused", + # Paused states + "CAoAEAUyAggB": "Paused", + "AggB": "Paused", + # Navigation states + "BBAHQgA=": "Heading Home", + "AgoA": "Heading Home", + "CgoAEAcyAggBQgA=": "Temporary Return", + "DAoCCAEQBzICCAFCAA==": "Temporary Return", + # Charging/docked states + "BBADGgA=": "Charging", + "BhADGgIIAQ==": "Completed", + "DAoCCAEQAxoAMgIIAQ==": "Charge Mid-Clean", + # Idle states + "AA==": "Standby", + "BhAHQgBSAA==": "Standby", + "AhAB": "Sleeping", + # Station states (X9 Pro has auto-clean station) + "DAoAEAUaADICEAFSAA==": "Adding Water", + "DAoCCAEQCRoCCAEyAA==": "Adding Water", + "DgoAEAkaAggBMgA6AhAB": "Adding Water", + "BhAJOgIQAg==": "Drying Mop", + "CBAJGgA6AhAC": "Drying Mop", + "ChAJGgIIAToCEAI=": "Drying Mop", + "DgoAEAUaAggBMgIQAVIA": "Washing Mop", + "EAoCCAEQCRoCCAEyADoCEAE=": "Washing Mop", + "BhAJOgIQAQ==": "Washing Mop", + "AhAJ": "Removing Dirty Water", + "BRAJ+gEA": "Emptying Dust", + "DQoCCAEQCTICCAH6AQA=": "Remove Dust Mid-Clean", + # Manual control + "BhAGGgIIAQ==": "Manual Control", + "BAoAEAY=": "Remote Control", + # Error state + "CAoAEAIyAggB": "Error", + }, }, RobovacCommand.RETURN_HOME: { - "code": 153, + # Return home is sent via MODE command (code 152) with protobuf-encoded value + # "AggG" encodes ModeCtrlRequest.Method.START_GOHOME (6) + "code": 152, "values": { - "return_home": True - } + "return": "AggG", + }, + }, + RobovacCommand.STOP: { + # Stop is sent via MODE command (code 152) with protobuf-encoded value + # "AggM" encodes ModeCtrlRequest.Method.STOP_TASK (12) + "code": 152, + "values": { + "stop": "AggM", + }, }, RobovacCommand.FAN_SPEED: { "code": 154, "values": { - "Standard": "standard", - "Boost IQ": "boost_iq", - "Max": "max", - "Quiet": "Quiet", + "quiet": "Quiet", + "standard": "Standard", + "turbo": "Turbo", + "max": "Max", + "boost_iq": "Boost_IQ", }, }, RobovacCommand.LOCATE: { - "code": 153, - "values": { - "locate": True - } + "code": 160, }, RobovacCommand.BATTERY: { "code": 172, @@ -66,4 +130,67 @@ class T2320(RobovacModelDetails): RobovacCommand.ERROR: { "code": 169, }, + RobovacCommand.BOOST_IQ: { + "code": 159, + }, + RobovacCommand.CLEANING_TIME: { + "code": 6, + }, + RobovacCommand.CLEANING_AREA: { + "code": 7, + }, + RobovacCommand.ERROR: { + "code": 177, + }, + } + + activity_mapping = { + # Cleaning states + "Auto Cleaning": VacuumActivity.CLEANING, + "Positioning": VacuumActivity.CLEANING, + "Room Cleaning": VacuumActivity.CLEANING, + "Room Positioning": VacuumActivity.CLEANING, + "Zone Cleaning": VacuumActivity.CLEANING, + "Zone Positioning": VacuumActivity.CLEANING, + "Manual Control": VacuumActivity.CLEANING, + "Remote Control": VacuumActivity.CLEANING, + # Paused states + "Paused": VacuumActivity.PAUSED, + "Room Paused": VacuumActivity.PAUSED, + "Zone Paused": VacuumActivity.PAUSED, + # Returning states + "Heading Home": VacuumActivity.RETURNING, + "Temporary Return": VacuumActivity.RETURNING, + # Docked states + "Charging": VacuumActivity.DOCKED, + "Completed": VacuumActivity.DOCKED, + "Charge Mid-Clean": VacuumActivity.DOCKED, + "Adding Water": VacuumActivity.DOCKED, + "Drying Mop": VacuumActivity.DOCKED, + "Washing Mop": VacuumActivity.DOCKED, + "Removing Dirty Water": VacuumActivity.DOCKED, + "Emptying Dust": VacuumActivity.DOCKED, + "Remove Dust Mid-Clean": VacuumActivity.DOCKED, + # Idle states + "Standby": VacuumActivity.IDLE, + "Sleeping": VacuumActivity.IDLE, + # Error state + "Error": VacuumActivity.ERROR, } + + # Patterns for STATUS codes with dynamic content (prefix, suffix, status_name) + # These match base64-encoded protobuf messages with embedded timestamps + status_patterns = [ + # Positioning codes: start with "DA" (0c08), end with "FSAA==" (5200) + # The middle bytes contain a timestamp that changes with each update + ("DA", "FSAA==", "Positioning"), + ("Dw", "BSAA==", "Washing Mop"), + ] + + # Patterns for ERROR codes - some devices send status messages on the ERROR DPS + # These patterns map such messages to "no_error" to prevent false error states + error_patterns = [ + # Positioning/relocating status sent on ERROR DPS - not an actual error + ("DA", "FSAA==", "no_error"), + ("Dw", "BSAA==", "no_error"), + ] diff --git a/custom_components/robovac/vacuums/base.py b/custom_components/robovac/vacuums/base.py index d30e80a3..129cb8a2 100644 --- a/custom_components/robovac/vacuums/base.py +++ b/custom_components/robovac/vacuums/base.py @@ -21,6 +21,7 @@ class RoboVacEntityFeature(IntEnum): class RobovacCommand(StrEnum): START_PAUSE = "start_pause" + STOP = "stop" DIRECTION = "direction" MODE = "mode" STATUS = "status" @@ -70,3 +71,7 @@ class RobovacModelDetails(Protocol): commands: Dict[RobovacCommand, Any] dps_codes: Dict[str, str] = {} # Optional model-specific DPS codes activity_mapping: Dict[str, VacuumActivity] | None = None + # Optional patterns for STATUS codes with dynamic content (e.g., timestamps) + # List of tuples: (prefix, suffix, human_readable_status) + # Example: [("DA", "FSAA==", "Positioning")] matches any base64 starting with DA, ending with FSAA== + status_patterns: List[tuple[str, str, str]] | None = None diff --git a/docs/T2267_CONFIGURATION_ANALYSIS.md b/docs/T2267_CONFIGURATION_ANALYSIS.md new file mode 100644 index 00000000..333a3c89 --- /dev/null +++ b/docs/T2267_CONFIGURATION_ANALYSIS.md @@ -0,0 +1,297 @@ +# T2267 (RoboVac L60) Configuration Analysis + +This document reviews the T2267 vacuum configuration and identifies missing or incorrect features. + +## Current Features + +### Home Assistant Features (`VacuumEntityFeature`) + +| Feature | Status | Notes | +| ------- | ------ | ----- | +| CLEAN_SPOT | ✓ | | +| FAN_SPEED | ✓ | | +| LOCATE | ✓ | | +| PAUSE | ✓ | Fixed - uses code 152 with "AggN" | +| RETURN_HOME | ✓ | Fixed - uses code 152 with "AggG" | +| SEND_COMMAND | ✓ | | +| START | ✓ | | +| STATE | ✓ | | +| STOP | ✓ | Fixed - uses code 152 with "AggM" | +| BATTERY | ✓ Via Sensor | Command exists (code 163), exposed via dedicated sensor entity (HA 2025.8+ compliant) | + +### RoboVac Features (`RoboVacEntityFeature`) + +| Feature | Status | Notes | +| ------- | ------ | ----- | +| DO_NOT_DISTURB | ✓ | Code 157 | +| BOOST_IQ | ✓ | Code 159 | +| CLEANING_TIME | ❌ Missing | T2278 has this | +| CLEANING_AREA | ❌ Missing | T2278 has this | +| ROOM | ❌ Missing | Device supports room cleaning (status values exist) | +| ZONE | ❌ Missing | Device supports zone cleaning (status values exist) | +| MAP | ❌ Missing | | +| CONSUMABLES | ⚠️ Partial | Command exists (code 168) but feature flag not set | + +## Current Commands + +| Command | DPS Code | Values | Status | +| ------- | -------- | ------ | ------ | +| MODE | 152 | auto, pause, spot, return, nosweep | ⚠️ Issues | +| STATUS | 153 | Multiple protobuf-encoded values | ✓ | +| DIRECTION | 155 | brake, forward, back, left, right | ✓ | +| START_PAUSE | 152 | pause, resume | ✓ Fixed | +| STOP | 152 | stop | ✓ Fixed | +| DO_NOT_DISTURB | 157 | - | ✓ | +| FAN_SPEED | 158 | quiet, standard, turbo, max, boost_iq | ✓ | +| BOOST_IQ | 159 | - | ✓ | +| LOCATE | 160 | - | ⚠️ Missing value | +| BATTERY | 163 | - | ✓ | +| CONSUMABLES | 168 | - | ✓ | +| RETURN_HOME | 152 | return | ✓ Fixed | +| ERROR | 177 | - | ✓ | + +## Issues Found + +### 1. MODE "spot" value is incorrect + +**Current:** + +```python +"spot": "AA==" # WRONG - AA== decodes to standby/empty +``` + +**Should be:** + +```python +"spot": "AggD" # Correct - encodes START_SPOT_CLEAN (method=3) +``` + +The value "AA==" (base64) decodes to a zero byte, which represents standby, not spot cleaning. The correct encoding for `ModeCtrlRequest.Method.START_SPOT_CLEAN` (value 3) is "AggD". + +### 2. MODE "nosweep" is misleading + +**Current:** + +```python +"nosweep": "AggO" # Encodes RESUME_TASK (14), not a cleaning mode +``` + +The value "AggO" encodes `ModeCtrlRequest.Method.RESUME_TASK` (value 14), which resumes a paused task. This should either be: + +- Renamed to "resume" for clarity, or +- Replaced with proper mop-only mode encoding if that's the intent + +### 3. LOCATE missing value + +**T2278 has:** + +```python +RobovacCommand.LOCATE: { + "code": 160, + "values": {"locate": "true"}, +} +``` + +**T2267 has:** + +```python +RobovacCommand.LOCATE: { + "code": 160, +} +``` + +The LOCATE command should have a value defined. + +### 4. FAN_SPEED missing MAX_PLUS + +Protobuf defines these fan suction levels: + +- QUIET = 0 +- STANDARD = 1 +- TURBO = 2 +- MAX = 3 +- MAX_PLUS = 4 + +T2267 is missing MAX_PLUS if the device hardware supports it. + +## Missing Commands + +| Command | Expected Code | Notes | +| ------- | ------------- | ----- | +| CLEANING_TIME | 6 | For tracking cleaning duration | +| CLEANING_AREA | 7 | For tracking cleaning area (m²) | + +## Protobuf Methods Reference + +From `control.proto` - `ModeCtrlRequest.Method`: + +| Method | Value | Base64 Encoding | T2267 Status | +| ------ | ----- | --------------- | ------------ | +| START_AUTO_CLEAN | 0 | BBoCCAE= | ✓ ("auto") | +| START_SELECT_ROOMS_CLEAN | 1 | Complex* | ❌ Not implemented | +| START_SELECT_ZONES_CLEAN | 2 | Complex* | ❌ Not implemented | +| START_SPOT_CLEAN | 3 | AggD | ❌ Wrong value (uses AA==) | +| START_GOTO_CLEAN | 4 | AggE | ❌ Not implemented | +| START_RC_CLEAN | 5 | AggF | ❌ Not implemented | +| START_GOHOME | 6 | AggG | ✓ ("return") | +| START_SCHEDULE_AUTO_CLEAN | 7 | AggH | ❌ Not implemented | +| START_SCHEDULE_ROOMS_CLEAN | 8 | AggI | ❌ Not implemented | +| START_FAST_MAPPING | 9 | AggJ | ❌ Not implemented | +| START_GOWASH | 10 | AggK | ❌ Not implemented | +| STOP_TASK | 12 | AggM | ✓ (STOP command) | +| PAUSE_TASK | 13 | AggN | ✓ ("pause") | +| RESUME_TASK | 14 | AggO | ✓ (labeled "nosweep") | +| STOP_GOHOME | 15 | AggP | ❌ Not implemented | +| STOP_RC_CLEAN | 16 | AggQ | ❌ Not implemented | + +*Complex methods require additional parameters (room IDs, zone coordinates, etc.) + +## Base64 Encoding Pattern + +The simple method-only commands follow this pattern: + +- 2 bytes length prefix + field 1 (method) as varint +- Example: Method 13 (PAUSE_TASK) = `02 08 0D` = "AggN" + +| Method Value | Hex Bytes | Base64 | +| ------------ | --------- | ------ | +| 0 | 02 08 00 | AggA | +| 3 | 02 08 03 | AggD | +| 6 | 02 08 06 | AggG | +| 12 | 02 08 0C | AggM | +| 13 | 02 08 0D | AggN | +| 14 | 02 08 0E | AggO | + +## Recommended Fixes + +### Priority 1 - Critical Fixes + +1. **Fix spot mode encoding** + + ```python + "spot": "AggD" # START_SPOT_CLEAN (method=3) + ``` + +2. **Rename "nosweep" to "resume"** (or implement proper mop-only mode) + + ```python + "resume": "AggO" # RESUME_TASK (method=14) + ``` + +### Priority 2 - Feature Additions + +1. **~~Add BATTERY to homeassistant_features~~** ✅ COMPLETED (2025-01) + + `VacuumEntityFeature.BATTERY` was deprecated in Home Assistant 2025.8 and will stop working in 2026.8. Instead, the integration now uses a dedicated battery sensor entity with `SensorDeviceClass.BATTERY`. + + **Changes made:** + - `sensor.py`: Updated to use model-specific DPS codes via `vacuum_entity._get_dps_code("BATTERY_LEVEL")` instead of hardcoded `TuyaCodes.BATTERY_LEVEL` + - T2267 correctly defines `RobovacCommand.BATTERY` with code `163` + - Battery level is now exposed as a separate sensor entity linked to the vacuum device + + See: [Vacuum battery properties are deprecated](https://developers.home-assistant.io/blog/2025/07/02/vacuum-battery-properties-deprecated/) + +2. **Add LOCATE value** + + ```python + RobovacCommand.LOCATE: { + "code": 160, + "values": {"locate": True}, + }, + ``` + +### Priority 3 - Enhanced Features + +1. **Add CLEANING_TIME and CLEANING_AREA** + + ```python + robovac_features = ( + ... + | RoboVacEntityFeature.CLEANING_TIME + | RoboVacEntityFeature.CLEANING_AREA + ) + + # Commands + RobovacCommand.CLEANING_TIME: {"code": 6}, + RobovacCommand.CLEANING_AREA: {"code": 7}, + ``` + +2. **Add ROOM and ZONE features** if device supports room/zone cleaning + + ```python + robovac_features = ( + ... + | RoboVacEntityFeature.ROOM + | RoboVacEntityFeature.ZONE + ) + ``` + +## Status Values Reference + +T2267 currently supports these status values: + +| Base64 Code | Human Readable | Activity | +| ----------- | -------------- | -------- | +| BgoAEAUyAA== | Cleaning | CLEANING | +| BgoAEAVSAA== | Positioning | CLEANING | +| CAoAEAUyAggB | Paused | PAUSED | +| AggB | Paused | PAUSED | +| CAoCCAEQBTIA | Room Cleaning | CLEANING | +| CAoCCAEQBVIA | Room Positioning | CLEANING | +| CgoCCAEQBTICCAE= | Room Paused | PAUSED | +| CAoCCAIQBTIA | Zone Cleaning | CLEANING | +| CAoCCAIQBVIA | Zone Positioning | CLEANING | +| CgoCCAIQBTICCAE= | Zone Paused | PAUSED | +| BAoAEAY= | Remote Control | CLEANING | +| BBAHQgA= | Heading Home | RETURNING | +| BBADGgA= | Charging | DOCKED | +| BhADGgIIAQ== | Completed | DOCKED | +| AA== | Standby | IDLE | +| AhAB | Sleeping | IDLE | +| BQgNEIsB | Off Ground | ERROR | + +## Comparison with Similar Models + +### T2278 (L60 Hybrid SES) has additional + +- CLEANING_TIME feature +- CLEANING_AREA feature +- AUTO_RETURN feature +- ROOM feature +- ZONE feature +- MAP feature +- STOP command in MODE values + +### T2080 (S1 Pro) has additional + +- Station-related status values (Adding Water, Drying Mop, Washing Mop, etc.) +- MOP_LEVEL command +- More comprehensive error handling + +## Additional Features + +### Status Patterns + +T2267 implements `status_patterns` for matching dynamic protobuf-encoded status values: + +```python +status_patterns = [ + ("DA", "FSAA==", "Positioning"), # Matches positioning codes with embedded timestamps +] +``` + +### Error Patterns + +T2267 implements `error_patterns` to filter false error states: + +```python +error_patterns = [ + ("DA", "FSAA==", "no_error"), # Positioning status sent on ERROR DPS +] +``` + +## Notes + +- Battery level is exposed via a dedicated sensor entity (Home Assistant 2025.8+ pattern) using model-specific DPS code 163 +- T2267 uses protobuf-encoded values for MODE commands +- Both T2267 and T2320 now use protobuf encoding for control commands diff --git a/docs/T2320_CONFIGURATION_ANALYSIS.md b/docs/T2320_CONFIGURATION_ANALYSIS.md new file mode 100644 index 00000000..49864a5c --- /dev/null +++ b/docs/T2320_CONFIGURATION_ANALYSIS.md @@ -0,0 +1,407 @@ +# T2320 (Eufy X9 Pro with Auto-Clean Station) Configuration Analysis + +This document reviews the T2320 vacuum configuration and identifies missing or incorrect features. + +## Device Overview + +The T2320 is the Eufy Robot Vacuum and Mop X9 Pro with Auto-Clean Station. It features: + +- Auto-clean station with mop washing and drying +- Dust collection +- Water tank management (clean and dirty water) +- Advanced navigation with LiDAR, camera, and 3D TOF sensors + +## Current Features + +### Home Assistant Features (`VacuumEntityFeature`) + +| Feature | Status | Notes | +| ------- | ------ | ----- | +| FAN_SPEED | ✓ | Code 154 | +| LOCATE | ✓ | Code 160 (no value defined) | +| PAUSE | ✓ | Via MODE code 152 with protobuf | +| RETURN_HOME | ✓ | Via MODE code 152 with protobuf "AggG" | +| SEND_COMMAND | ✓ | | +| START | ✓ | Via MODE code 152 with protobuf | +| STATE | ✓ | Code 177 with protobuf values | +| STOP | ✓ | Via MODE code 152 with protobuf "AggM" | +| BATTERY | ✓ Via Sensor | Command exists (code 172), exposed via dedicated sensor entity (HA 2025.8+ compliant) | +| CLEAN_SPOT | ❌ Missing | Device likely supports spot cleaning | + +### RoboVac Features (`RoboVacEntityFeature`) + +| Feature | Status | Notes | +| ------- | ------ | ----- | +| DO_NOT_DISTURB | ✓ | | +| BOOST_IQ | ✓ | Code 159 | +| CLEANING_TIME | ✓ | Code 6 | +| CLEANING_AREA | ✓ | Code 7 | +| MAP | ✓ | | +| ROOM | ❌ Missing | Device supports room cleaning (status values exist) | +| ZONE | ❌ Missing | Device supports zone cleaning (status values exist) | +| CONSUMABLES | ❌ Missing | X9 Pro has consumables tracking | +| AUTO_RETURN | ❌ Missing | | + +## Current Commands + +| Command | DPS Code | Values | Status | +| ------- | -------- | ------ | ------ | +| START_PAUSE | 152 | pause="AggN", resume="AggO" | ✓ Protobuf | +| MODE | 152 | auto="BBoCCAE=", pause="AggN", return="AggG" | ✓ Protobuf | +| STATUS | 177 | Multiple protobuf-encoded values | ✓ | +| RETURN_HOME | 152 | return="AggG" | ✓ Protobuf | +| STOP | 152 | stop="AggM" | ✓ Protobuf | +| FAN_SPEED | 154 | quiet, standard, turbo, max, boost_iq | ✓ | +| LOCATE | 160 | - | ⚠️ No value defined | +| BATTERY | 172 | - | ✓ Via sensor entity | +| ERROR | 177 | - | ✓ | +| BOOST_IQ | 159 | - | ✓ | +| CLEANING_TIME | 6 | - | ✓ | +| CLEANING_AREA | 7 | - | ✓ | + +## DPS Code Differences from Other Models + +T2320 uses a different DPS code scheme than T2267/T2278: + +| Command | T2267/T2278 | T2320 | Notes | +| ------- | ----------- | ----- | ----- | +| STATUS | 153 | 177 | Different code | +| FAN_SPEED | 158 | 154 | Different code | +| BATTERY | 163 | 172 | Different code | +| ERROR | 177 | 177 | Same code | +| RETURN_HOME | 152 (via MODE) | 152 (via MODE) | Same approach now | + +## Issues Found + +### 1. ~~MODE uses string values instead of protobuf~~ ✅ FIXED + +T2320 now uses protobuf-encoded values for MODE commands, matching T2267: + +```python +"values": { + "auto": "BBoCCAE=", # Protobuf: START_AUTO_CLEAN + "pause": "AggN", # Protobuf: PAUSE_TASK + "return": "AggG", # Protobuf: START_GOHOME + "small_room": "small_room", # Legacy string values remain + "single_room": "single_room", +} +``` + +### 2. ~~Missing STOP command~~ ✅ FIXED + +T2320 now has a dedicated STOP command with protobuf encoding: + +```python +RobovacCommand.STOP: { + "code": 152, + "values": { + "stop": "AggM", # Protobuf: STOP_TASK (12) + }, +}, +``` + +### 3. Missing MOP_LEVEL command + +The X9 Pro has mopping capabilities but no MOP_LEVEL command is defined. Based on T2080: + +```python +RobovacCommand.MOP_LEVEL: { + "code": 10, # verify correct code for T2320 + "values": { + "low": "low", + "middle": "middle", + "high": "high", + }, +}, +``` + +### 4. Missing DIRECTION command + +No remote control direction command is defined: + +```python +RobovacCommand.DIRECTION: { + "code": ???, # needs discovery + "values": { + "forward": "forward", + "back": "back", + "left": "left", + "right": "right", + "brake": "brake", + }, +}, +``` + +### 5. Missing CONSUMABLES command + +The X9 Pro tracks consumables but no command is defined: + +```python +RobovacCommand.CONSUMABLES: { + "code": ???, # needs discovery +}, +``` + +## Missing Features Compared to T2080 (S1 Pro) + +T2080 has these additional features that T2320 might support: + +| Feature | T2080 Code | T2320 Status | +| ------- | ---------- | ------------ | +| MOP_LEVEL | 10 | ❌ Not implemented | +| DIRECTION | 176 | ❌ Not implemented | + +## Error Codes Reference + +From `error_code_list_t2320.proto`, the X9 Pro supports these error categories: + +### Robot Errors (E001-E055) + +| Code | Description | +| ---- | ----------- | +| E001 | Crash buffer is stuck | +| E002 | Wheel is stuck | +| E003 | Side brush is stuck | +| E004 | Rolling brush is stuck | +| E005 | Host machine is trapped | +| E006 | Machine is trapped, move to start | +| E007 | Wheel is overhanging | +| E008 | Battery too low, shutting down | +| E013 | Host is tilted | +| E014 | Dust box/filter missing | +| E017 | Forbidden area detected | +| E018 | Laser protection cover stuck | +| E019 | Laser sensor stuck or tangled | +| E020 | Laser sensor blocked | +| E021 | Docking failed | +| E026 | Insufficient power for scheduled start | +| E031 | Foreign objects in suction port | +| E032 | Mop holder rotating motor stuck | +| E033 | Mop bracket lift motor stuck | +| E039 | Positioning failed | +| E040 | Mop cloth dislodged | +| E041 | Air drying heater abnormal | +| E050 | Accidentally on carpet | +| E051 | Camera blocked | +| E052 | Unable to leave station | +| E055 | Exploring station failed | + +### Station Errors (E070-E083) + +| Code | Description | +| ---- | ----------- | +| E070 | Clean dust collector and filter | +| E071 | Wall sensor abnormal | +| E072 | Robot water tank insufficient | +| E073 | Station dirty tank full | +| E074 | Station clean water insufficient | +| E075 | Water tank absent | +| E076 | Camera abnormal | +| E077 | 3D TOF abnormal | +| E078 | Ultrasonic sensor abnormal | +| E079 | Station clean tray not installed | +| E080 | Robot-station communication abnormal | +| E081 | Sewage tank leaking | +| E082 | Clean station tray | +| E083 | Poor charging contact | + +### Hardware Errors (E101-E119) + +| Code | Description | +| ---- | ----------- | +| E101 | Battery abnormal | +| E102 | Wheel module abnormal | +| E103 | Side brush module abnormal | +| E104 | Fan abnormal | +| E105 | Roller brush motor abnormal | +| E106 | Host pump abnormal | +| E107 | Laser sensor abnormal | +| E111 | Rotation motor abnormal | +| E112 | Lift motor abnormal | +| E113 | Water spraying device abnormal | +| E114 | Water pumping device abnormal | +| E117 | Ultrasonic sensor abnormal | +| E119 | WiFi or Bluetooth abnormal | + +## Prompt Codes Reference + +| Code | Description | +| ---- | ----------- | +| P001 | Start scheduled cleaning | +| P003 | Battery low, returning to base | +| P004 | Positioning failed, rebuilding map | +| P005 | Positioning failed, mission ended | +| P006 | Some areas unreachable | +| P007 | Path planning failed | +| P009 | Base station exploration failed | +| P010 | Positioning successful | +| P011 | Task finished, returning | +| P012 | Cannot start task while on station | +| P013 | Scheduled cleaning failed (busy) | +| P014 | Map updating, try later | +| P015 | Mop washing complete, resuming | +| P016 | Low battery, charge first | +| P017 | Mop cleaning completed | + +## Status Values Reference + +T2320 supports these status values: + +### Cleaning States + +| Base64 Code | Human Readable | +| ----------- | -------------- | +| BgoAEAUyAA== | Auto Cleaning | +| BgoAEAVSAA== | Positioning | +| CgoAEAUyAhABUgA= | Auto Cleaning | +| CgoAEAkaAggBMgA= | Auto Cleaning | +| CAoCCAEQBTIA | Room Cleaning | +| CAoCCAEQBVIA | Room Positioning | +| CgoCCAEQBTICCAE= | Room Paused | +| CAoCCAIQBTIA | Zone Cleaning | +| CAoCCAIQBVIA | Zone Positioning | +| CgoCCAIQBTICCAE= | Zone Paused | + +### Navigation States + +| Base64 Code | Human Readable | +| ----------- | -------------- | +| BBAHQgA= | Heading Home | +| AgoA | Heading Home | +| CgoAEAcyAggBQgA= | Temporary Return | +| DAoCCAEQBzICCAFCAA== | Temporary Return | + +### Docked/Station States + +| Base64 Code | Human Readable | +| ----------- | -------------- | +| BBADGgA= | Charging | +| BhADGgIIAQ== | Completed | +| DAoCCAEQAxoAMgIIAQ== | Charge Mid-Clean | +| DAoAEAUaADICEAFSAA== | Adding Water | +| BhAJOgIQAg== | Drying Mop | +| BhAJOgIQAQ== | Washing Mop | +| AhAJ | Removing Dirty Water | +| BRAJ+gEA | Emptying Dust | +| DQoCCAEQCTICCAH6AQA= | Remove Dust Mid-Clean | + +### Other States + +| Base64 Code | Human Readable | +| ----------- | -------------- | +| CAoAEAUyAggB | Paused | +| AggB | Paused | +| AA== | Standby | +| BhAHQgBSAA== | Standby | +| AhAB | Sleeping | +| BhAGGgIIAQ== | Manual Control | +| BAoAEAY= | Remote Control | +| CAoAEAIyAggB | Error | + +## Recommended Fixes + +### Priority 1 - Critical + +1. **~~Add BATTERY to homeassistant_features~~** ✅ COMPLETED (2025-01) + + `VacuumEntityFeature.BATTERY` was deprecated in Home Assistant 2025.8 and will stop working in 2026.8. Instead, the integration now uses a dedicated battery sensor entity with `SensorDeviceClass.BATTERY`. + + **Changes made:** + - `sensor.py`: Updated to use model-specific DPS codes via `vacuum_entity._get_dps_code("BATTERY_LEVEL")` instead of hardcoded `TuyaCodes.BATTERY_LEVEL` + - T2320 correctly defines `RobovacCommand.BATTERY` with code `172` + - Battery level is now exposed as a separate sensor entity linked to the vacuum device + + See: [Vacuum battery properties are deprecated](https://developers.home-assistant.io/blog/2025/07/02/vacuum-battery-properties-deprecated/) + +2. **Add CLEAN_SPOT feature and command** (if device supports it) + +### Priority 2 - Feature Additions + +1. **Add ROOM and ZONE features** + + ```python + robovac_features = ( + ... + | RoboVacEntityFeature.ROOM + | RoboVacEntityFeature.ZONE + ) + ``` + +2. **Add MOP_LEVEL command** (verify DPS code) + + ```python + RobovacCommand.MOP_LEVEL: { + "code": 10, + "values": { + "low": "low", + "middle": "middle", + "high": "high", + }, + }, + ``` + +3. **Add CONSUMABLES command and feature** + +### Priority 3 - Enhanced Features + +1. **Add DIRECTION command** for remote control + +2. **Add DO_NOT_DISTURB command with DPS code** + + Currently listed in features but no command defined with a code. + +## Comparison with Similar Models + +### T2080 (S1 Pro) Differences + +| Feature | T2080 | T2320 | +| ------- | ----- | ----- | +| STATUS code | 153 | 177 | +| FAN_SPEED code | 158 | 154 | +| BATTERY code | 163 | 172 | +| ERROR code | 106 | 177 | +| LOCATE code | 103 | 160 | +| MODE encoding | Protobuf | Protobuf | +| RETURN_HOME | Via MODE (152) | Via MODE (152) | + +### T2267 (L60) Differences + +| Feature | T2267 | T2320 | +| ------- | ----- | ----- | +| MODE encoding | Protobuf | Protobuf (now matching) | +| START_PAUSE | Via MODE (152) | Via MODE (152) | +| RETURN_HOME | Via MODE (152) | Via MODE (152) | +| Station features | No | Yes (washing, drying, dust) | + +## Additional Features + +### Status Patterns + +T2320 implements `status_patterns` for matching dynamic protobuf-encoded status values: + +```python +status_patterns = [ + ("DA", "FSAA==", "Positioning"), # Matches positioning codes with embedded timestamps + ("Dw", "BSAA==", "Washing Mop"), # Matches washing mop codes +] +``` + +### Error Patterns + +T2320 implements `error_patterns` to filter false error states: + +```python +error_patterns = [ + ("DA", "FSAA==", "no_error"), # Positioning status sent on ERROR DPS + ("Dw", "BSAA==", "no_error"), # Washing mop status sent on ERROR DPS +] +``` + +## Notes + +- T2320 now uses protobuf encoding for control commands (MODE, START_PAUSE, STOP, RETURN_HOME), matching T2267 +- The DPS code scheme differs from T2267/T2278 family for STATUS (177 vs 153), FAN_SPEED (154 vs 158), BATTERY (172 vs 163) +- Station-related status values are comprehensive for the auto-clean station features +- Error codes are specific to the X9 Pro model with station-related errors +- Battery level is exposed via a dedicated sensor entity (Home Assistant 2025.8+ pattern) using model-specific DPS code 172 +- **Note:** T2320.py has a duplicate ERROR command entry (codes 169 and 177) - the latter (177) takes effect diff --git a/tests/test_vacuum/test_dps_command_mapping.py b/tests/test_vacuum/test_dps_command_mapping.py index ce7b79f8..ad95abe8 100644 --- a/tests/test_vacuum/test_dps_command_mapping.py +++ b/tests/test_vacuum/test_dps_command_mapping.py @@ -124,13 +124,13 @@ def test_getDpsCodes_extraction_method() -> None: # Verify T2320 has different codes too assert "STATUS" in t2320_dps_codes - assert t2320_dps_codes["STATUS"] == "173" # Non-default code + assert t2320_dps_codes["STATUS"] == "177" # T2320 uses different STATUS code than T2267 assert t2320_dps_codes["STATUS"] != TuyaCodes.STATUS assert "BATTERY_LEVEL" in t2320_dps_codes assert t2320_dps_codes["BATTERY_LEVEL"] == "172" # Non-default code assert t2320_dps_codes["BATTERY_LEVEL"] != TuyaCodes.BATTERY_LEVEL assert "ERROR_CODE" in t2320_dps_codes - assert t2320_dps_codes["ERROR_CODE"] == "169" # Non-default code + assert t2320_dps_codes["ERROR_CODE"] == "177" # T2320 uses same ERROR code as STATUS assert t2320_dps_codes["ERROR_CODE"] != TuyaCodes.ERROR_CODE diff --git a/tests/test_vacuum/test_sensor.py b/tests/test_vacuum/test_sensor.py index bd45cbe5..3b6cb535 100644 --- a/tests/test_vacuum/test_sensor.py +++ b/tests/test_vacuum/test_sensor.py @@ -39,6 +39,7 @@ async def test_battery_sensor_update_success(): # Create mock vacuum entity mock_vacuum_entity = MagicMock() + mock_vacuum_entity._get_dps_code.return_value = TuyaCodes.BATTERY_LEVEL mock_vacuum_entity.tuyastatus = {TuyaCodes.BATTERY_LEVEL: 85} # Create mock hass data structure diff --git a/tests/test_vacuum/test_t2267_command_mappings.py b/tests/test_vacuum/test_t2267_command_mappings.py index 038e8481..b7faf31d 100644 --- a/tests/test_vacuum/test_t2267_command_mappings.py +++ b/tests/test_vacuum/test_t2267_command_mappings.py @@ -3,6 +3,8 @@ import pytest from unittest.mock import patch +from homeassistant.components.vacuum import VacuumActivity + from custom_components.robovac.robovac import RoboVac from custom_components.robovac.vacuums.base import RobovacCommand @@ -27,14 +29,16 @@ def test_t2267_dps_codes(mock_t2267_robovac: RoboVac) -> None: assert dps_codes["MODE"] == "152" assert dps_codes["STATUS"] == "153" assert dps_codes["DIRECTION"] == "155" - assert dps_codes["START_PAUSE"] == "156" + # START_PAUSE uses MODE command (code 152) for protobuf models + assert dps_codes["START_PAUSE"] == "152" assert dps_codes["DO_NOT_DISTURB"] == "157" assert dps_codes["FAN_SPEED"] == "158" assert dps_codes["BOOST_IQ"] == "159" assert dps_codes["LOCATE"] == "160" assert dps_codes["BATTERY_LEVEL"] == "163" assert dps_codes["CONSUMABLES"] == "168" - assert dps_codes["RETURN_HOME"] == "173" + # RETURN_HOME uses MODE command (code 152) for protobuf models + assert dps_codes["RETURN_HOME"] == "152" assert dps_codes["ERROR_CODE"] == "177" @@ -42,9 +46,9 @@ def test_t2267_mode_command_values(mock_t2267_robovac: RoboVac) -> None: """Test T2267 MODE command value mappings.""" assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "auto") == "BBoCCAE=" assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "pause") == "AggN" - assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "Spot") == "AA==" + assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "spot") == "AA==" assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "return") == "AggG" - assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "Nosweep") == "AggO" + assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "nosweep") == "AggO" # Unknown returns as-is assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "unknown") == "unknown" @@ -74,6 +78,49 @@ def test_t2267_direction_command_values(mock_t2267_robovac: RoboVac) -> None: assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.DIRECTION, "unknown") == "unknown" +def test_t2267_start_pause_command_values(mock_t2267_robovac: RoboVac) -> None: + """Test T2267 START_PAUSE command value mappings. + + START_PAUSE uses the MODE DPS code (152) with protobuf-encoded values: + - "AggN" encodes ModeCtrlRequest.Method.PAUSE_TASK (13) + - "AggO" encodes ModeCtrlRequest.Method.RESUME_TASK (14) + """ + # Pause command - encodes PAUSE_TASK (method=13) + assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.START_PAUSE, "pause") == "AggN" + + # Resume command - encodes RESUME_TASK (method=14) + assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.START_PAUSE, "resume") == "AggO" + + # Unknown returns as-is + assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.START_PAUSE, "unknown") == "unknown" + + +def test_t2267_return_home_command_values(mock_t2267_robovac: RoboVac) -> None: + """Test T2267 RETURN_HOME command value mappings. + + RETURN_HOME uses the MODE DPS code (152) with protobuf-encoded value: + - "AggG" encodes ModeCtrlRequest.Method.START_GOHOME (6) + """ + # Return home command - encodes START_GOHOME (method=6) + assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.RETURN_HOME, "return") == "AggG" + + # Unknown returns as-is + assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.RETURN_HOME, "unknown") == "unknown" + + +def test_t2267_stop_command_values(mock_t2267_robovac: RoboVac) -> None: + """Test T2267 STOP command value mappings. + + STOP uses the MODE DPS code (152) with protobuf-encoded value: + - "AggM" encodes ModeCtrlRequest.Method.STOP_TASK (12) + """ + # Stop command - encodes STOP_TASK (method=12) + assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.STOP, "stop") == "AggM" + + # Unknown returns as-is + assert mock_t2267_robovac.getRoboVacCommandValue(RobovacCommand.STOP, "unknown") == "unknown" + + def test_t2267_command_codes(mock_t2267_robovac: RoboVac) -> None: """Test that T2267 command codes are correctly defined on model.""" commands = mock_t2267_robovac.model_details.commands @@ -81,12 +128,123 @@ def test_t2267_command_codes(mock_t2267_robovac: RoboVac) -> None: assert commands[RobovacCommand.MODE]["code"] == 152 assert commands[RobovacCommand.STATUS]["code"] == 153 assert commands[RobovacCommand.DIRECTION]["code"] == 155 - assert commands[RobovacCommand.START_PAUSE]["code"] == 156 + # START_PAUSE uses MODE command (code 152) for protobuf models + assert commands[RobovacCommand.START_PAUSE]["code"] == 152 assert commands[RobovacCommand.DO_NOT_DISTURB]["code"] == 157 assert commands[RobovacCommand.FAN_SPEED]["code"] == 158 assert commands[RobovacCommand.BOOST_IQ]["code"] == 159 assert commands[RobovacCommand.LOCATE]["code"] == 160 assert commands[RobovacCommand.BATTERY]["code"] == 163 assert commands[RobovacCommand.CONSUMABLES]["code"] == 168 - assert commands[RobovacCommand.RETURN_HOME]["code"] == 173 + # RETURN_HOME uses MODE command (code 152) for protobuf models + assert commands[RobovacCommand.RETURN_HOME]["code"] == 152 assert commands[RobovacCommand.ERROR]["code"] == 177 + + +def test_t2267_status_values(mock_t2267_robovac: RoboVac) -> None: + """Test T2267 STATUS command value mappings.""" + # Cleaning states + assert mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "BgoAEAUyAA==" + ) == "Cleaning" + assert mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "BgoAEAVSAA==" + ) == "Positioning" + + # Room cleaning states + assert mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "CAoCCAEQBTIA" + ) == "Room Cleaning" + assert mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "CgoCCAEQBTICCAE=" + ) == "Room Paused" + + # Zone cleaning states + assert mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "CAoCCAIQBTIA" + ) == "Zone Cleaning" + assert mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "CgoCCAIQBTICCAE=" + ) == "Zone Paused" + + # Docked/charging states + assert mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "BBADGgA=" + ) == "Charging" + assert mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "BhADGgIIAQ==" + ) == "Completed" + + # Navigation states + assert mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "BBAHQgA=" + ) == "Heading Home" + + # Idle states + assert mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "AA==" + ) == "Standby" + assert mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "AhAB" + ) == "Sleeping" + + +def test_t2267_activity_mapping(mock_t2267_robovac: RoboVac) -> None: + """Test T2267 activity_mapping for VacuumActivity states.""" + activity_mapping = mock_t2267_robovac.model_details.activity_mapping + + # Verify activity_mapping exists + assert activity_mapping is not None + + # Cleaning states map to CLEANING + assert activity_mapping["Cleaning"] == VacuumActivity.CLEANING + assert activity_mapping["Positioning"] == VacuumActivity.CLEANING + assert activity_mapping["Room Cleaning"] == VacuumActivity.CLEANING + assert activity_mapping["Zone Cleaning"] == VacuumActivity.CLEANING + assert activity_mapping["Remote Control"] == VacuumActivity.CLEANING + + # Paused states map to PAUSED + assert activity_mapping["Paused"] == VacuumActivity.PAUSED + assert activity_mapping["Room Paused"] == VacuumActivity.PAUSED + assert activity_mapping["Zone Paused"] == VacuumActivity.PAUSED + + # Returning states map to RETURNING + assert activity_mapping["Heading Home"] == VacuumActivity.RETURNING + + # Docked states map to DOCKED + assert activity_mapping["Charging"] == VacuumActivity.DOCKED + assert activity_mapping["Completed"] == VacuumActivity.DOCKED + + # Idle states map to IDLE + assert activity_mapping["Standby"] == VacuumActivity.IDLE + assert activity_mapping["Sleeping"] == VacuumActivity.IDLE + + +def test_t2267_status_patterns(mock_t2267_robovac: RoboVac) -> None: + """Test T2267 status pattern matching for dynamic STATUS codes.""" + # Verify status_patterns exists + status_patterns = mock_t2267_robovac.model_details.status_patterns + assert status_patterns is not None + assert len(status_patterns) > 0 + + # Test pattern matching for positioning codes with different timestamps + # These codes follow the pattern: DA...FSAA== (start with DA, end with FSAA==) + positioning_codes = [ + "DAi73ou93qHyzgFSAA==", # Different timestamps + "DAjE74KF76HyzgFSAA==", + "DAiCobvM+KHyzgFSAA==", + "DAxxxxxxxxxxxxxxFSAA==", # Any content in middle should match + ] + + for code in positioning_codes: + result = mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, code + ) + assert result == "Positioning", f"Expected 'Positioning' for {code}, got {result}" + + # Test that non-matching codes are returned as-is + non_matching = "XYZabc123==" + result = mock_t2267_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, non_matching + ) + assert result == non_matching diff --git a/tests/test_vacuum/test_t2320_command_mappings.py b/tests/test_vacuum/test_t2320_command_mappings.py index c6813211..bcb7e032 100644 --- a/tests/test_vacuum/test_t2320_command_mappings.py +++ b/tests/test_vacuum/test_t2320_command_mappings.py @@ -24,10 +24,10 @@ class TestT2320CommandMappings: """Test T2320 command mappings match debug log expectations.""" def test_return_home_command_value(self, t2320_robovac): - """Test RETURN_HOME command returns boolean true as seen in debug logs.""" - # Debug log shows: "dps": {"153": true} - result = t2320_robovac.getRoboVacCommandValue(RobovacCommand.RETURN_HOME, "return_home") - assert result is True or result == "True" or result == "true" + """Test RETURN_HOME command returns protobuf-encoded value like T2267.""" + # T2320 uses same protobuf encoding as T2267 for return home + result = t2320_robovac.getRoboVacCommandValue(RobovacCommand.RETURN_HOME, "return") + assert result == "AggG" # Protobuf: ModeCtrlRequest.Method.START_GOHOME def test_start_pause_command_exists(self, t2320_robovac): """Test START_PAUSE command is defined for T2320.""" @@ -36,19 +36,19 @@ def test_start_pause_command_exists(self, t2320_robovac): assert RobovacCommand.START_PAUSE in commands def test_start_pause_command_value(self, t2320_robovac): - """Test START_PAUSE command returns boolean values.""" - # Debug log shows: "dps": {"2": false} + """Test START_PAUSE command returns protobuf-encoded values like T2267.""" + # T2320 uses same protobuf encoding as T2267 for pause/resume pause_result = t2320_robovac.getRoboVacCommandValue(RobovacCommand.START_PAUSE, "pause") - assert pause_result is False or pause_result == "False" or pause_result == "false" + assert pause_result == "AggN" # Protobuf: ModeCtrlRequest.Method.PAUSE_TASK - start_result = t2320_robovac.getRoboVacCommandValue(RobovacCommand.START_PAUSE, "start") - assert start_result is True or start_result == "True" or start_result == "true" + resume_result = t2320_robovac.getRoboVacCommandValue(RobovacCommand.START_PAUSE, "resume") + assert resume_result == "AggO" # Protobuf: ModeCtrlRequest.Method.RESUME_TASK def test_mode_command_value(self, t2320_robovac): - """Test MODE command returns plain string values as seen in debug logs.""" - # Debug log shows: "dps": {"152": "auto"} + """Test MODE command returns protobuf-encoded values like T2267.""" + # T2320 uses same protobuf encoding as T2267 for mode commands result = t2320_robovac.getRoboVacCommandValue(RobovacCommand.MODE, "auto") - assert result == "auto" + assert result == "BBoCCAE=" # Protobuf: ModeCtrlRequest.Method.START_AUTO_CLEAN def test_fan_speed_command_has_multiple_options(self, t2320_robovac): """Test FAN_SPEED command has multiple readable options.""" @@ -61,13 +61,17 @@ def test_fan_speed_command_has_multiple_options(self, t2320_robovac): assert len(speed) < 20 # Reasonable length for human-readable names def test_dps_codes_mapping(self, t2320_robovac): - """Test DPS codes match debug log expectations.""" + """Test DPS codes match expected values.""" dps_codes = t2320_robovac.getDpsCodes() - # Based on debug logs - assert dps_codes.get("RETURN_HOME") == "153" - assert dps_codes.get("START_PAUSE") == "2" + assert dps_codes.get("RETURN_HOME") == "152" # Same as MODE, uses protobuf like T2267 + assert dps_codes.get("START_PAUSE") == "152" # Same as MODE, uses protobuf like T2267 assert dps_codes.get("MODE") == "152" + assert dps_codes.get("STOP") == "152" # Same as MODE, uses protobuf like T2267 assert dps_codes.get("FAN_SPEED") == "154" + assert dps_codes.get("LOCATE") == "160" # Fixed: was 153 (conflict with RETURN_HOME) + assert dps_codes.get("STATUS") == "177" # T2320 uses different STATUS code than T2267 + assert dps_codes.get("BATTERY_LEVEL") == "172" + assert dps_codes.get("ERROR_CODE") == "177" # T2320 uses same ERROR code as STATUS def test_status_command_exists(self, t2320_robovac): """Test STATUS command is defined for state polling.""" @@ -78,3 +82,116 @@ def test_locate_command_exists(self, t2320_robovac): """Test LOCATE command is defined.""" commands = t2320_robovac.getSupportedCommands() assert RobovacCommand.LOCATE in commands + + def test_locate_uses_different_code_than_return_home(self, t2320_robovac): + """Test LOCATE and RETURN_HOME use different DPS codes.""" + dps_codes = t2320_robovac.getDpsCodes() + # LOCATE should NOT share the same code as RETURN_HOME + assert dps_codes.get("LOCATE") != dps_codes.get("RETURN_HOME") + + def test_status_values_mapping(self, t2320_robovac): + """Test STATUS command has value mappings for protobuf-encoded states.""" + # Test that STATUS has value mappings + commands = t2320_robovac.model_details.commands + status_cmd = commands.get(RobovacCommand.STATUS) + assert status_cmd is not None + assert "values" in status_cmd + + # Test common status values exist + status_values = status_cmd["values"] + # Check charging state exists + assert "BBADGgA=" in status_values + assert status_values["BBADGgA="] == "Charging" + # Check standby state exists + assert "AA==" in status_values + assert status_values["AA=="] == "Standby" + + def test_activity_mapping_exists(self, t2320_robovac): + """Test T2320 has activity_mapping for Home Assistant states.""" + activity_mapping = t2320_robovac.model_details.activity_mapping + assert activity_mapping is not None + assert len(activity_mapping) > 0 + + def test_activity_mapping_contains_station_states(self, t2320_robovac): + """Test activity_mapping includes X9 Pro auto-clean station states.""" + from homeassistant.components.vacuum import VacuumActivity + activity_mapping = t2320_robovac.model_details.activity_mapping + + # X9 Pro has auto-clean station - verify station states are mapped + assert "Washing Mop" in activity_mapping + assert activity_mapping["Washing Mop"] == VacuumActivity.DOCKED + assert "Drying Mop" in activity_mapping + assert activity_mapping["Drying Mop"] == VacuumActivity.DOCKED + assert "Emptying Dust" in activity_mapping + assert activity_mapping["Emptying Dust"] == VacuumActivity.DOCKED + + def test_human_readable_status_conversion(self, t2320_robovac): + """Test STATUS values are converted to human-readable strings.""" + # Test protobuf-encoded status -> human readable + result = t2320_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "BBADGgA=" + ) + assert result == "Charging" + + result = t2320_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "AA==" + ) + assert result == "Standby" + + result = t2320_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, "BhAJOgIQAQ==" + ) + assert result == "Washing Mop" + + def test_fan_speed_values(self, t2320_robovac): + """Test FAN_SPEED command value mappings.""" + # Test snake_case input -> PascalCase output + assert t2320_robovac.getRoboVacCommandValue(RobovacCommand.FAN_SPEED, "quiet") == "Quiet" + assert t2320_robovac.getRoboVacCommandValue(RobovacCommand.FAN_SPEED, "standard") == "Standard" + assert t2320_robovac.getRoboVacCommandValue(RobovacCommand.FAN_SPEED, "turbo") == "Turbo" + assert t2320_robovac.getRoboVacCommandValue(RobovacCommand.FAN_SPEED, "max") == "Max" + + def test_status_patterns_exist(self, t2320_robovac): + """Test T2320 has status_patterns for dynamic STATUS codes.""" + status_patterns = t2320_robovac.model_details.status_patterns + assert status_patterns is not None + assert len(status_patterns) > 0 + # Verify positioning pattern exists + assert ("DA", "FSAA==", "Positioning") in status_patterns + + def test_error_patterns_exist(self, t2320_robovac): + """Test T2320 has error_patterns to prevent false error states.""" + error_patterns = t2320_robovac.model_details.error_patterns + assert error_patterns is not None + assert len(error_patterns) > 0 + # Verify positioning-on-error pattern exists + assert ("DA", "FSAA==", "no_error") in error_patterns + + def test_status_pattern_matching(self, t2320_robovac): + """Test STATUS pattern matching for dynamic positioning codes.""" + # These codes have dynamic timestamps in the middle + positioning_codes = [ + "DAi73ou93qHyzgFSAA==", + "DAjE74KF76HyzgFSAA==", + "DAiCobvM+KHyzgFSAA==", + ] + + for code in positioning_codes: + result = t2320_robovac.getRoboVacHumanReadableValue( + RobovacCommand.STATUS, code + ) + assert result == "Positioning", f"Expected 'Positioning' for {code}, got {result}" + + def test_error_pattern_matching(self, t2320_robovac): + """Test ERROR pattern matching prevents false error states.""" + # Positioning status sent on ERROR DPS should return "no_error" + positioning_codes = [ + "DAi73ou93qHyzgFSAA==", + "DAjE74KF76HyzgFSAA==", + ] + + for code in positioning_codes: + result = t2320_robovac.getRoboVacHumanReadableValue( + RobovacCommand.ERROR, code + ) + assert result == "no_error", f"Expected 'no_error' for {code} on ERROR DPS, got {result}"