"""Test Roborock Image platform.""" import copy from datetime import timedelta from http import HTTPStatus from unittest.mock import patch from PIL import Image import pytest from roborock import RoborockException from roborock.data import RoborockStateCode from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN from homeassistant.components.roborock.const import V1_LOCAL_NOT_CLEANING_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .mock_data import MAP_DATA, PROP from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @pytest.fixture def platforms() -> list[Platform]: """Fixture to set platforms used in the test.""" return [Platform.IMAGE] async def test_floorplan_image( hass: HomeAssistant, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test floor plan map image is correctly set up.""" assert len(hass.states.async_all("image")) == 4 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None # Load the image on demand client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None assert body[0:4] == b"\x89PNG" # Call a second time - this time forcing it to update - and save new image now = dt_util.utcnow() + timedelta(minutes=61) # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 new_map_data = copy.deepcopy(MAP_DATA) new_map_data.image = ImageData( 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (2, 2)), lambda p: p ) with ( patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", return_value=prop, ), patch( "homeassistant.components.roborock.coordinator.dt_util.utcnow", return_value=now, ), patch( "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=MAP_DATA, ) as parse_map, ): # This should call parse_map twice as the both devices are in cleaning. async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK resp = await client.get("/api/image_proxy/image.roborock_s7_2_upstairs") assert resp.status == HTTPStatus.OK resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_downstairs") assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None assert parse_map.call_count == 2 async def test_floorplan_image_failed_parse( hass: HomeAssistant, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test that we correctly handle getting None from the image parser.""" client = await hass_client() map_data = copy.deepcopy(MAP_DATA) map_data.image = None now = dt_util.utcnow() + timedelta(seconds=91) # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state # Update image, but get none for parse image. with ( patch( "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", return_value=prop, ), patch( "homeassistant.components.roborock.coordinator.dt_util.utcnow", return_value=now, ), ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") # The map should load fine from the coordinator, but it should not update the # last_updated timestamp. assert resp.ok assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state async def test_fail_to_save_image( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_roborock_entry: MockConfigEntry, bypass_api_fixture, caplog: pytest.LogCaptureFixture, ) -> None: """Test that we gracefully handle a oserror on saving an image.""" await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() # Ensure that map is still working properly. assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") # Test that we can get the image and it correctly serialized and unserialized. assert resp.status == HTTPStatus.OK with patch( "homeassistant.components.roborock.roborock_storage.Path.write_bytes", side_effect=OSError, ): await hass.config_entries.async_unload(mock_roborock_entry.entry_id) assert "Unable to write map file" in caplog.text # Config entry is unloaded successfully assert mock_roborock_entry.state is ConfigEntryState.NOT_LOADED async def test_fail_to_load_image( hass: HomeAssistant, hass_client: ClientSessionGenerator, setup_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: """Test that we gracefully handle failing to load an image.""" with ( patch( "homeassistant.components.roborock.roborock_storage.Path.exists", return_value=True, ), patch( "homeassistant.components.roborock.roborock_storage.Path.read_bytes", side_effect=OSError, ) as read_bytes, patch( "homeassistant.components.roborock.coordinator.RoborockDataUpdateCoordinator.refresh_coordinator_map" ), ): # Reload the config entry so that the map is saved in storage and entities exist. await hass.config_entries.async_reload(setup_entry.entry_id) await hass.async_block_till_done() assert read_bytes.call_count == 4 assert "Unable to read map file" in caplog.text async def test_fail_parse_on_startup( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_roborock_entry: MockConfigEntry, bypass_api_fixture, ) -> None: """Test that if we fail parsing on startup, we still create the entity.""" map_data = copy.deepcopy(MAP_DATA) map_data.image = None with patch( "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ): await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert ( image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") ) is not None assert image_entity.state async def test_fail_get_map_on_startup( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_roborock_entry: MockConfigEntry, bypass_api_fixture, ) -> None: """Test that if we fail getting map on startup, we can still create the entity.""" with ( patch( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", return_value=None, ), ): await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert ( image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") ) is not None assert image_entity.state async def test_fail_updating_image( hass: HomeAssistant, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test that we handle failing getting the image after it has already been setup..""" client = await hass_client() map_data = copy.deepcopy(MAP_DATA) map_data.image = None now = dt_util.utcnow() + timedelta(seconds=91) # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 # Update image, but get none for parse image. previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state with ( patch( "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", return_value=prop, ), patch( "homeassistant.components.roborock.coordinator.dt_util.utcnow", return_value=now, ), patch( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", side_effect=RoborockException, ), ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") # The map should load fine from the coordinator, but it should not update the # last_updated timestamp. assert resp.ok assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state async def test_index_error_map( hass: HomeAssistant, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test that we handle failing getting the image after it has already been setup with a indexerror.""" client = await hass_client() now = dt_util.utcnow() + timedelta(seconds=91) # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state # Update image, but get IndexError for image. with ( patch( "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", side_effect=IndexError, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", return_value=prop, ), patch( "homeassistant.components.roborock.coordinator.dt_util.utcnow", return_value=now, ), ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") # The map should load fine from the coordinator, but it should not update the # last_updated timestamp. assert resp.ok assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state async def test_map_status_change( hass: HomeAssistant, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test floor plan map image is correctly updated on status change.""" assert len(hass.states.async_all("image")) == 4 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK old_body = await resp.read() assert old_body[0:4] == b"\x89PNG" # Call a second time. This interval does not directly trigger a map update, but does # trigger a status update which detects the state has changed and uddates the map now = dt_util.utcnow() + V1_LOCAL_NOT_CLEANING_INTERVAL # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.state = RoborockStateCode.docking new_map_data = copy.deepcopy(MAP_DATA) new_map_data.image = ImageData( 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (2, 2)), lambda p: p ) with ( patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", return_value=prop, ), patch( "homeassistant.components.roborock.coordinator.dt_util.utcnow", return_value=now, ), patch( "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=new_map_data, ), ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None assert body != old_body