diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 746b30cc..29fb9c3a 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -792,7 +792,7 @@ class AppInitStatusLocalInfo(RoborockBase): class AppInitStatus(RoborockBase): local_info: AppInitStatusLocalInfo feature_info: list[int] - new_feature_info: int + new_feature_info: int = 0 new_feature_info_str: str = "" new_feature_info_2: int | None = None carriage_type: int | None = None diff --git a/roborock/devices/traits/v1/clean_summary.py b/roborock/devices/traits/v1/clean_summary.py index 61fa4033..1a2a4223 100644 --- a/roborock/devices/traits/v1/clean_summary.py +++ b/roborock/devices/traits/v1/clean_summary.py @@ -2,6 +2,7 @@ from roborock.data import CleanRecord, CleanSummaryWithDetail, RoborockBase from roborock.devices.traits.v1 import common +from roborock.exceptions import RoborockParsingException from roborock.roborock_typing import RoborockCommand from roborock.util import unpack_list @@ -87,4 +88,12 @@ async def refresh(self) -> None: async def get_clean_record(self, record_id: int) -> CleanRecord: """Load a specific clean record by ID.""" response = await self.rpc_channel.send_command(RoborockCommand.GET_CLEAN_RECORD, params=[record_id]) - return self.clean_record_converter.convert(response) + try: + return self.clean_record_converter.convert(response) + except (TypeError, ValueError) as err: + raise RoborockParsingException( + trait_name=type(self).__name__, + command=RoborockCommand.GET_CLEAN_RECORD, + payload=response, + inner_error=err, + ) from err diff --git a/roborock/devices/traits/v1/common.py b/roborock/devices/traits/v1/common.py index ce2c899e..431cd075 100644 --- a/roborock/devices/traits/v1/common.py +++ b/roborock/devices/traits/v1/common.py @@ -9,6 +9,7 @@ from typing import ClassVar from roborock.data import RoborockBase +from roborock.exceptions import RoborockParsingException from roborock.protocols.v1_protocol import V1RpcChannel from roborock.roborock_typing import RoborockCommand @@ -63,7 +64,7 @@ class V1TraitMixin(ABC): def __init__(self) -> None: """Initialize the V1TraitMixin.""" - self._rpc_channel = None + self._rpc_channel: V1RpcChannel | None = None @property def rpc_channel(self) -> V1RpcChannel: @@ -75,7 +76,15 @@ def rpc_channel(self) -> V1RpcChannel: async def refresh(self) -> None: """Refresh the contents of this trait.""" response = await self.rpc_channel.send_command(self.command) - new_data = self.converter.convert(response) + try: + new_data = self.converter.convert(response) + except (TypeError, ValueError) as err: + raise RoborockParsingException( + trait_name=type(self).__name__, + command=self.command, + payload=response, + inner_error=err, + ) from err merge_trait_values(self, new_data) # type: ignore[arg-type] diff --git a/roborock/devices/traits/v1/rooms.py b/roborock/devices/traits/v1/rooms.py index 6c6e35c7..c3cf2562 100644 --- a/roborock/devices/traits/v1/rooms.py +++ b/roborock/devices/traits/v1/rooms.py @@ -5,6 +5,7 @@ from roborock.data import HomeData, HomeDataRoom, NamedRoomMapping, RoborockBase from roborock.devices.traits.v1 import common +from roborock.exceptions import RoborockParsingException from roborock.roborock_typing import RoborockCommand from roborock.web_api import UserWebApiClient @@ -94,7 +95,12 @@ async def refresh(self) -> None: """Refresh room mappings and backfill unknown room names from the web API.""" response = await self.rpc_channel.send_command(self.command) if not isinstance(response, list): - raise ValueError(f"Unexpected RoomsTrait response format: {response!r}") + raise RoborockParsingException( + trait_name=type(self).__name__, + command=self.command, + payload=response, + inner_error="Unexpected RoomsTrait response format", + ) segment_map = RoomsConverter.extract_segment_map(response) # Track all iot ids seen before. Refresh the room list when new ids are found. @@ -105,8 +111,16 @@ async def refresh(self) -> None: _LOGGER.debug("Updating rooms: %s", list(updated_rooms)) self._home_data.rooms = updated_rooms self._discovered_iot_ids.update(new_iot_ids) + try: + rooms = self.converter.convert(response) + except (TypeError, ValueError) as err: + raise RoborockParsingException( + trait_name=type(self).__name__, + command=self.command, + payload=response, + inner_error=err, + ) from err - rooms = self.converter.convert(response) rooms = rooms.with_room_names(self._home_data.rooms_name_map) common.merge_trait_values(self, rooms) diff --git a/roborock/exceptions.py b/roborock/exceptions.py index 55e72b60..efa1e265 100644 --- a/roborock/exceptions.py +++ b/roborock/exceptions.py @@ -1,5 +1,8 @@ """Roborock exceptions.""" +from enum import Enum +from typing import Any + class RoborockException(Exception): """Class for Roborock exceptions.""" @@ -91,3 +94,14 @@ class RoborockInvalidStatus(RoborockException): class RoborockUnsupportedFeature(RoborockException): """Class for Roborock unsupported feature exceptions.""" + + +class RoborockParsingException(RoborockException): + """Class for Roborock exceptions when parsing device responses.""" + + def __init__(self, trait_name: str, command: Enum | str, payload: Any, inner_error: Exception | str) -> None: + cmd_name = command.name if isinstance(command, Enum) else str(command) + self.message = ( + f"Failed to parse {cmd_name} response for {trait_name}. Payload: {payload!r} Error: {inner_error!r}" + ) + super().__init__(self.message) diff --git a/tests/data/v1/test_v1_containers.py b/tests/data/v1/test_v1_containers.py index 1511350f..604860c2 100644 --- a/tests/data/v1/test_v1_containers.py +++ b/tests/data/v1/test_v1_containers.py @@ -338,3 +338,24 @@ def test_partial_app_init_status() -> None: assert app_init_status.local_info.name == "custom_A.03.0096_FCC" assert app_init_status.feature_info == [111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125] assert app_init_status.new_feature_info_str == "" + + +def test_missing_new_feature_info() -> None: + """Test that an AppInitStatus response missing new_feature_info is handled correctly.""" + app_init_status = AppInitStatus.from_dict( + { + "local_info": { + "name": "custom_A.03.0096_FCC", + "bom": "A.03.0096", + "location": "us", + "language": "en", + "wifiplan": "US", + "timezone": "US/Pacific", + "logserver": "awsusor0.fds.api.xiaomi.com", + "featureset": 1, + }, + "feature_info": [111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125], + } + ) + assert app_init_status.new_feature_info == 0 + assert app_init_status.new_feature_info_str == "" diff --git a/tests/devices/traits/v1/test_common.py b/tests/devices/traits/v1/test_common.py new file mode 100644 index 00000000..aafda2e1 --- /dev/null +++ b/tests/devices/traits/v1/test_common.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass +from unittest.mock import AsyncMock + +import pytest + +from roborock.data.containers import RoborockBase +from roborock.devices.traits.v1.common import DefaultConverter, V1TraitMixin +from roborock.exceptions import RoborockParsingException +from roborock.roborock_typing import RoborockCommand + + +@dataclass +class FakeTraitData(RoborockBase): + """Arbitrary data container for testing purposes.""" + + fake_field: int | None = None + other_field: str | None = None + + +class FakeTrait(FakeTraitData, V1TraitMixin): + """Arbitrary trait for testing purposes.""" + + _rpc_channel: AsyncMock + + # Arbitrary command used for testing. + command = RoborockCommand.APP_GET_INIT_STATUS + + def __init__(self): + super().__init__() + self._rpc_channel = AsyncMock() + self.converter = DefaultConverter(FakeTraitData) + + +async def test_fake_trait_bad_payload() -> None: + """Test that parsing a bad payload throws a helpful parsing error.""" + trait = FakeTrait() + trait._rpc_channel.send_command.return_value = ["abc"] + + with pytest.raises( + RoborockParsingException, + match=r"Failed to parse APP_GET_INIT_STATUS response for FakeTrait. Payload: .*ValueError.*", + ): + await trait.refresh() + + +async def test_valid_payload() -> None: + """Test that a valid payload is parsed successfully into trait fields.""" + trait = FakeTrait() + trait._rpc_channel.send_command.return_value = [{"fake_field": 123, "other_field": "abc"}] + await trait.refresh() + assert trait.fake_field == 123 + assert trait.other_field == "abc" diff --git a/tests/devices/traits/v1/test_status.py b/tests/devices/traits/v1/test_status.py index a308dbca..b0f32ce4 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -13,7 +13,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.device_features import DeviceFeaturesTrait from roborock.devices.traits.v1.status import StatusTrait -from roborock.exceptions import RoborockException +from roborock.exceptions import RoborockException, RoborockParsingException from roborock.roborock_typing import RoborockCommand from tests import mock_data from tests.mock_data import STATUS @@ -60,10 +60,10 @@ async def test_refresh_status_propagates_exception(status_trait: StatusTrait, mo async def test_refresh_status_invalid_format(status_trait: StatusTrait, mock_rpc_channel: AsyncMock) -> None: - """Test that invalid response format raises ValueError.""" + """Test that invalid response format raises RoborockParsingException.""" mock_rpc_channel.send_command.return_value = "invalid" - with pytest.raises(ValueError, match="Unexpected StatusV2 response format"): + with pytest.raises(RoborockParsingException, match="Unexpected StatusV2 response format"): await status_trait.refresh()