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 <chimera88@gmx.de>
This commit is contained in:
dontinelli 2025-05-26 15:40:15 +02:00 committed by GitHub
parent 486535c189
commit 14cd00a116
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 293 additions and 38 deletions

View File

@ -84,7 +84,10 @@ async def async_migrate_entry(
new[CONF_EXPIRATION] = credentials.expiration.isoformat() new[CONF_EXPIRATION] = credentials.expiration.isoformat()
hass.config_entries.async_update_entry( 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( _LOGGER.debug(

View File

@ -2,9 +2,20 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime 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.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@ -12,6 +23,30 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FytaConfigEntry, FytaCoordinator from .coordinator import FytaConfigEntry, FytaCoordinator
from .entity import FytaPlantEntity 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -21,17 +56,17 @@ async def async_setup_entry(
"""Set up the FYTA plant images.""" """Set up the FYTA plant images."""
coordinator = entry.runtime_data coordinator = entry.runtime_data
description = ImageEntityDescription(key="plant_image")
async_add_entities( async_add_entities(
FytaPlantImageEntity(coordinator, entry, description, plant_id) FytaPlantImageEntity(coordinator, entry, description, plant_id)
for plant_id in coordinator.fyta.plant_list for plant_id in coordinator.fyta.plant_list
if plant_id in coordinator.data if plant_id in coordinator.data
for description in IMAGES
) )
def _async_add_new_device(plant_id: int) -> None: def _async_add_new_device(plant_id: int) -> None:
async_add_entities( 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) coordinator.new_device_callbacks.append(_async_add_new_device)
@ -40,26 +75,49 @@ async def async_setup_entry(
class FytaPlantImageEntity(FytaPlantEntity, ImageEntity): class FytaPlantImageEntity(FytaPlantEntity, ImageEntity):
"""Represents a Fyta image.""" """Represents a Fyta image."""
entity_description: ImageEntityDescription entity_description: FytaImageEntityDescription
def __init__( def __init__(
self, self,
coordinator: FytaCoordinator, coordinator: FytaCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
description: ImageEntityDescription, description: FytaImageEntityDescription,
plant_id: int, plant_id: int,
) -> None: ) -> None:
"""Initiatlize Fyta Image entity.""" """Initialize Fyta Image entity."""
super().__init__(coordinator, entry, description, plant_id) super().__init__(coordinator, entry, description, plant_id)
ImageEntity.__init__(self, coordinator.hass) 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 @property
def image_url(self) -> str: def image_url(self) -> str:
"""Return the image_url for this sensor.""" """Return the image_url for this plant."""
image = self.plant.plant_origin_path url = self.entity_description.url_fn(self.plant)
if image != self._attr_image_url:
self._attr_image_last_updated = datetime.now()
return image if url != self._attr_image_url:
self._cached_image = None
self._attr_image_last_updated = datetime.now()
return url

View File

@ -61,6 +61,14 @@
"name": "Sensor update available" "name": "Sensor update available"
} }
}, },
"image": {
"plant_image": {
"name": "Plant image"
},
"plant_image_user": {
"name": "User image"
}
},
"sensor": { "sensor": {
"scientific_name": { "scientific_name": {
"name": "Scientific name" "name": "Scientific name"

View File

@ -25,7 +25,7 @@
"sw_version": "1.0", "sw_version": "1.0",
"status": 1, "status": 1,
"online": true, "online": true,
"origin_path": "http://www.plant_picture.com/user_picture", "origin_path": "http://www.plant_picture.com/user_picture1",
"ph": null, "ph": null,
"plant_id": 0, "plant_id": 0,
"plant_origin_path": "http://www.plant_picture.com/picture1", "plant_origin_path": "http://www.plant_picture.com/picture1",

View File

@ -1,5 +1,5 @@
# serializer version: 1 # serializer version: 1
# name: test_all_entities[image.gummibaum-entry] # name: test_all_entities[image.gummibaum_plant_image-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({
}), }),
@ -12,7 +12,7 @@
'disabled_by': None, 'disabled_by': None,
'domain': 'image', 'domain': 'image',
'entity_category': None, 'entity_category': None,
'entity_id': 'image.gummibaum', 'entity_id': 'image.gummibaum_plant_image',
'has_entity_name': True, 'has_entity_name': True,
'hidden_by': None, 'hidden_by': None,
'icon': None, 'icon': None,
@ -24,31 +24,31 @@
}), }),
'original_device_class': None, 'original_device_class': None,
'original_icon': None, 'original_icon': None,
'original_name': None, 'original_name': 'Plant image',
'platform': 'fyta', 'platform': 'fyta',
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': None, 'translation_key': 'plant_image',
'unique_id': 'ce5f5431554d101905d31797e1232da8-0-plant_image', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-plant_image',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_all_entities[image.gummibaum-state] # name: test_all_entities[image.gummibaum_plant_image-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'access_token': '1', 'access_token': '1',
'entity_picture': '/api/image_proxy/image.gummibaum?token=1', 'entity_picture': '/api/image_proxy/image.gummibaum_plant_image?token=1',
'friendly_name': 'Gummibaum', 'friendly_name': 'Gummibaum Plant image',
}), }),
'context': <ANY>, 'context': <ANY>,
'entity_id': 'image.gummibaum', 'entity_id': 'image.gummibaum_plant_image',
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'unknown', 'state': 'unknown',
}) })
# --- # ---
# name: test_all_entities[image.kakaobaum-entry] # name: test_all_entities[image.gummibaum_user_image-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({
}), }),
@ -61,7 +61,7 @@
'disabled_by': None, 'disabled_by': None,
'domain': 'image', 'domain': 'image',
'entity_category': None, 'entity_category': None,
'entity_id': 'image.kakaobaum', 'entity_id': 'image.gummibaum_user_image',
'has_entity_name': True, 'has_entity_name': True,
'hidden_by': None, 'hidden_by': None,
'icon': None, 'icon': None,
@ -73,27 +73,131 @@
}), }),
'original_device_class': None, 'original_device_class': None,
'original_icon': None, 'original_icon': None,
'original_name': None, 'original_name': 'User image',
'platform': 'fyta', 'platform': 'fyta',
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
'translation_key': None, 'translation_key': 'plant_image_user',
'unique_id': 'ce5f5431554d101905d31797e1232da8-1-plant_image', 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-plant_image_user',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_all_entities[image.kakaobaum-state] # name: test_all_entities[image.gummibaum_user_image-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'access_token': '1', 'access_token': '1',
'entity_picture': '/api/image_proxy/image.kakaobaum?token=1', 'entity_picture': '/api/image_proxy/image.gummibaum_user_image?token=1',
'friendly_name': 'Kakaobaum', 'friendly_name': 'Gummibaum User image',
}), }),
'context': <ANY>, 'context': <ANY>,
'entity_id': 'image.kakaobaum', 'entity_id': 'image.gummibaum_user_image',
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'unknown', 'state': 'unknown',
}) })
# --- # ---
# name: test_all_entities[image.kakaobaum_plant_image-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'image',
'entity_category': None,
'entity_id': 'image.kakaobaum_plant_image',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'image.kakaobaum_plant_image',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[image.kakaobaum_user_image-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'image',
'entity_category': None,
'entity_id': 'image.kakaobaum_user_image',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'image.kakaobaum_user_image',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_update_user_image
None
# ---
# name: test_update_user_image.1
b'd'
# ---

View File

@ -1,6 +1,7 @@
"""Test the Home Assistant fyta sensor module.""" """Test the Home Assistant fyta sensor module."""
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
@ -23,6 +24,7 @@ from tests.common import (
load_json_object_fixture, load_json_object_fixture,
snapshot_platform, snapshot_platform,
) )
from tests.typing import ClientSessionGenerator
async def test_all_entities( async def test_all_entities(
@ -37,7 +39,7 @@ async def test_all_entities(
await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) await setup_platform(hass, mock_config_entry, [Platform.IMAGE])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) 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( @pytest.mark.parametrize(
@ -63,7 +65,8 @@ async def test_connection_error(
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() 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( 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]) 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] = { plants: dict[int, Plant] = {
0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)), 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) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.states.get("image.kakaobaum") is None assert hass.states.get("image.kakaobaum_plant_image") is None
assert hass.states.get("image.tomatenpflanze") is not 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( async def test_update_image(
@ -106,7 +112,10 @@ async def test_update_image(
await setup_platform(hass, mock_config_entry, [Platform.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" 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) async_fire_time_changed(hass)
await hass.async_block_till_done() 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_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