From e6b37018fc988d439c386f05f2cef81471bcd885 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 20 Sep 2026 19:10:57 +0000 Subject: [PATCH] fix: offload map parsing to worker thread to prevent event loop blocking Offload CPU-intensive map parsing and rendering to worker threads via `asyncio.to_thread` to prevent blocking the asyncio event loop. - In `MapContentTrait.refresh()`, offload `self.converter.convert` to `asyncio.to_thread`. - In `HomeTrait.discover_home()`, move cached map parsing to a helper function `_parse_cached_home_map_content` and run it via `asyncio.to_thread`. - Avoid reparsing cached maps in `HomeTrait` when raw bytes are unchanged. Addresses https://github.com/home-assistant/core/issues/182656 --- roborock/devices/traits/v1/home.py | 2 +- roborock/devices/traits/v1/map_content.py | 34 +++++++++++++++ tests/devices/traits/v1/test_map_content.py | 48 +++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) 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