diff --git a/roborock/cli.py b/roborock/cli.py index 66ada755..5fbdc4ba 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -388,16 +388,32 @@ async def status(ctx, device_id: str): device_manager = await context.get_device_manager() device = await device_manager.get_device(device_id) - click.echo(f"Getting status for device {device_id}") if not (status_trait := device.traits.get("status")): click.echo(f"Device {device.name} does not have a status trait") return status_result = await status_trait.get_status() - click.echo(f"Device {device_id} status:") click.echo(dump_json(status_result.as_dict())) +@session.command() +@click.option("--device_id", required=True) +@click.pass_context +@async_command +async def clean_summary(ctx, device_id: str): + """Get device clean summary.""" + context: RoborockContext = ctx.obj + + device_manager = await context.get_device_manager() + device = await device_manager.get_device(device_id) + if not (clean_summary_trait := device.traits.get("clean_summary")): + click.echo(f"Device {device.name} does not have a clean summary trait") + return + + clean_summary_result = await clean_summary_trait.get_clean_summary() + click.echo(dump_json(clean_summary_result.as_dict())) + + @click.command() @click.option("--device_id", required=True) @click.option("--cmd", required=True) @@ -636,6 +652,7 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur cli.add_command(session) cli.add_command(get_device_info) cli.add_command(update_docs) +cli.add_command(clean_summary) def main(): diff --git a/roborock/devices/device_manager.py b/roborock/devices/device_manager.py index 853a5f57..c858a9ef 100644 --- a/roborock/devices/device_manager.py +++ b/roborock/devices/device_manager.py @@ -22,6 +22,7 @@ from .channel import Channel from .mqtt_channel import create_mqtt_channel from .traits.b01.props import B01PropsApi +from .traits.clean_summary import CleanSummaryTrait from .traits.dnd import DoNotDisturbTrait from .traits.dyad import DyadApi from .traits.status import StatusTrait @@ -154,6 +155,7 @@ def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> Roborock channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, cache) traits.append(StatusTrait(product, channel.rpc_channel)) traits.append(DoNotDisturbTrait(channel.rpc_channel)) + traits.append(CleanSummaryTrait(channel.rpc_channel)) case DeviceVersion.A01: mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) match product.category: diff --git a/roborock/devices/traits/clean_summary.py b/roborock/devices/traits/clean_summary.py new file mode 100644 index 00000000..5a7f3ff7 --- /dev/null +++ b/roborock/devices/traits/clean_summary.py @@ -0,0 +1,52 @@ +"""Module for Roborock V1 devices. + +This interface is experimental and subject to breaking changes without notice +until the API is stable. +""" + +import logging + +from roborock.containers import ( + CleanSummary, +) +from roborock.devices.v1_rpc_channel import V1RpcChannel +from roborock.roborock_typing import RoborockCommand +from roborock.util import unpack_list + +from .trait import Trait + +_LOGGER = logging.getLogger(__name__) + +__all__ = [ + "CleanSummaryTrait", +] + + +class CleanSummaryTrait(Trait): + """Trait for managing the clean summary of Roborock devices.""" + + name = "clean_summary" + + def __init__(self, rpc_channel: V1RpcChannel) -> None: + """Initialize the CleanSummaryTrait.""" + self._rpc_channel = rpc_channel + + async def get_clean_summary(self) -> CleanSummary: + """Get the current clean summary of the device. + + This is a placeholder command and will likely be changed/moved in the future. + """ + clean_summary = await self._rpc_channel.send_command(RoborockCommand.GET_CLEAN_SUMMARY) + if isinstance(clean_summary, dict): + return CleanSummary.from_dict(clean_summary) + elif isinstance(clean_summary, list): + clean_time, clean_area, clean_count, records = unpack_list(clean_summary, 4) + return CleanSummary( + clean_time=clean_time, + clean_area=clean_area, + clean_count=clean_count, + records=records, + ) + elif isinstance(clean_summary, int): + return CleanSummary(clean_time=clean_summary) + raise ValueError(f"Unexpected clean summary format: {clean_summary!r}") diff --git a/roborock/devices/traits/status.py b/roborock/devices/traits/status.py index e9a210d0..a42648e7 100644 --- a/roborock/devices/traits/status.py +++ b/roborock/devices/traits/status.py @@ -5,6 +5,7 @@ """ import logging +from typing import Any from roborock.containers import ( HomeDataProduct, @@ -40,4 +41,9 @@ async def get_status(self) -> Status: This is a placeholder command and will likely be changed/moved in the future. """ status_type: type[Status] = ModelStatus.get(self._product_info.model, S7MaxVStatus) - return await self._rpc_channel.send_command(RoborockCommand.GET_STATUS, response_type=status_type) + status: dict[str, Any] | list = await self._rpc_channel.send_command(RoborockCommand.GET_STATUS) + if isinstance(status, list): + status = status[0] + if not isinstance(status, dict): + raise ValueError(f"Unexpected status format: {status!r}") + return status_type.from_dict(status) diff --git a/roborock/devices/v1_rpc_channel.py b/roborock/devices/v1_rpc_channel.py index 84af2755..c78e42ab 100644 --- a/roborock/devices/v1_rpc_channel.py +++ b/roborock/devices/v1_rpc_channel.py @@ -17,6 +17,7 @@ CommandType, ParamsType, RequestMessage, + ResponseData, SecurityData, decode_rpc_response, ) @@ -130,7 +131,7 @@ async def _send_raw_command( method: CommandType, *, params: ParamsType = None, - ) -> Any: + ) -> ResponseData: """Send a command and return a parsed response RoborockBase type.""" request_message = RequestMessage(method, params=params) _LOGGER.debug( @@ -138,7 +139,7 @@ async def _send_raw_command( ) message = self._payload_encoder(request_message) - future: asyncio.Future[dict[str, Any]] = asyncio.Future() + future: asyncio.Future[ResponseData] = asyncio.Future() def find_response(response_message: RoborockMessage) -> None: try: diff --git a/roborock/protocols/v1_protocol.py b/roborock/protocols/v1_protocol.py index 8c1b70dd..d901e931 100644 --- a/roborock/protocols/v1_protocol.py +++ b/roborock/protocols/v1_protocol.py @@ -95,6 +95,9 @@ def _as_payload(self, security_data: SecurityData | None) -> bytes: ) +ResponseData = dict[str, Any] | list | int + + @dataclass(kw_only=True, frozen=True) class ResponseMessage: """Data structure for v1 RoborockMessage responses.""" @@ -102,8 +105,8 @@ class ResponseMessage: request_id: int | None """The request ID of the response.""" - data: dict[str, Any] - """The data of the response.""" + data: ResponseData + """The data of the response, where the type depends on the command.""" def decode_rpc_response(message: RoborockMessage) -> ResponseMessage: @@ -139,12 +142,12 @@ def decode_rpc_response(message: RoborockMessage) -> ResponseMessage: if not (result := data_point_response.get("result")): raise RoborockException(f"Invalid V1 message format: missing 'result' in data point for {message.payload!r}") _LOGGER.debug("Decoded V1 message result: %s", result) - if isinstance(result, list) and result: - result = result[0] if isinstance(result, str) and result == "ok": result = {} - if not isinstance(result, dict): - raise RoborockException(f"Invalid V1 message format: 'result' should be a dictionary for {message.payload!r}") + if not isinstance(result, (dict, list, int)): + raise RoborockException( + f"Invalid V1 message format: 'result' was unexpected type {type(result)}. {message.payload!r}" + ) return ResponseMessage(request_id=request_id, data=result) diff --git a/tests/devices/test_v1_device.py b/tests/devices/test_v1_device.py index 10ef8073..de2b7754 100644 --- a/tests/devices/test_v1_device.py +++ b/tests/devices/test_v1_device.py @@ -73,7 +73,7 @@ async def test_device_connection(device: RoborockDevice, channel: AsyncMock) -> async def test_device_get_status_command(device: RoborockDevice, rpc_channel: AsyncMock) -> None: """Test the device get_status command.""" # Mock response for get_status command - rpc_channel.send_command.return_value = STATUS + rpc_channel.send_command.return_value = [STATUS.as_dict()] # Test get_status and verify the command was sent status_api = device.traits["status"] diff --git a/tests/devices/traits/test_clean_summary.py b/tests/devices/traits/test_clean_summary.py new file mode 100644 index 00000000..b1a34a06 --- /dev/null +++ b/tests/devices/traits/test_clean_summary.py @@ -0,0 +1,125 @@ +"""Tests for the CleanSummary class.""" + +from unittest.mock import AsyncMock + +import pytest + +from roborock.containers import CleanSummary +from roborock.devices.traits.clean_summary import CleanSummaryTrait +from roborock.devices.v1_rpc_channel import V1RpcChannel +from roborock.exceptions import RoborockException +from roborock.roborock_typing import RoborockCommand + +CLEAN_SUMMARY_DATA = [ + 1442559, + 24258125000, + 296, + [ + 1756848207, + 1754930385, + 1753203976, + 1752183435, + 1747427370, + 1746204046, + 1745601543, + 1744387080, + 1743528522, + 1742489154, + 1741022299, + 1740433682, + 1739902516, + 1738875106, + 1738864366, + 1738620067, + 1736873889, + 1736197544, + 1736121269, + 1734458038, + ], +] + + +@pytest.fixture +def mock_rpc_channel() -> AsyncMock: + """Create a mock RPC channel.""" + mock_channel = AsyncMock(spec=V1RpcChannel) + # Ensure send_command is an AsyncMock that returns awaitable coroutines + mock_channel.send_command = AsyncMock() + return mock_channel + + +@pytest.fixture +def clean_summary_trait(mock_rpc_channel: AsyncMock) -> CleanSummaryTrait: + """Create a CleanSummaryTrait instance with mocked dependencies.""" + return CleanSummaryTrait(mock_rpc_channel) + + +@pytest.fixture +def sample_clean_summary() -> CleanSummary: + """Create a sample CleanSummary for testing.""" + return CleanSummary( + clean_area=100, + clean_time=3600, + ) + + +def test_trait_name(clean_summary_trait: CleanSummaryTrait) -> None: + """Test that the trait has the correct name.""" + assert clean_summary_trait.name == "clean_summary" + + +async def test_get_clean_summary_success( + clean_summary_trait: CleanSummaryTrait, mock_rpc_channel: AsyncMock, sample_clean_summary: CleanSummary +) -> None: + """Test successfully getting clean summary.""" + # Setup mock to return the sample clean summary + mock_rpc_channel.send_command.return_value = CLEAN_SUMMARY_DATA + + # Call the method + result = await clean_summary_trait.get_clean_summary() + + # Verify the result + assert result.clean_area == 24258125000 + assert result.clean_time == 1442559 + assert result.square_meter_clean_area == 24258.1 + assert result.clean_count == 296 + assert result.records + assert len(result.records) == 20 + assert result.records[0] == 1756848207 + + # Verify the RPC call was made correctly + mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_CLEAN_SUMMARY) + + +async def test_get_clean_summary_clean_time_only( + clean_summary_trait: CleanSummaryTrait, mock_rpc_channel: AsyncMock, sample_clean_summary: CleanSummary +) -> None: + """Test successfully getting clean summary where the response only has the clean time.""" + + mock_rpc_channel.send_command.return_value = [1442559] + + # Call the method + result = await clean_summary_trait.get_clean_summary() + + # Verify the result + assert result.clean_area is None + assert result.clean_time == 1442559 + assert result.square_meter_clean_area is None + assert result.clean_count is None + assert not result.records + + # Verify the RPC call was made correctly + mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_CLEAN_SUMMARY) + + +async def test_get_clean_summary_propagates_exception( + clean_summary_trait: CleanSummaryTrait, mock_rpc_channel: AsyncMock +) -> None: + """Test that exceptions from RPC channel are propagated in get_clean_summary.""" + + # Setup mock to raise an exception + mock_rpc_channel.send_command.side_effect = RoborockException("Communication error") + + # Verify the exception is propagated + with pytest.raises(RoborockException, match="Communication error"): + await clean_summary_trait.get_clean_summary() diff --git a/tests/protocols/__snapshots__/test_v1_protocol.ambr b/tests/protocols/__snapshots__/test_v1_protocol.ambr index 69687a15..26955faa 100644 --- a/tests/protocols/__snapshots__/test_v1_protocol.ambr +++ b/tests/protocols/__snapshots__/test_v1_protocol.ambr @@ -1,16 +1,52 @@ # serializer version: 1 +# name: test_decode_rpc_payload[get_clean_summary] + 20001 +# --- +# name: test_decode_rpc_payload[get_clean_summary].1 + ''' + [ + 1442559, + 24258125000, + 296, + [ + 1756848207, + 1754930385, + 1753203976, + 1752183435, + 1747427370, + 1746204046, + 1745601543, + 1744387080, + 1743528522, + 1742489154, + 1741022299, + 1740433682, + 1739902516, + 1738875106, + 1738864366, + 1738620067, + 1736873889, + 1736197544, + 1736121269, + 1734458038 + ] + ] + ''' +# --- # name: test_decode_rpc_payload[get_dnd] 20002 # --- # name: test_decode_rpc_payload[get_dnd].1 ''' - { - "start_hour": 22, - "start_minute": 0, - "end_hour": 8, - "end_minute": 0, - "enabled": 1 - } + [ + { + "start_hour": 22, + "start_minute": 0, + "end_hour": 8, + "end_minute": 0, + "enabled": 1 + } + ] ''' # --- # name: test_decode_rpc_payload[get_status] @@ -18,31 +54,33 @@ # --- # name: test_decode_rpc_payload[get_status].1 ''' - { - "msg_ver": 2, - "msg_seq": 515, - "state": 8, - "battery": 100, - "clean_time": 5405, - "clean_area": 91287500, - "error_code": 0, - "map_present": 1, - "in_cleaning": 0, - "in_returning": 0, - "in_fresh_state": 1, - "lab_status": 1, - "water_box_status": 0, - "fan_power": 106, - "dnd_enabled": 1, - "map_status": 3, - "is_locating": 0, - "lock_status": 0, - "water_box_mode": 204, - "distance_off": 0, - "water_box_carriage_status": 0, - "mop_forbidden_enable": 0, - "unsave_map_reason": 4, - "unsave_map_flag": 0 - } + [ + { + "msg_ver": 2, + "msg_seq": 515, + "state": 8, + "battery": 100, + "clean_time": 5405, + "clean_area": 91287500, + "error_code": 0, + "map_present": 1, + "in_cleaning": 0, + "in_returning": 0, + "in_fresh_state": 1, + "lab_status": 1, + "water_box_status": 0, + "fan_power": 106, + "dnd_enabled": 1, + "map_status": 3, + "is_locating": 0, + "lock_status": 0, + "water_box_mode": 204, + "distance_off": 0, + "water_box_carriage_status": 0, + "mop_forbidden_enable": 0, + "unsave_map_reason": 4, + "unsave_map_flag": 0 + } + ] ''' # --- diff --git a/tests/protocols/test_v1_protocol.py b/tests/protocols/test_v1_protocol.py index 7423fc23..0defaceb 100644 --- a/tests/protocols/test_v1_protocol.py +++ b/tests/protocols/test_v1_protocol.py @@ -96,32 +96,34 @@ def test_encode_mqtt_payload(command, params, expected): [ ( b'{"t":1652547161,"dps":{"102":"{\\"id\\":20005,\\"result\\":[{\\"msg_ver\\":2,\\"msg_seq\\":1072,\\"state\\":8,\\"battery\\":100,\\"clean_time\\":1041,\\"clean_area\\":37080000,\\"error_code\\":0,\\"map_present\\":1,\\"in_cleaning\\":0,\\"in_returning\\":0,\\"in_fresh_state\\":1,\\"lab_status\\":1,\\"water_box_status\\":0,\\"fan_power\\":103,\\"dnd_enabled\\":0,\\"map_status\\":3,\\"is_locating\\":0,\\"lock_status\\":0,\\"water_box_mode\\":202,\\"distance_off\\":0,\\"water_box_carriage_status\\":0,\\"mop_forbidden_enable\\":0,\\"unsave_map_reason\\":0,\\"unsave_map_flag\\":0}]}"}}', - { - "msg_ver": 2, - "msg_seq": 1072, - "state": 8, - "battery": 100, - "clean_time": 1041, - "clean_area": 37080000, - "error_code": 0, - "map_present": 1, - "in_cleaning": 0, - "in_returning": 0, - "in_fresh_state": 1, - "lab_status": 1, - "water_box_status": 0, - "fan_power": 103, - "dnd_enabled": 0, - "map_status": 3, - "is_locating": 0, - "lock_status": 0, - "water_box_mode": 202, - "distance_off": 0, - "water_box_carriage_status": 0, - "mop_forbidden_enable": 0, - "unsave_map_reason": 0, - "unsave_map_flag": 0, - }, + [ + { + "msg_ver": 2, + "msg_seq": 1072, + "state": 8, + "battery": 100, + "clean_time": 1041, + "clean_area": 37080000, + "error_code": 0, + "map_present": 1, + "in_cleaning": 0, + "in_returning": 0, + "in_fresh_state": 1, + "lab_status": 1, + "water_box_status": 0, + "fan_power": 103, + "dnd_enabled": 0, + "map_status": 3, + "is_locating": 0, + "lock_status": 0, + "water_box_mode": 202, + "distance_off": 0, + "water_box_carriage_status": 0, + "mop_forbidden_enable": 0, + "unsave_map_reason": 0, + "unsave_map_flag": 0, + } + ], ), ], ) diff --git a/tests/protocols/testdata/v1_protocol/get_clean_summary.json b/tests/protocols/testdata/v1_protocol/get_clean_summary.json new file mode 100644 index 00000000..856b8350 --- /dev/null +++ b/tests/protocols/testdata/v1_protocol/get_clean_summary.json @@ -0,0 +1,6 @@ +{ + "t": 1757878288, + "dps": { + "102": "{\"id\":20001,\"result\":[1442559,24258125000,296,[1756848207,1754930385,1753203976,1752183435,1747427370,1746204046,1745601543,1744387080,1743528522,1742489154,1741022299,1740433682,1739902516,1738875106,1738864366,1738620067,1736873889,1736197544,1736121269,1734458038]]}" + } +}