Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion roborock/data/v1/v1_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion roborock/devices/traits/v1/clean_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
13 changes: 11 additions & 2 deletions roborock/devices/traits/v1/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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]


Expand Down
18 changes: 16 additions & 2 deletions roborock/devices/traits/v1/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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__,
Comment thread
allenporter marked this conversation as resolved.
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)

Expand Down
14 changes: 14 additions & 0 deletions roborock/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Roborock exceptions."""

from enum import Enum
from typing import Any


class RoborockException(Exception):
"""Class for Roborock exceptions."""
Expand Down Expand Up @@ -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)
21 changes: 21 additions & 0 deletions tests/data/v1/test_v1_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ""
52 changes: 52 additions & 0 deletions tests/devices/traits/v1/test_common.py
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 3 additions & 3 deletions tests/devices/traits/v1/test_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()


Expand Down
Loading