Skip to content
Closed
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
66 changes: 51 additions & 15 deletions homeassistant/components/roborock/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@
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
from homeassistant.core import HomeAssistant
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__)

Expand All @@ -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):
Expand Down Expand Up @@ -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
69 changes: 69 additions & 0 deletions homeassistant/components/roborock/vacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ class RoborockQ7Vacuum(RoborockCoordinatedEntityB01Q7, StateVacuumEntity):
| VacuumEntityFeature.LOCATE
| VacuumEntityFeature.STATE
| VacuumEntityFeature.START
| VacuumEntityFeature.CLEAN_AREA
Comment thread
arduano marked this conversation as resolved.
Comment thread
arduano marked this conversation as resolved.
)
_attr_translation_key = DOMAIN
_attr_name = None
Expand Down Expand Up @@ -422,6 +423,61 @@ 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.

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,
Expand All @@ -430,6 +486,19 @@ 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 (
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"]]
)
Comment thread
arduano marked this conversation as resolved.
return

await self.coordinator.api.send(command, params)
except RoborockException as err:
raise HomeAssistantError(
Expand Down
6 changes: 6 additions & 0 deletions tests/components/roborock/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,13 @@ 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()

b01_trait.map_content = AsyncMock()
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


Expand Down
27 changes: 25 additions & 2 deletions tests/components/roborock/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading