mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Revert images functionality in Home Connect (#180933)
This commit is contained in:
committed by
Bram Kragten
parent
e9dd5befeb
commit
47295cb2d7
@@ -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}"
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user