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()
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(

View File

@ -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

View File

@ -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"

View File

@ -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",

View File

@ -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': <ANY>,
'entity_id': 'image.gummibaum',
'entity_id': 'image.gummibaum_plant_image',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'entity_id': 'image.kakaobaum',
'entity_id': 'image.gummibaum_user_image',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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."""
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