From 29d2f1a47ec483dc8ce41085fd827e2140a4171f Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 28 Feb 2026 13:46:16 +1100 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A6=8E=20roborock:=20add=20Q7=20segme?= =?UTF-8?q?nt=20clean=20path=20and=20command=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/roborock/vacuum.py | 42 ++++++++++++++++++ tests/components/roborock/conftest.py | 1 + tests/components/roborock/test_vacuum.py | 47 +++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 45d837f3b948d8..91f100b0dcaab6 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -308,6 +308,7 @@ class RoborockQ7Vacuum(RoborockCoordinatedEntityB01Q7, StateVacuumEntity): | VacuumEntityFeature.LOCATE | VacuumEntityFeature.STATE | VacuumEntityFeature.START + | VacuumEntityFeature.CLEAN_AREA ) _attr_translation_key = DOMAIN _attr_name = None @@ -422,6 +423,37 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: }, ) from err + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Clean the specified room ids on Q7 devices. + + Q7 uses room ids directly. We accept either "12" or "map_12"/"1_12" + formatted segment identifiers and normalize to integers. + """ + room_ids: list[int] = [] + for seg_id in segment_ids: + try: + room_ids.append(int(seg_id.split("_")[-1])) + except ValueError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="segment_id_parse_error", + translation_placeholders={"segment_id": seg_id}, + ) from err + + if not room_ids: + return + + try: + await self.coordinator.api.clean_segments(room_ids) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "clean_segments", + }, + ) from err + async def async_send_command( self, command: str, @@ -430,6 +462,16 @@ async def async_send_command( ) -> None: """Send a command to a vacuum cleaner.""" try: + if command == "app_segment_clean" and isinstance(params, list) and params: + first_param = params[0] + if isinstance(first_param, dict) and isinstance( + first_param.get("segments"), list + ): + await self.async_clean_segments( + [str(segment) for segment in first_param["segments"]] + ) + return + await self.coordinator.api.send(command, params) except RoborockException as err: raise HomeAssistantError( diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 08c75f234e96c4..7bede8402a310b 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -158,6 +158,7 @@ async def return_to_dock_side_effect(): b01_trait.set_fan_speed = AsyncMock() b01_trait.set_mode = AsyncMock() b01_trait.set_water_level = AsyncMock() + b01_trait.clean_segments = AsyncMock() b01_trait.send = AsyncMock() return b01_trait diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index e88de6b178d269..b634061be1b5aa 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -497,6 +497,7 @@ def fake_q7_vacuum_api_fixture( api.return_to_dock.side_effect = send_message_exception api.find_me.side_effect = send_message_exception api.set_fan_speed.side_effect = send_message_exception + api.clean_segments.side_effect = send_message_exception api.send.side_effect = send_message_exception return api @@ -620,6 +621,51 @@ async def test_q7_send_command( assert q7_vacuum_api.send.call_args[0] == ("test_command", None) +async def test_q7_clean_segments_with_clean_area( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q7_vacuum_api: Mock, +) -> None: + """Test cleaning Q7 segments via the clean_area service.""" + vacuum = hass.states.get(Q7_ENTITY_ID) + assert vacuum + + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_CLEAN_AREA, + {ATTR_ENTITY_ID: Q7_ENTITY_ID, "segments": ["10", "1_11"]}, + blocking=True, + ) + + assert q7_vacuum_api.clean_segments.call_count == 1 + assert q7_vacuum_api.clean_segments.call_args[0] == ([10, 11],) + + +async def test_q7_app_segment_clean_alias_routes_to_clean_segments( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q7_vacuum_api: Mock, +) -> None: + """Test APP_SEGMENT_CLEAN style send_command is normalized for Q7.""" + vacuum = hass.states.get(Q7_ENTITY_ID) + assert vacuum + + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: Q7_ENTITY_ID, + "command": "app_segment_clean", + "params": [{"segments": [10, "11"]}], + }, + blocking=True, + ) + + assert q7_vacuum_api.clean_segments.call_count == 1 + assert q7_vacuum_api.clean_segments.call_args[0] == ([10, 11],) + assert q7_vacuum_api.send.call_count == 0 + + @pytest.mark.parametrize( ("service", "api_method", "service_params"), [ @@ -629,6 +675,7 @@ async def test_q7_send_command( (SERVICE_RETURN_TO_BASE, "return_to_dock", None), (SERVICE_LOCATE, "find_me", None), (SERVICE_SET_FAN_SPEED, "set_fan_speed", {"fan_speed": "quiet"}), + (SERVICE_CLEAN_AREA, "clean_segments", {"segments": ["10"]}), (SERVICE_SEND_COMMAND, "send", {"command": "test_command"}), ], ) From 8ddd0abb78bb13c8dfb35b308802fb9bbcb9fbda Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 28 Feb 2026 14:51:37 +1100 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A6=8E=20q7:=20add=20B01=20map=20imag?= =?UTF-8?q?e=20entity=20wired=20to=20map=5Fcontent=20trait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/roborock/image.py | 66 +++++++++++++++++----- tests/components/roborock/conftest.py | 3 + tests/components/roborock/test_image.py | 27 ++++++++- 3 files changed, 79 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index c03c49dabfc565..39a52a031e5002 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -6,6 +6,8 @@ from roborock.devices.traits.v1.home import HomeTrait from roborock.devices.traits.v1.map_content import MapContent +from homeassistant.util import dt as dt_util + from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -13,8 +15,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator -from .entity import RoborockCoordinatedEntityV1 +from .coordinator import ( + RoborockB01Q7UpdateCoordinator, + RoborockConfigEntry, + RoborockDataUpdateCoordinator, +) +from .entity import RoborockCoordinatedEntityB01Q7, RoborockCoordinatedEntityV1 _LOGGER = logging.getLogger(__name__) @@ -28,20 +34,24 @@ async def async_setup_entry( ) -> None: """Set up Roborock image platform.""" - async_add_entities( - ( - RoborockMap( - config_entry, - coord, - coord.properties_api.home, - map_info.map_flag, - map_info.name, - ) - for coord in config_entry.runtime_data.v1 - if coord.properties_api.home is not None - for map_info in (coord.properties_api.home.home_map_info or {}).values() - ), + entities = [ + RoborockMap( + config_entry, + coord, + coord.properties_api.home, + map_info.map_flag, + map_info.name, + ) + for coord in config_entry.runtime_data.v1 + if coord.properties_api.home is not None + for map_info in (coord.properties_api.home.home_map_info or {}).values() + ] + entities.extend( + RoborockQ7Map(config_entry, coord) + for coord in config_entry.runtime_data.b01_q7 + if getattr(coord.api, "map_content", None) is not None ) + async_add_entities(entities) class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): @@ -106,3 +116,29 @@ async def async_image(self) -> bytes | None: if (map_content := self._map_content) is None: raise HomeAssistantError("Map flag not found in coordinator maps") return map_content.image_content + + +class RoborockQ7Map(RoborockCoordinatedEntityB01Q7, ImageEntity): + """Image entity for B01/Q7 map content.""" + + _attr_has_entity_name = True + + def __init__(self, config_entry: ConfigEntry, coordinator: RoborockB01Q7UpdateCoordinator) -> None: + unique_id = f"{coordinator.duid_slug}_map_current" + RoborockCoordinatedEntityB01Q7.__init__(self, unique_id, coordinator) + ImageEntity.__init__(self, coordinator.hass) + self.config_entry = config_entry + self._attr_name = "Current map" + self._attr_entity_category = EntityCategory.DIAGNOSTIC + self._cached_map: bytes | None = None + self._attr_image_last_updated = dt_util.utcnow() + + async def async_image(self) -> bytes | None: + map_content_trait = self.coordinator.api.map_content + map_content = await map_content_trait.refresh() + if map_content.image_content is None: + raise HomeAssistantError("No map image content available") + if self._cached_map != map_content.image_content: + self._cached_map = map_content.image_content + self._attr_image_last_updated = dt_util.utcnow() + return map_content.image_content diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 7bede8402a310b..3044cfc5d86bdc 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -160,6 +160,9 @@ async def return_to_dock_side_effect(): b01_trait.set_water_level = AsyncMock() b01_trait.clean_segments = AsyncMock() b01_trait.send = AsyncMock() + + b01_trait.map_content = AsyncMock() + b01_trait.map_content.refresh = AsyncMock(return_value=MapContent(image_content=b"\x89PNG-Q7", map_data=None)) return b01_trait diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index ffdc43fc639951..780b744e74a8d0 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -45,7 +45,7 @@ async def test_floorplan_image( fake_devices: list[FakeDevice], ) -> None: """Test floor plan map image is correctly set up.""" - assert len(hass.states.async_all("image")) == 4 + assert len(hass.states.async_all("image")) == 5 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None # Load the image on demand @@ -130,7 +130,7 @@ async def test_map_status_change( fake_vacuum: FakeDevice, ) -> None: """Test floor plan map image is correctly updated on status change.""" - assert len(hass.states.async_all("image")) == 4 + assert len(hass.states.async_all("image")) == 5 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None client = await hass_client() @@ -176,6 +176,7 @@ async def test_map_status_change( ( MULTI_MAP_LIST, { + "image.roborock_q7_current_map", "image.roborock_s7_2_downstairs", "image.roborock_s7_2_upstairs", "image.roborock_s7_maxv_downstairs", @@ -185,6 +186,7 @@ async def test_map_status_change( ( MULTI_MAP_LIST_NO_MAP_NAMES, { + "image.roborock_q7_current_map", "image.roborock_s7_2_downstairs", "image.roborock_s7_2_upstairs", # Expect default names based on map flags @@ -221,3 +223,24 @@ async def test_image_entity_naming( assert { state.entity_id for state in hass.states.async_all("image") } == expected_entity_ids + + +async def test_q7_map_image_entity( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + fake_devices: list[FakeDevice], +) -> None: + """Test B01/Q7 image entity is created and serves PNG bytes.""" + client = await hass_client() + state = hass.states.get("image.roborock_q7_current_map") + assert state is not None + + resp = await client.get("/api/image_proxy/image.roborock_q7_current_map") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"\x89PNG-Q7" + + q7 = next(device for device in fake_devices if device.name == "Roborock Q7") + assert q7.b01_q7_properties is not None + assert q7.b01_q7_properties.map_content.refresh.call_count >= 1 From 44feff6a4cecc8d03a00cdeb7a02208277310994 Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 28 Feb 2026 16:53:06 +1100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A6=8E=20roborock:=20fix=20Q7=20clean?= =?UTF-8?q?=5Farea=20capability=20+=20test=20schema=20+=20alias=20passthro?= =?UTF-8?q?ugh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/roborock/vacuum.py | 31 ++++++++- tests/components/roborock/conftest.py | 4 +- tests/components/roborock/test_vacuum.py | 75 ++++++++++++++++++++- 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 91f100b0dcaab6..acc207c4c240d2 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -423,6 +423,30 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: }, ) from err + async def async_get_segments(self) -> list[Segment]: + """Get the segments/rooms that can be cleaned on Q7 devices.""" + map_content_trait = self.coordinator.api.map_content + if map_content_trait is None: + return [] + + try: + map_content = await map_content_trait.refresh() + except RoborockException as err: + _LOGGER.debug("Failed to refresh Q7 map content: %s", err) + return [] + + if not map_content.rooms: + return [] + + return [ + Segment( + id=str(room_id), + name=room_name, + group="Current map", + ) + for room_id, room_name in sorted(map_content.rooms.items()) + ] + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: """Clean the specified room ids on Q7 devices. @@ -464,8 +488,11 @@ async def async_send_command( try: if command == "app_segment_clean" and isinstance(params, list) and params: first_param = params[0] - if isinstance(first_param, dict) and isinstance( - first_param.get("segments"), list + if ( + len(params) == 1 + and isinstance(first_param, dict) + and isinstance(first_param.get("segments"), list) + and set(first_param) <= {"segments"} ): await self.async_clean_segments( [str(segment) for segment in first_param["segments"]] diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 3044cfc5d86bdc..27e27719a885d4 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -162,7 +162,9 @@ async def return_to_dock_side_effect(): b01_trait.send = AsyncMock() b01_trait.map_content = AsyncMock() - b01_trait.map_content.refresh = AsyncMock(return_value=MapContent(image_content=b"\x89PNG-Q7", map_data=None)) + map_content = MapContent(image_content=b"\x89PNG-Q7", map_data=None) + map_content.rooms = {10: "room1", 11: "room2"} + b01_trait.map_content.refresh = AsyncMock(return_value=map_content) return b01_trait diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index b634061be1b5aa..41d75598525fcf 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -625,15 +625,28 @@ async def test_q7_clean_segments_with_clean_area( hass: HomeAssistant, setup_entry: MockConfigEntry, q7_vacuum_api: Mock, + entity_registry: er.EntityRegistry, ) -> None: """Test cleaning Q7 segments via the clean_area service.""" vacuum = hass.states.get(Q7_ENTITY_ID) assert vacuum + entity_registry.async_update_entity_options( + Q7_ENTITY_ID, + VACUUM_DOMAIN, + { + "area_mapping": {"area_1": ["10", "1_11"]}, + "last_seen_segments": [ + {"id": "10", "name": "room1", "group": "Current map"}, + {"id": "11", "name": "room2", "group": "Current map"}, + ], + }, + ) + await hass.services.async_call( VACUUM_DOMAIN, SERVICE_CLEAN_AREA, - {ATTR_ENTITY_ID: Q7_ENTITY_ID, "segments": ["10", "1_11"]}, + {ATTR_ENTITY_ID: Q7_ENTITY_ID, "cleaning_area_id": ["area_1"]}, blocking=True, ) @@ -641,6 +654,26 @@ async def test_q7_clean_segments_with_clean_area( assert q7_vacuum_api.clean_segments.call_args[0] == ([10, 11],) +async def test_q7_get_segments_returns_rooms_from_map_content( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q7_vacuum_api: Mock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that Q7 async_get_segments returns room ids from map content.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "vacuum/get_segments", "entity_id": Q7_ENTITY_ID} + ) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"]["segments"] == [ + {"id": "10", "name": "room1", "group": "Current map"}, + {"id": "11", "name": "room2", "group": "Current map"}, + ] + + async def test_q7_app_segment_clean_alias_routes_to_clean_segments( hass: HomeAssistant, setup_entry: MockConfigEntry, @@ -666,6 +699,31 @@ async def test_q7_app_segment_clean_alias_routes_to_clean_segments( assert q7_vacuum_api.send.call_count == 0 +async def test_q7_app_segment_clean_with_extra_payload_passthrough( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + q7_vacuum_api: Mock, +) -> None: + """Test APP_SEGMENT_CLEAN payload with extra keys is passed through unchanged.""" + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: Q7_ENTITY_ID, + "command": "app_segment_clean", + "params": [{"segments": [10], "repeat": 2}], + }, + blocking=True, + ) + + assert q7_vacuum_api.clean_segments.call_count == 0 + assert q7_vacuum_api.send.call_count == 1 + assert q7_vacuum_api.send.call_args[0] == ( + "app_segment_clean", + [{"segments": [10], "repeat": 2}], + ) + + @pytest.mark.parametrize( ("service", "api_method", "service_params"), [ @@ -675,7 +733,7 @@ async def test_q7_app_segment_clean_alias_routes_to_clean_segments( (SERVICE_RETURN_TO_BASE, "return_to_dock", None), (SERVICE_LOCATE, "find_me", None), (SERVICE_SET_FAN_SPEED, "set_fan_speed", {"fan_speed": "quiet"}), - (SERVICE_CLEAN_AREA, "clean_segments", {"segments": ["10"]}), + (SERVICE_CLEAN_AREA, "clean_segments", {"cleaning_area_id": ["area_1"]}), (SERVICE_SEND_COMMAND, "send", {"command": "test_command"}), ], ) @@ -687,6 +745,7 @@ async def test_q7_failed_commands( api_method: str, service_params: dict[str, Any] | None, q7_vacuum_api: Mock, + entity_registry: er.EntityRegistry, ) -> None: """Test that when Q7 commands fail, we raise HomeAssistantError.""" vacuum = hass.states.get(Q7_ENTITY_ID) @@ -694,6 +753,18 @@ async def test_q7_failed_commands( # Store the original state to verify it doesn't change on error original_state = vacuum.state + if service == SERVICE_CLEAN_AREA: + entity_registry.async_update_entity_options( + Q7_ENTITY_ID, + VACUUM_DOMAIN, + { + "area_mapping": {"area_1": ["10"]}, + "last_seen_segments": [ + {"id": "10", "name": "room1", "group": "Current map"} + ], + }, + ) + data = {ATTR_ENTITY_ID: Q7_ENTITY_ID, **(service_params or {})} command_name = ( service_params.get("command", api_method) if service_params else api_method