Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 10 additions & 1 deletion 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 @@ -106,7 +107,15 @@ async def refresh(self) -> None:
self._home_data.rooms = updated_rooms
self._discovered_iot_ids.update(new_iot_ids)

rooms = self.converter.convert(response)
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 = 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) -> 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 parsing a bad payload throws a helpful parsing error."""
Comment thread
allenporter marked this conversation as resolved.
Outdated
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