From 14cd00a116f3b06f9e6fe5932fa14676dc0309c7 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Mon, 26 May 2025 15:40:15 +0200 Subject: [PATCH] Add user picture to fyta (#140934) * Add user picture * FYTA integration: Add separate entities for both default and user plant images (#12) * Refactor FYTA integration to provide both default and user plant images as separate entities * Refactor FYTA tests by removing unused CONF_USER_IMAGE option and related test cases * Update FytaPlantImageEntity to set entity name based on image type * Refactor FYTA image tests to accommodate separate plant and user image entities, updating assertions and snapshots accordingly. * Enhance FYTA image handling by introducing FytaImageEntityDescription for better separation of plant and user images, and update image URL retrieval logic. Additionally, add localized strings for image entities in strings.json. * Correct typo * Update FYTA image snapshots to reflect changes in translation keys for plant and user images. * Update homeassistant/components/fyta/image.py * Update homeassistant/components/fyta/image.py --------- Co-authored-by: dontinelli <73341522+dontinelli@users.noreply.github.com> * Update QS + ruff * Revert MINOR_VERSION increase and remove obsolete migration test * Update snapshot * Resolve comments * Update snapshot * Fix --------- Co-authored-by: Alexander --- homeassistant/components/fyta/__init__.py | 5 +- homeassistant/components/fyta/image.py | 84 +++++++++-- homeassistant/components/fyta/strings.json | 8 + .../fyta/fixtures/plant_status1_update.json | 2 +- .../components/fyta/snapshots/test_image.ambr | 138 +++++++++++++++--- tests/components/fyta/test_image.py | 94 +++++++++++- 6 files changed, 293 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index 1b00afc9c80..2264f341bad 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -84,7 +84,10 @@ async def async_migrate_entry( new[CONF_EXPIRATION] = credentials.expiration.isoformat() hass.config_entries.async_update_entry( - config_entry, data=new, minor_version=2, version=1 + config_entry, + data=new, + minor_version=2, + version=1, ) _LOGGER.debug( diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py index 326f2ddf570..891c0bf53eb 100644 --- a/homeassistant/components/fyta/image.py +++ b/homeassistant/components/fyta/image.py @@ -2,9 +2,20 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime +import logging +from typing import Final -from homeassistant.components.image import ImageEntity, ImageEntityDescription +from fyta_cli.fyta_models import Plant + +from homeassistant.components.image import ( + Image, + ImageEntity, + ImageEntityDescription, + valid_image_content_type, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -12,6 +23,30 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FytaConfigEntry, FytaCoordinator from .entity import FytaPlantEntity +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class FytaImageEntityDescription(ImageEntityDescription): + """Describes Fyta image entity.""" + + url_fn: Callable[[Plant], str] + name_key: str | None = None + + +IMAGES: Final[list[FytaImageEntityDescription]] = [ + FytaImageEntityDescription( + key="plant_image", + translation_key="plant_image", + url_fn=lambda plant: plant.plant_origin_path, + ), + FytaImageEntityDescription( + key="plant_image_user", + translation_key="plant_image_user", + url_fn=lambda plant: plant.user_picture_path, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -21,17 +56,17 @@ async def async_setup_entry( """Set up the FYTA plant images.""" coordinator = entry.runtime_data - description = ImageEntityDescription(key="plant_image") - async_add_entities( FytaPlantImageEntity(coordinator, entry, description, plant_id) for plant_id in coordinator.fyta.plant_list if plant_id in coordinator.data + for description in IMAGES ) def _async_add_new_device(plant_id: int) -> None: async_add_entities( - [FytaPlantImageEntity(coordinator, entry, description, plant_id)] + FytaPlantImageEntity(coordinator, entry, description, plant_id) + for description in IMAGES ) coordinator.new_device_callbacks.append(_async_add_new_device) @@ -40,26 +75,49 @@ async def async_setup_entry( class FytaPlantImageEntity(FytaPlantEntity, ImageEntity): """Represents a Fyta image.""" - entity_description: ImageEntityDescription + entity_description: FytaImageEntityDescription def __init__( self, coordinator: FytaCoordinator, entry: ConfigEntry, - description: ImageEntityDescription, + description: FytaImageEntityDescription, plant_id: int, ) -> None: - """Initiatlize Fyta Image entity.""" + """Initialize Fyta Image entity.""" super().__init__(coordinator, entry, description, plant_id) ImageEntity.__init__(self, coordinator.hass) - self._attr_name = None + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + if self.entity_description.key == "plant_image_user": + if self._cached_image is None: + response = await self.coordinator.fyta.get_plant_image( + self.plant.user_picture_path + ) + _LOGGER.debug("Response of downloading user image: %s", response) + if response is None: + _LOGGER.debug( + "%s: Error getting new image from %s", + self.entity_id, + self.plant.user_picture_path, + ) + return None + + content_type, raw_image = response + self._cached_image = Image( + valid_image_content_type(content_type), raw_image + ) + + return self._cached_image.content + return await ImageEntity.async_image(self) @property def image_url(self) -> str: - """Return the image_url for this sensor.""" - image = self.plant.plant_origin_path - if image != self._attr_image_url: - self._attr_image_last_updated = datetime.now() + """Return the image_url for this plant.""" + url = self.entity_description.url_fn(self.plant) - return image + if url != self._attr_image_url: + self._cached_image = None + self._attr_image_last_updated = datetime.now() + return url diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index a10fa5bfc47..67bb991a437 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -61,6 +61,14 @@ "name": "Sensor update available" } }, + "image": { + "plant_image": { + "name": "Plant image" + }, + "plant_image_user": { + "name": "User image" + } + }, "sensor": { "scientific_name": { "name": "Scientific name" diff --git a/tests/components/fyta/fixtures/plant_status1_update.json b/tests/components/fyta/fixtures/plant_status1_update.json index 5363c5bd290..85f77a014a7 100644 --- a/tests/components/fyta/fixtures/plant_status1_update.json +++ b/tests/components/fyta/fixtures/plant_status1_update.json @@ -25,7 +25,7 @@ "sw_version": "1.0", "status": 1, "online": true, - "origin_path": "http://www.plant_picture.com/user_picture", + "origin_path": "http://www.plant_picture.com/user_picture1", "ph": null, "plant_id": 0, "plant_origin_path": "http://www.plant_picture.com/picture1", diff --git a/tests/components/fyta/snapshots/test_image.ambr b/tests/components/fyta/snapshots/test_image.ambr index cb39efb4500..d36472f91b9 100644 --- a/tests/components/fyta/snapshots/test_image.ambr +++ b/tests/components/fyta/snapshots/test_image.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[image.gummibaum-entry] +# name: test_all_entities[image.gummibaum_plant_image-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,7 +12,7 @@ 'disabled_by': None, 'domain': 'image', 'entity_category': None, - 'entity_id': 'image.gummibaum', + 'entity_id': 'image.gummibaum_plant_image', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24,31 +24,31 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Plant image', 'platform': 'fyta', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'plant_image', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-plant_image', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[image.gummibaum-state] +# name: test_all_entities[image.gummibaum_plant_image-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'access_token': '1', - 'entity_picture': '/api/image_proxy/image.gummibaum?token=1', - 'friendly_name': 'Gummibaum', + 'entity_picture': '/api/image_proxy/image.gummibaum_plant_image?token=1', + 'friendly_name': 'Gummibaum Plant image', }), 'context': , - 'entity_id': 'image.gummibaum', + 'entity_id': 'image.gummibaum_plant_image', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_all_entities[image.kakaobaum-entry] +# name: test_all_entities[image.gummibaum_user_image-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -61,7 +61,7 @@ 'disabled_by': None, 'domain': 'image', 'entity_category': None, - 'entity_id': 'image.kakaobaum', + 'entity_id': 'image.gummibaum_user_image', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -73,27 +73,131 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'User image', 'platform': 'fyta', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-plant_image', + 'translation_key': 'plant_image_user', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-plant_image_user', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[image.kakaobaum-state] +# name: test_all_entities[image.gummibaum_user_image-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'access_token': '1', - 'entity_picture': '/api/image_proxy/image.kakaobaum?token=1', - 'friendly_name': 'Kakaobaum', + 'entity_picture': '/api/image_proxy/image.gummibaum_user_image?token=1', + 'friendly_name': 'Gummibaum User image', }), 'context': , - 'entity_id': 'image.kakaobaum', + 'entity_id': 'image.gummibaum_user_image', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- +# name: test_all_entities[image.kakaobaum_plant_image-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.kakaobaum_plant_image', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plant image', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plant_image', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-plant_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.kakaobaum_plant_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.kakaobaum_plant_image?token=1', + 'friendly_name': 'Kakaobaum Plant image', + }), + 'context': , + 'entity_id': 'image.kakaobaum_plant_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[image.kakaobaum_user_image-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.kakaobaum_user_image', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'User image', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plant_image_user', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-plant_image_user', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.kakaobaum_user_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.kakaobaum_user_image?token=1', + 'friendly_name': 'Kakaobaum User image', + }), + 'context': , + 'entity_id': 'image.kakaobaum_user_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_user_image + None +# --- +# name: test_update_user_image.1 + b'd' +# --- diff --git a/tests/components/fyta/test_image.py b/tests/components/fyta/test_image.py index 93cca1a1c09..2a0c71d68cc 100644 --- a/tests/components/fyta/test_image.py +++ b/tests/components/fyta/test_image.py @@ -1,6 +1,7 @@ """Test the Home Assistant fyta sensor module.""" from datetime import timedelta +from http import HTTPStatus from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory @@ -23,6 +24,7 @@ from tests.common import ( load_json_object_fixture, snapshot_platform, ) +from tests.typing import ClientSessionGenerator async def test_all_entities( @@ -37,7 +39,7 @@ async def test_all_entities( await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - assert len(hass.states.async_all("image")) == 2 + assert len(hass.states.async_all("image")) == 4 @pytest.mark.parametrize( @@ -63,7 +65,8 @@ async def test_connection_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("image.gummibaum").state == STATE_UNAVAILABLE + assert hass.states.get("image.gummibaum_plant_image").state == STATE_UNAVAILABLE + assert hass.states.get("image.gummibaum_user_image").state == STATE_UNAVAILABLE async def test_add_remove_entities( @@ -76,7 +79,8 @@ async def test_add_remove_entities( await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) - assert hass.states.get("image.gummibaum") is not None + assert hass.states.get("image.gummibaum_plant_image") is not None + assert hass.states.get("image.gummibaum_user_image") is not None plants: dict[int, Plant] = { 0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)), @@ -92,8 +96,10 @@ async def test_add_remove_entities( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("image.kakaobaum") is None - assert hass.states.get("image.tomatenpflanze") is not None + assert hass.states.get("image.kakaobaum_plant_image") is None + assert hass.states.get("image.kakaobaum_user_image") is None + assert hass.states.get("image.tomatenpflanze_plant_image") is not None + assert hass.states.get("image.tomatenpflanze_user_image") is not None async def test_update_image( @@ -106,7 +112,10 @@ async def test_update_image( await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) - image_entity: ImageEntity = hass.data["domain_entities"]["image"]["image.gummibaum"] + image_entity: ImageEntity = hass.data["domain_entities"]["image"][ + "image.gummibaum_plant_image" + ] + image_state_1 = hass.states.get("image.gummibaum_plant_image") assert image_entity.image_url == "http://www.plant_picture.com/picture" @@ -126,4 +135,77 @@ async def test_update_image( async_fire_time_changed(hass) await hass.async_block_till_done() + image_state_2 = hass.states.get("image.gummibaum_plant_image") + assert image_entity.image_url == "http://www.plant_picture.com/picture1" + assert image_state_1 != image_state_2 + + +async def test_update_user_image_error( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test error during user picture update.""" + + mock_fyta_connector.get_plant_image.return_value = AsyncMock(return_value=None) + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + mock_fyta_connector.get_plant_image.return_value = None + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + image_entity: ImageEntity = hass.data["domain_entities"]["image"][ + "image.gummibaum_user_image" + ] + + assert image_entity.image_url == "http://www.plant_picture.com/user_picture" + assert image_entity._cached_image is None + + # Validate no image is available + client = await hass_client() + resp = await client.get("/api/image_proxy/image.gummibaum_user_image?token=1") + assert resp.status == 500 + + +async def test_update_user_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test if entity user picture is updated.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + mock_fyta_connector.get_plant_image.return_value = ( + "image/png", + bytes([100]), + ) + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + image_entity: ImageEntity = hass.data["domain_entities"]["image"][ + "image.gummibaum_user_image" + ] + + assert image_entity.image_url == "http://www.plant_picture.com/user_picture" + image = image_entity._cached_image + assert image == snapshot + + # Validate image + client = await hass_client() + resp = await client.get("/api/image_proxy/image.gummibaum_user_image?token=1") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == snapshot