diff --git a/roborock/devices/traits/v1/home.py b/roborock/devices/traits/v1/home.py index afc134ae9..fd723e33d 100644 --- a/roborock/devices/traits/v1/home.py +++ b/roborock/devices/traits/v1/home.py @@ -96,7 +96,7 @@ async def discover_home(self) -> None: self._discovery_completed = True try: self._home_map_content = { - k: self._map_content.converter.parse_map_content(base64.b64decode(v)) + k: await self._map_content.converter.async_parse_map_content(base64.b64decode(v)) for k, v in (device_cache_data.home_map_content_base64 or {}).items() } except (ValueError, RoborockException) as ex: diff --git a/roborock/devices/traits/v1/map_content.py b/roborock/devices/traits/v1/map_content.py index 35b015d63..053b65b67 100644 --- a/roborock/devices/traits/v1/map_content.py +++ b/roborock/devices/traits/v1/map_content.py @@ -1,5 +1,6 @@ """Trait for fetching the map content from Roborock devices.""" +import asyncio import logging from dataclasses import dataclass @@ -7,6 +8,7 @@ from roborock.data import RoborockBase from roborock.devices.traits.v1 import common +from roborock.exceptions import RoborockParsingException from roborock.map.map_parser import MapParser, MapParserConfig from roborock.roborock_typing import RoborockCommand @@ -78,6 +80,20 @@ def parse_map_content(self, response: bytes) -> MapContent: raw_api_response=response, ) + async def async_parse_map_content(self, response: bytes) -> MapContent: + """Parse the map content from raw bytes asynchronously. + + Args: + response: The raw bytes of the map data from the API. + + Returns: + MapContent: The parsed map content. + + Raises: + RoborockException: If the map data cannot be parsed. + """ + return await asyncio.to_thread(self.parse_map_content, response) + @common.map_rpc_channel class MapContentTrait(MapContent, common.V1TraitMixin): @@ -90,3 +106,21 @@ def __init__(self, map_parser_config: MapParserConfig | None = None) -> None: """Initialize MapContentTrait.""" super().__init__() self.converter = MapContentConverter(MapParser(map_parser_config or MapParserConfig())) + + async def refresh(self) -> None: + """Refresh the contents of this trait without blocking the event loop.""" + response = await self.rpc_channel.send_command(self.command) + if self.raw_api_response is not None and self.raw_api_response == response: + return + if not isinstance(response, bytes): + raise ValueError(f"Unexpected MapContentTrait response format: {type(response)}") + try: + new_data = await self.converter.async_parse_map_content(response) + except (TypeError, ValueError) as err: + raise RoborockParsingException( + trait_name=type(self).__name__, + command=self.command, + payload=response, + inner_error=err, + ) from err + common.merge_trait_values(self, new_data) diff --git a/tests/devices/traits/v1/test_map_content.py b/tests/devices/traits/v1/test_map_content.py index 0c0f62de4..c9228b80a 100644 --- a/tests/devices/traits/v1/test_map_content.py +++ b/tests/devices/traits/v1/test_map_content.py @@ -37,3 +37,51 @@ async def test_refresh_map_content_trait( assert map_content_trait.map_data is not None mock_map_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_MAP_V1) + + +async def test_refresh_unchanged_content_skips_parsing( + map_content_trait: MapContentTrait, + mock_map_rpc_channel: AsyncMock, +) -> None: + """Test that refresh avoids re-parsing when the map content is unchanged.""" + map_data = b"dummy_map_bytes" + mock_map_rpc_channel.send_command.return_value = map_data + mock_parsed_data = ParsedMapData( + image_content=b"dummy_image_content", + map_data=MagicMock(), + ) + + with patch("roborock.devices.traits.v1.map_content.MapParser.parse", return_value=mock_parsed_data) as mock_parse: + await map_content_trait.refresh() + assert mock_parse.call_count == 1 + assert map_content_trait.image_content == b"dummy_image_content" + + # Second refresh with identical response should not re-parse + await map_content_trait.refresh() + assert mock_parse.call_count == 1 + + # Third refresh with new response parses again + new_map_data = b"new_map_bytes" + new_parsed_data = ParsedMapData( + image_content=b"new_image_content", + map_data=MagicMock(), + ) + mock_map_rpc_channel.send_command.return_value = new_map_data + mock_parse.return_value = new_parsed_data + await map_content_trait.refresh() + assert mock_parse.call_count == 2 + assert map_content_trait.image_content == b"new_image_content" + + +async def test_async_parse_map_content(map_content_trait: MapContentTrait) -> None: + """Test async_parse_map_content on MapContentConverter.""" + map_data = b"dummy_map_bytes" + mock_parsed_data = ParsedMapData( + image_content=b"dummy_image_content", + map_data=MagicMock(), + ) + with patch("roborock.devices.traits.v1.map_content.MapParser.parse", return_value=mock_parsed_data) as mock_parse: + result = await map_content_trait.converter.async_parse_map_content(map_data) + mock_parse.assert_called_once_with(map_data) + assert result.image_content == b"dummy_image_content" + assert result.raw_api_response == map_data