From 465b354670277d8734162185315271e87a03d3f5 Mon Sep 17 00:00:00 2001 From: Paolo Stivanin Date: Wed, 23 Sep 2026 15:39:48 +0200 Subject: [PATCH] feat: add resolve_error to StatusTrait A dock error such as water_empty (38) stays latched on the device until it is resolved in the app, even after the tank has been refilled. Add StatusTrait.resolve_error(), which sends resolve_error with dict params {"error_code": N} and refreshes the status. Without an explicit code it resolves the current dock error, else the current robot error. Array params are rejected by the firmware with -10007 (see #834). Also add a resolve-error CLI command. Verified on a Qrevo Master (roborock.vacuum.a117, fw 02.29.48): dock error water_empty went back to ok within a second. Closes #702 --- roborock/cli.py | 15 ++++++ roborock/devices/traits/v1/status.py | 17 ++++++ tests/devices/traits/v1/test_status.py | 71 +++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/roborock/cli.py b/roborock/cli.py index bb02116b0..5d1e1cbc7 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -749,6 +749,20 @@ async def reset_consumable(ctx, device_id: str, consumable: str): click.echo(f"Reset {consumable} for device {device_id}") +@session.command() +@click.option("--device_id", required=True) +@click.option("--error_code", type=int, help="Error code to resolve. Defaults to the current dock or robot error.") +@click.pass_context +@async_command +async def resolve_error(ctx, device_id: str, error_code: int | None): + """Resolve the current dock or robot error, like "Resolved" in the app.""" + context: RoborockContext = ctx.obj + trait = await _v1_trait(context, device_id, lambda v1: v1.status) + await trait.refresh() + await trait.resolve_error(error_code) + click.echo(f"Dock error: {trait.dock_error_status}, error: {trait.error_code}") + + @session.command() @click.option("--device_id", required=True) @click.option("--enabled", type=bool, help="Enable (True) or disable (False) the child lock.") @@ -1369,6 +1383,7 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur cli.add_command(q10_position) cli.add_command(consumables) cli.add_command(reset_consumable) +cli.add_command(resolve_error) cli.add_command(rooms) cli.add_command(home) cli.add_command(features) diff --git a/roborock/devices/traits/v1/status.py b/roborock/devices/traits/v1/status.py index 9bed4e41e..fa83f603e 100644 --- a/roborock/devices/traits/v1/status.py +++ b/roborock/devices/traits/v1/status.py @@ -133,6 +133,23 @@ async def set_cleaning_mode(self, cleaning_mode: str | CleaningMode) -> None: params=get_cleaning_mode_parameters(resolve_cleaning_mode(cleaning_mode), self._device_features_trait), ) + async def resolve_error(self, error_code: int | None = None) -> None: + """Resolve an error, like tapping "Resolved" in the Roborock app. + + Without an explicit code this resolves the current dock error if there is one, + otherwise the current robot error. A dock error such as water_empty stays latched + on the device until it is resolved, even after the cause has been fixed. + """ + if error_code is None: + if self.dock_error_status: + error_code = int(self.dock_error_status) + elif self.error_code: + error_code = int(self.error_code) + else: + return + await self.rpc_channel.send_command(RoborockCommand.RESOLVE_ERROR, params={"error_code": error_code}) + await self.refresh() + def update_from_dps(self, decoded_dps: dict[RoborockDataProtocol, Any]) -> None: """Update the trait from data protocol push message data. diff --git a/tests/devices/traits/v1/test_status.py b/tests/devices/traits/v1/test_status.py index 6fb0284c8..32c825246 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -2,7 +2,7 @@ import asyncio from typing import cast -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, call import pytest @@ -17,6 +17,8 @@ ) from roborock.data import SHORT_MODEL_TO_ENUM, RoborockProductNickname from roborock.data.v1 import ( + RoborockDockErrorCode, + RoborockErrorCode, RoborockStateCode, ) from roborock.device_features import DeviceFeatures @@ -90,6 +92,73 @@ async def test_refresh_status_propagates_exception(status_trait: StatusTrait, mo await status_trait.refresh() +async def test_resolve_error_explicit_code(status_trait: StatusTrait, mock_rpc_channel: AsyncMock) -> None: + """Test resolving an explicitly given error code.""" + mock_rpc_channel.send_command.side_effect = ["ok", STATUS] + + await status_trait.resolve_error(38) + + assert mock_rpc_channel.send_command.mock_calls == [ + call(RoborockCommand.RESOLVE_ERROR, params={"error_code": 38}), + call(RoborockCommand.GET_STATUS), + ] + assert status_trait.dock_error_status == RoborockDockErrorCode.ok + + +async def test_resolve_error_defaults_to_dock_error(status_trait: StatusTrait, mock_rpc_channel: AsyncMock) -> None: + """Test that the current dock error is resolved when no code is given.""" + mock_rpc_channel.send_command.side_effect = [ + {**STATUS, "dock_error_status": RoborockDockErrorCode.water_empty.value, "error_code": 38}, + "ok", + STATUS, + ] + await status_trait.refresh() + assert status_trait.dock_error_status == RoborockDockErrorCode.water_empty + + await status_trait.resolve_error() + + assert mock_rpc_channel.send_command.mock_calls[1:] == [ + call(RoborockCommand.RESOLVE_ERROR, params={"error_code": 38}), + call(RoborockCommand.GET_STATUS), + ] + assert status_trait.dock_error_status == RoborockDockErrorCode.ok + + +async def test_resolve_error_defaults_to_robot_error(status_trait: StatusTrait, mock_rpc_channel: AsyncMock) -> None: + """Test that the robot error is resolved when there is no dock error.""" + mock_rpc_channel.send_command.side_effect = [ + {**STATUS, "error_code": RoborockErrorCode.clear_water_box_exception.value}, + "ok", + STATUS, + ] + await status_trait.refresh() + + await status_trait.resolve_error() + + assert mock_rpc_channel.send_command.mock_calls[1:] == [ + call(RoborockCommand.RESOLVE_ERROR, params={"error_code": RoborockErrorCode.clear_water_box_exception.value}), + call(RoborockCommand.GET_STATUS), + ] + + +async def test_resolve_error_no_error(status_trait: StatusTrait, mock_rpc_channel: AsyncMock) -> None: + """Test that nothing is sent when there is no error to resolve.""" + mock_rpc_channel.send_command.return_value = STATUS + await status_trait.refresh() + + await status_trait.resolve_error() + + mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_STATUS) + + +async def test_resolve_error_propagates_exception(status_trait: StatusTrait, mock_rpc_channel: AsyncMock) -> None: + """Test that exceptions from the RPC channel are propagated.""" + mock_rpc_channel.send_command.side_effect = RoborockException("invalid params") + + with pytest.raises(RoborockException, match="invalid params"): + await status_trait.resolve_error(38) + + async def test_refresh_status_invalid_format(status_trait: StatusTrait, mock_rpc_channel: AsyncMock) -> None: """Test that invalid response format raises RoborockParsingException.""" mock_rpc_channel.send_command.return_value = "invalid"