Revert images functionality in Home Connect (#180933)

This commit is contained in:
J. Diego Rodríguez Royo
2026-09-01 11:33:50 +02:00
committed by Bram Kragten
parent e9dd5befeb
commit 47295cb2d7
10 changed files with 29 additions and 613 deletions
@@ -43,7 +43,6 @@ PLATFORMS = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.FAN,
Platform.IMAGE,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,
@@ -1,7 +1,7 @@
"""Common callbacks for all Home Connect platforms."""
from collections import defaultdict
from collections.abc import Callable, Sequence
from collections.abc import Callable
from functools import partial
from typing import cast
@@ -10,7 +10,7 @@ from aiohomeconnect.model import EventKey
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -48,7 +48,7 @@ def _create_option_entities(
known_entity_unique_ids: dict[str, str],
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
Sequence[HomeConnectEntity],
list[HomeConnectEntity],
],
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
@@ -74,11 +74,11 @@ def _handle_paired_or_connected_appliance(
entry: HomeConnectConfigEntry,
known_entity_unique_ids: dict[str, str],
get_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator], Sequence[Entity]
[HomeConnectApplianceCoordinator], list[HomeConnectEntity]
],
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
Sequence[HomeConnectEntity],
list[HomeConnectEntity],
]
| None,
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
@@ -91,7 +91,7 @@ def _handle_paired_or_connected_appliance(
when they are turned off, so we need to check if the entities have been added
already or it is the first time we see them when the appliance is connected.
"""
entities: list[Entity] = []
entities: list[HomeConnectEntity] = []
entity_registry = er.async_get(hass)
for appliance_coordinator in entry.runtime_data.appliance_coordinators.values():
appliance_ha_id = appliance_coordinator.data.info.ha_id
@@ -161,12 +161,12 @@ def setup_home_connect_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
get_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator], Sequence[Entity]
[HomeConnectApplianceCoordinator], list[HomeConnectEntity]
],
async_add_entities: AddConfigEntryEntitiesCallback,
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
Sequence[HomeConnectEntity],
list[HomeConnectEntity],
]
| None = None,
) -> None:
@@ -2,7 +2,7 @@
from collections.abc import Mapping
import logging
from typing import Any, Final, override
from typing import Any, override
import jwt
import voluptuous as vol
@@ -13,8 +13,6 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN
INPUT_IMAGES_SCOPE: Final = "images_scope"
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
@@ -25,49 +23,12 @@ class OAuth2FlowHandler(
MINOR_VERSION = 3
images_scope: bool | None = None
@property
@override
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
@override
def extra_authorize_data(self) -> dict[str, str]:
return {
"scope": (
"Control Monitor Settings"
f" IdentifyAppliance{' Images' if self.images_scope else ''}"
),
}
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow start."""
return await self.async_step_scopes(user_input)
async def async_step_scopes(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Ask for the scopes to use."""
if user_input is not None:
self.images_scope = user_input[INPUT_IMAGES_SCOPE]
if self.images_scope is not None:
return await self.async_step_pick_implementation(None)
return self.async_show_form(
step_id="scopes",
data_schema=vol.Schema(
{
vol.Required(INPUT_IMAGES_SCOPE): bool,
}
),
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
@@ -29,7 +29,6 @@ from aiohomeconnect.model.error import (
TooManyRequestsError,
UnauthorizedError,
)
from aiohomeconnect.model.image import Image
from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption
from homeassistant.config_entries import ConfigEntry
@@ -66,7 +65,6 @@ class HomeConnectApplianceData:
programs: list[EnumerateProgram]
settings: dict[SettingKey, GetSetting]
status: dict[StatusKey, Status]
images: dict[str, Image]
def update(self, other: HomeConnectApplianceData) -> None:
"""Update data with data from other instance."""
@@ -80,7 +78,6 @@ class HomeConnectApplianceData:
self.programs.extend(other.programs)
self.settings.update(other.settings)
self.status.update(other.status)
self.images.update(other.images)
@classmethod
def empty(cls, appliance: HomeAppliance) -> HomeConnectApplianceData:
@@ -93,7 +90,6 @@ class HomeConnectApplianceData:
programs=[],
settings={},
status={},
images={},
)
@@ -272,10 +268,6 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance
self.global_listeners = global_listeners
self.data = HomeConnectApplianceData.empty(appliance)
self._execution_tracker: list[float] = []
self.should_fetch_images = "Images" in self._config_entry.data["token"].get(
"scope", ""
)
self._image_listeners: dict[str, list[CALLBACK_TYPE]] = {}
def _get_listeners_for_event_key(self, event_key: EventKey) -> list[CALLBACK_TYPE]:
return [
@@ -575,18 +567,6 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance
except HomeConnectError:
commands = set()
try:
images = await self.get_latest_images() if self.should_fetch_images else {}
except TooManyRequestsError:
raise
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching images for %s: %s",
appliance.ha_id,
error,
)
images = {}
self.data.update(
HomeConnectApplianceData(
commands=commands,
@@ -596,7 +576,6 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance
programs=programs,
settings=settings,
status=status,
images=images,
)
)
@@ -667,59 +646,6 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance
for listener in self._get_listeners_for_event_key(EventKey(option_key)):
listener()
async def get_latest_images(self) -> dict[str, Image]:
"""Get the latest images for the appliance."""
try:
new_images = await self.client.get_images(self.data.info.ha_id)
except TooManyRequestsError:
raise
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching images for %s: %s",
self.data.info.ha_id,
error,
)
return {}
latest_images: dict[str, Image] = {}
for image in new_images.images:
if (
existing_image := latest_images.get(image.key)
) is None or image.timestamp > existing_image.timestamp:
latest_images[image.key] = image
return latest_images
async def update_images(self) -> None:
"""Update images for appliance."""
old_images = self.data.images.copy()
self.data.images.update(await self.get_latest_images())
for image_key, image in self.data.images.items():
if image.image_key != old_images[image_key].image_key:
for listener in self._image_listeners.get(image_key, []):
listener()
def add_image_listener(
self, image_key: str, update_callback: CALLBACK_TYPE
) -> Callable[[], None]:
"""Listen for image updates.
These listeners will not be called on refresh.
"""
@callback
def remove_listener() -> None:
"""Remove update listener."""
self._image_listeners[image_key].remove(update_callback)
if not self._image_listeners[image_key]:
del self._image_listeners[image_key]
if image_key not in self._image_listeners:
self._image_listeners[image_key] = []
self._image_listeners[image_key].append(update_callback)
return remove_listener
def refreshed_too_often_recently(self) -> bool:
"""Check if the appliance data hasn't been refreshed too often recently."""
@@ -1,129 +0,0 @@
"""Image entity for Home Connect."""
from typing import override
from aiohomeconnect.model.error import HomeConnectError
from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .common import setup_home_connect_entry
from .const import DOMAIN
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .utils import get_dict_from_home_connect_error
PARALLEL_UPDATES = 1
IMAGES = (
ImageEntityDescription(
key="Refrigeration.Common.EnumType.Compartment.Type.InteriorRightRC",
translation_key="interior_right_camera",
),
ImageEntityDescription(
key="Refrigeration.Common.EnumType.Compartment.Type.DoorRightRC",
translation_key="door_right_camera",
),
)
def _get_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectImageEntity]:
"""Get all entities for an appliance."""
return [
HomeConnectImageEntity(appliance_coordinator, desc)
for desc in IMAGES
if desc.key in appliance_coordinator.data.images
]
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect sensor."""
setup_home_connect_entry(
hass,
entry,
_get_entities_for_appliance,
async_add_entities,
)
class HomeConnectImageEntity(ImageEntity):
"""Image class for Home Connect."""
_attr_has_entity_name = True
_last_image_key_fetched: str | None = None
def __init__(
self,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: ImageEntityDescription,
) -> None:
"""Initialize the entity."""
appliance_ha_id = appliance_coordinator.data.info.ha_id
super().__init__(appliance_coordinator.hass)
self.appliance = appliance_coordinator.data
self.entity_description = desc
self.appliance_coordinator = appliance_coordinator
self._attr_unique_id = f"{appliance_ha_id}-{desc.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, appliance_ha_id)},
)
@override
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
await self.async_fetch_image()
self.async_on_remove(
self.appliance_coordinator.add_image_listener(
self.entity_description.key, self._handle_coordinator_update
)
)
async def async_update(self) -> None:
"""Set the value of the image based on the given value."""
await self.appliance_coordinator.update_images()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.hass.async_create_task(self.async_fetch_image())
async def async_fetch_image(self) -> None:
"""Fetch the image from the Home Connect API if it has changed since the last fetch."""
image_info = self.appliance_coordinator.data.images[self.entity_description.key]
if image_info.image_key == self._last_image_key_fetched:
return
self._last_image_key_fetched = image_info.image_key
self._attr_image_last_updated = dt_util.utc_from_timestamp(
# It is not specified whether the timestamp is in seconds or milliseconds,
# so we check if it is larger than 10^10 (which would indicate milliseconds)
# and convert it to seconds if necessary.
image_info.timestamp / 1000
if image_info.timestamp > 10_000_000_000
else image_info.timestamp
)
try:
image_data = await self.appliance_coordinator.client.get_image(
self.appliance.info.ha_id, image_key=image_info.image_key
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="fetch_image_error",
translation_placeholders=get_dict_from_home_connect_error(err),
) from err
self._cached_image = Image(
content_type="image/jpeg",
content=image_data,
)
self.async_write_ha_state()
@@ -32,16 +32,6 @@
"reauth_confirm": {
"description": "The Home Connect integration needs to re-authenticate your account",
"title": "[%key:common::config_flow::title::reauth%]"
},
"scopes": {
"data": {
"images_scope": "Images scope"
},
"data_description": {
"images_scope": "Allows Home Assistant to access images from your Home Connect devices."
},
"description": "Select the optional scopes you want to enable for Home Connect authentication.",
"title": "Scopes"
}
}
},
@@ -152,14 +142,6 @@
}
}
},
"image": {
"door_right_camera": {
"name": "Door right camera"
},
"interior_right_camera": {
"name": "Interior right camera"
}
},
"light": {
"ambient_light": {
"name": "Ambient light"
@@ -1601,9 +1583,6 @@
"fetch_api_error": {
"message": "Error obtaining data from the API: {error}"
},
"fetch_image_error": {
"message": "Error obtaining image from the API: {error}"
},
"fetch_program_error": {
"message": "Error obtaining the selected or active program: {error}"
},
-20
View File
@@ -12,7 +12,6 @@ from aiohomeconnect.model import (
ArrayOfCommands,
ArrayOfEvents,
ArrayOfHomeAppliances,
ArrayOfImages,
ArrayOfOptions,
ArrayOfPrograms,
ArrayOfSettings,
@@ -79,7 +78,6 @@ def mock_token_entry(token_expiration_time: float) -> dict[str, Any]:
"access_token": FAKE_ACCESS_TOKEN,
"type": "Bearer",
"expires_at": token_expiration_time,
"scope": "Control Monitor Images Settings IdentifyAppliance",
}
@@ -123,22 +121,6 @@ def mock_config_entry_v1_2(token_entry: dict[str, Any]) -> MockConfigEntry:
)
@pytest.fixture(name="config_entry_no_images_scope")
def mock_config_entry_no_image_scope(token_entry: dict[str, Any]) -> MockConfigEntry:
"""Fixture for a config entry."""
_token_entry = token_entry.copy()
_token_entry["scope"] = "Control Monitor Settings IdentifyAppliance"
return MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": FAKE_AUTH_IMPL,
"token": _token_entry,
},
minor_version=3,
unique_id="1234567890",
)
@pytest.fixture(autouse=True)
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup credentials."""
@@ -442,7 +424,6 @@ def mock_client(
mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect)
mock.get_setting = AsyncMock(side_effect=_get_setting_side_effect)
mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS))
mock.get_images = AsyncMock(return_value=ArrayOfImages([]))
mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect)
mock.get_available_commands = AsyncMock(
side_effect=_get_available_commands_side_effect
@@ -506,7 +487,6 @@ def mock_client_with_exception(
mock.get_settings = AsyncMock(side_effect=exception)
mock.get_setting = AsyncMock(side_effect=exception)
mock.get_status = AsyncMock(side_effect=exception)
mock.get_images = AsyncMock(side_effect=exception)
mock.get_all_programs = AsyncMock(side_effect=exception)
mock.get_available_commands = AsyncMock(side_effect=exception)
mock.put_command = AsyncMock(side_effect=exception)
@@ -3,7 +3,6 @@
from collections.abc import Awaitable, Callable
from http import HTTPStatus
from unittest.mock import MagicMock, patch
from urllib.parse import parse_qsl, urlsplit
from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
from aiohomeconnect.model import HomeAppliance
@@ -26,23 +25,6 @@ from tests.typing import ClientSessionGenerator
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
def assert_authorize_url(url: str, state: str, images_scope: bool | None) -> None:
"""Assert the generated OAuth authorize URL."""
split_url = urlsplit(url)
assert (
f"{split_url.scheme}://{split_url.netloc}{split_url.path}" == OAUTH2_AUTHORIZE
)
assert dict(parse_qsl(split_url.query)) == {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": "https://example.com/auth/external/callback",
"state": state,
"scope": f"Control Monitor Settings IdentifyAppliance{' Images' if images_scope else ''}",
}
DHCP_DISCOVERY = (
DhcpServiceInfo(
ip="1.1.1.1",
@@ -113,14 +95,10 @@ DHCP_DISCOVERY = (
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.parametrize(
"images_scope", [True, False], ids=["images_scope", "no_images_scope"]
)
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
images_scope: bool,
) -> None:
"""Check full flow."""
assert await setup.async_setup_component(hass, "home_connect", {})
@@ -128,13 +106,6 @@ async def test_full_flow(
result = await hass.config_entries.flow.async_init(
DOMAIN, context=ConfigFlowContext(source=config_entries.SOURCE_USER)
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "scopes"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"images_scope": images_scope}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
@@ -144,7 +115,11 @@ async def test_full_flow(
)
assert result["type"] is FlowResultType.EXTERNAL_STEP
assert_authorize_url(result["url"], state, images_scope)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
@@ -186,13 +161,6 @@ async def test_prevent_reconfiguring_same_account(
result = await hass.config_entries.flow.async_init(
DOMAIN, context=ConfigFlowContext(source=config_entries.SOURCE_USER)
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "scopes"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"images_scope": True}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
@@ -202,7 +170,11 @@ async def test_prevent_reconfiguring_same_account(
)
assert result["type"] is FlowResultType.EXTERNAL_STEP
assert_authorize_url(result["url"], state, True)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
@@ -242,13 +214,6 @@ async def test_reauth_flow(
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "scopes"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"images_scope": False}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
@@ -303,13 +268,6 @@ async def test_reauth_flow_with_different_account(
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "scopes"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"images_scope": True}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
@@ -365,13 +323,6 @@ async def test_zeroconf_flow(
result["flow_id"],
{},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "scopes"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"images_scope": True}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
@@ -381,7 +332,11 @@ async def test_zeroconf_flow(
)
assert result["type"] is FlowResultType.EXTERNAL_STEP
assert_authorize_url(result["url"], state, True)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
@@ -451,13 +406,6 @@ async def test_dhcp_flow(
result["flow_id"],
{},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "scopes"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"images_scope": True}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
@@ -466,7 +414,11 @@ async def test_dhcp_flow(
},
)
assert result["type"] is FlowResultType.EXTERNAL_STEP
assert_authorize_url(result["url"], state, True)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
@@ -77,7 +77,6 @@ INITIAL_FETCH_CLIENT_METHODS = [
"get_all_programs",
"get_available_commands",
"get_available_program",
"get_images",
]
@@ -1100,27 +1099,3 @@ async def test_option_values_kept_after_changing_program(
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, "on")
async def test_images_not_fetched_if_no_images_scope(
hass: HomeAssistant,
client: MagicMock,
config_entry_no_images_scope: MockConfigEntry,
platforms: list[str],
) -> None:
"""Test that images are not fetched if the images scope is not granted."""
config_entry_no_images_scope.add_to_hass(hass)
assert config_entry_no_images_scope.state is ConfigEntryState.NOT_LOADED
with (
patch("homeassistant.components.home_connect.PLATFORMS", platforms),
patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock,
):
client_mock.return_value = client
assert await hass.config_entries.async_setup(
config_entry_no_images_scope.entry_id
)
await hass.async_block_till_done()
assert config_entry_no_images_scope.state is ConfigEntryState.LOADED
client.get_images.assert_not_awaited()
-227
View File
@@ -1,227 +0,0 @@
"""Tests for home_connect image entities."""
from collections.abc import Awaitable, Callable
from unittest.mock import AsyncMock, MagicMock
from aiohomeconnect.model import ArrayOfEvents, EventMessage, EventType, HomeAppliance
from aiohomeconnect.model.image import ArrayOfImages, Image
import pytest
from homeassistant.components.home_connect.const import DOMAIN
from homeassistant.components.homeassistant import (
DOMAIN as HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, EntityStateAttribute, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
from tests.typing import ClientSessionGenerator
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.IMAGE]
@pytest.fixture(autouse=True)
def images(client: MagicMock) -> list[Image]:
"""Fixture to inject and return the default image entities."""
images = [
Image(
key="Refrigeration.Common.EnumType.Compartment.Type.InteriorRightRC",
image_key="image_key_1",
preview_image_key="preview_image_key_1",
timestamp=1785974400000,
quality="good",
),
Image(
key="Refrigeration.Common.EnumType.Compartment.Type.DoorRightRC",
image_key="image_key_2",
preview_image_key="preview_image_key_2",
timestamp=1785974400000,
quality="good",
),
]
client.get_images = AsyncMock(return_value=ArrayOfImages(images))
return images
@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True)
async def test_paired_depaired_devices_flow(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
) -> None:
"""Test device removal and re-addition on API events."""
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
device = device_registry.async_get_device_by_identifier(
(DOMAIN, appliance.ha_id), config_entry.entry_id
)
assert device
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert entity_entries
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.DEPAIRED,
data=ArrayOfEvents([]),
)
]
)
await hass.async_block_till_done()
device = device_registry.async_get_device_by_identifier(
(DOMAIN, appliance.ha_id), config_entry.entry_id
)
assert not device
for entity_entry in entity_entries:
assert not entity_registry.async_get(entity_entry.entity_id)
# Now that all everything related to the device is removed, pair it again
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.PAIRED,
data=ArrayOfEvents([]),
)
]
)
await hass.async_block_till_done()
assert device_registry.async_get_device_by_identifier(
(DOMAIN, appliance.ha_id), config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_registry.async_get(entity_entry.entity_id)
@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True)
async def test_image_functionality(
hass: HomeAssistant,
client: MagicMock,
hass_client: ClientSessionGenerator,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
) -> None:
"""Test that the image entities use the correct image data."""
image_data_dict = {
"image_key_1": b"image_data_1",
"image_key_2": b"image_data_2",
}
async def mock_get_image(_: str, *, image_key: str) -> bytes:
return image_data_dict[image_key]
client.get_image = AsyncMock(wraps=mock_get_image)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
client.get_images.assert_awaited_once_with(appliance.ha_id)
assert client.get_image.await_count == 2
for image_key in image_data_dict:
client.get_image.assert_any_call(appliance.ha_id, image_key=image_key)
_client = await hass_client()
for entity_id, expected_image_data in (
("image.fridgefreezer_interior_right_camera", b"image_data_1"),
("image.fridgefreezer_door_right_camera", b"image_data_2"),
):
state = hass.states.get(entity_id)
assert state
assert state.state == "2026-08-06T00:00:00+00:00"
resp = await _client.get(state.attributes[EntityStateAttribute.ENTITY_PICTURE])
assert resp.status == 200
assert await resp.read() == expected_image_data
@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True)
async def test_update_image_entity_functionality(
hass: HomeAssistant,
client: MagicMock,
hass_client: ClientSessionGenerator,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
) -> None:
"""Test that the image update correctly.
Whenever an entity does update, the integration update any other from the same
appliance y there's an update available.
"""
image_data_dict = {
"image_key_1": b"image_data_1",
"image_key_2": b"image_data_2",
"image_key_3": b"image_data_3",
"image_key_4": b"image_data_4",
}
entity_id = "image.fridgefreezer_interior_right_camera"
async def mock_get_image(_: str, *, image_key: str) -> bytes:
return image_data_dict[image_key]
client.get_image = AsyncMock(wraps=mock_get_image)
await async_setup_component(hass, HA_DOMAIN, {})
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
client.get_images.assert_awaited_once_with(appliance.ha_id)
client.get_images.reset_mock()
client.get_image.reset_mock()
client.get_images.return_value.images.extend(
[
Image(
key="Refrigeration.Common.EnumType.Compartment.Type.InteriorRightRC",
image_key="image_key_3",
preview_image_key="preview_image_key_3",
timestamp=1785978000000,
quality="good",
),
Image(
key="Refrigeration.Common.EnumType.Compartment.Type.DoorRightRC",
image_key="image_key_4",
preview_image_key="preview_image_key_4",
timestamp=1785978000000,
quality="good",
),
]
)
await hass.services.async_call(
HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
client.get_images.assert_awaited_once_with(appliance.ha_id)
assert client.get_image.await_count == 2
client.get_image.assert_any_await(appliance.ha_id, image_key="image_key_3")
client.get_image.assert_any_await(appliance.ha_id, image_key="image_key_4")
for entity_id, expected_image_data in (
("image.fridgefreezer_interior_right_camera", b"image_data_3"),
("image.fridgefreezer_door_right_camera", b"image_data_4"),
):
state = hass.states.get(entity_id)
assert state
assert state.state == "2026-08-06T01:00:00+00:00"
_client = await hass_client()
resp = await _client.get(state.attributes[EntityStateAttribute.ENTITY_PICTURE])
assert resp.status == 200
assert await resp.read() == expected_image_data