diff --git a/CODEOWNERS b/CODEOWNERS index 0b6e39248560..df0534cbcc7e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -318,6 +318,8 @@ CLAUDE.md @home-assistant/core /tests/components/co2signal/ @jpbede @VIKTORVAV99 /homeassistant/components/coinbase/ @tombrien /tests/components/coinbase/ @tombrien +/homeassistant/components/collection_image/ @karwosts +/tests/components/collection_image/ @karwosts /homeassistant/components/color_extractor/ @GenericStudent /tests/components/color_extractor/ @GenericStudent /homeassistant/components/comelit/ @chemelli74 diff --git a/homeassistant/components/collection_image/__init__.py b/homeassistant/components/collection_image/__init__.py new file mode 100644 index 000000000000..80760273a083 --- /dev/null +++ b/homeassistant/components/collection_image/__init__.py @@ -0,0 +1,20 @@ +"""The Collection Image integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.IMAGE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/collection_image/config_flow.py b/homeassistant/components/collection_image/config_flow.py new file mode 100644 index 000000000000..bb529de913f5 --- /dev/null +++ b/homeassistant/components/collection_image/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Collection Image integration.""" + +from typing import Any, override + +import voluptuous as vol + +from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_source import async_browse_media +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.selector import MediaSelector + +from .const import CONF_MEDIA, DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_MEDIA): MediaSelector({"accept": ["directory"]}), + } +) + + +class CollectionImageConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Collection Image.""" + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + if user_input is not None: + user_media = user_input[CONF_MEDIA] + try: + browse = await async_browse_media( + self.hass, user_media["media_content_id"] + ) + except BrowseError as err: + errors["media"] = "failed_browse" + placeholders["error"] = str(err) + else: + if browse.children and any( + item.media_class == MediaClass.IMAGE for item in browse.children + ): + return self.async_create_entry( + title=f"{browse.title or 'Unnamed'} collection", + data=user_input, + ) + + errors["media"] = "selected_media_no_images" + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + description_placeholders=placeholders, + ) diff --git a/homeassistant/components/collection_image/const.py b/homeassistant/components/collection_image/const.py new file mode 100644 index 000000000000..4c002bceb05d --- /dev/null +++ b/homeassistant/components/collection_image/const.py @@ -0,0 +1,5 @@ +"""Constants for the Collection Image integration.""" + +DOMAIN = "collection_image" + +CONF_MEDIA = "media" diff --git a/homeassistant/components/collection_image/image.py b/homeassistant/components/collection_image/image.py new file mode 100644 index 000000000000..b138c9f0d181 --- /dev/null +++ b/homeassistant/components/collection_image/image.py @@ -0,0 +1,163 @@ +"""Support for Collection Image image.""" + +import logging +from pathlib import Path +import random +from typing import override + +from homeassistant.components.image import ImageEntity +from homeassistant.components.media_player import ( + BrowseError, + MediaClass, + async_process_play_media_url, +) +from homeassistant.components.media_source import ( + Unresolvable, + async_browse_media, + async_resolve_media, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.start import async_at_started +from homeassistant.helpers.typing import UNDEFINED +from homeassistant.util import dt as dt_util + +from .const import CONF_MEDIA, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Collection Image image entities.""" + media = entry.data[CONF_MEDIA] + async_add_entities( + [ + CollectionImageImageEntity( + name=entry.title, + media_content_id=media["media_content_id"], + unique_id=entry.entry_id, + hass=hass, + ) + ] + ) + + +class CollectionImageImageEntity(ImageEntity): + """Implement the image entity for Collection Image.""" + + _unavailable_logged: bool = False + + path: Path | None + + def __init__( + self, + name: str, + media_content_id: str, + unique_id: str, + hass: HomeAssistant, + ) -> None: + """Initialize the entity.""" + super().__init__(hass) + self.path = None + self._attr_unique_id = unique_id + self._attr_name = name + self.media_content_id = media_content_id + + async def get_next_image(self) -> None: + """Update the image entity with the next image from the source media.""" + + self._cached_image = None + + def set_unavailable() -> None: + self._unavailable_logged = True + self._attr_available = False + self.path = None + self._attr_image_url = UNDEFINED + self.async_write_ha_state() + + try: + media = await async_browse_media(self.hass, self.media_content_id) + except BrowseError as err: + if not self._unavailable_logged: + _LOGGER.info("%s: %s", self.entity_id, str(err)) + set_unavailable() + return + + if media.children and ( + filtered := [ + item for item in media.children if item.media_class == MediaClass.IMAGE + ] + ): + child = random.choice(filtered) + try: + resolved = await async_resolve_media( + self.hass, child.media_content_id, self.entity_id + ) + except Unresolvable as err: + if not self._unavailable_logged: + _LOGGER.info("%s: %s", self.entity_id, str(err)) + set_unavailable() + return + + if resolved.url: + self.path = None + self._attr_image_url = async_process_play_media_url( + self.hass, resolved.url + ) + else: + self.path = resolved.path + self._attr_image_url = UNDEFINED + + self._attr_content_type = resolved.mime_type + self._attr_available = True + self._attr_image_last_updated = dt_util.utcnow() + if self._unavailable_logged: + _LOGGER.info( + "%s: Has become available again", + self.entity_id, + ) + self._unavailable_logged = False + self.async_write_ha_state() + return + + if not self._unavailable_logged: + _LOGGER.info( + "%s: No valid images in %s", + self.entity_id, + self.media_content_id, + ) + set_unavailable() + return + + @override + async def async_added_to_hass(self) -> None: + """Initialize the first image after entity has been created.""" + + async def get_next_image_on_start(_hass: HomeAssistant) -> None: + await self.get_next_image() + + self.async_on_remove(async_at_started(self.hass, get_next_image_on_start)) + + @override + def image(self) -> bytes | None: + """Return bytes of image.""" + if self.path: + try: + return self.path.read_bytes() + except OSError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="image_read_error", + translation_placeholders={ + "path": str(self.path), + "error": str(err), + }, + ) from err + + return None diff --git a/homeassistant/components/collection_image/manifest.json b/homeassistant/components/collection_image/manifest.json new file mode 100644 index 000000000000..e0c5b6848bf2 --- /dev/null +++ b/homeassistant/components/collection_image/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "collection_image", + "name": "Collection Image", + "codeowners": ["@karwosts"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/collection_image", + "integration_type": "service", + "iot_class": "calculated", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/collection_image/quality_scale.yaml b/homeassistant/components/collection_image/quality_scale.yaml new file mode 100644 index 000000000000..612fa2421d90 --- /dev/null +++ b/homeassistant/components/collection_image/quality_scale.yaml @@ -0,0 +1,118 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: Integration does not poll. + brands: done + common-modules: + status: exempt + comment: No common modules exist, only a single platform. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: + status: exempt + comment: No dependencies. + docs-actions: + status: exempt + comment: No actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: No entity event subscriptions. + entity-unique-id: done + has-entity-name: + status: exempt + comment: Config entry only has one entity, and the entity has the same name as the config entry; there is no device name. + runtime-data: + status: exempt + comment: No known use for runtime data. + test-before-configure: + status: exempt + comment: Integration does not connect to anything. + test-before-setup: + status: exempt + comment: Integration does not connect to anything. + unique-config-entry: + status: exempt + comment: Integration does not connect to any physical object or service. + + # Silver + action-exceptions: + status: exempt + comment: No current actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: Does not communicate with a device or external service, so no limit is necessary. + reauthentication-flow: + status: exempt + comment: Does not authenticate. + test-coverage: done + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: + status: exempt + comment: Nothing to discover. + discovery: + status: exempt + comment: Nothing to discover. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: + status: exempt + comment: No 'known limitations'. + docs-supported-devices: + status: exempt + comment: Integration does not support physical devices. + docs-supported-functions: + status: exempt + comment: Integration does not support physical devices. + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Integration does not support physical devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: done + icon-translations: + status: exempt + comment: No meaningful icon translations for an image entity. + reconfiguration-flow: + status: exempt + comment: Nothing to reconfigure. + repair-issues: + status: exempt + comment: Nothing to repair. + stale-devices: + status: exempt + comment: Integration does not support physical devices. + + # Platinum + async-dependency: + status: exempt + comment: No dependencies. + inject-websession: + status: exempt + comment: No external connections. + strict-typing: todo diff --git a/homeassistant/components/collection_image/strings.json b/homeassistant/components/collection_image/strings.json new file mode 100644 index 000000000000..1eda792e2a14 --- /dev/null +++ b/homeassistant/components/collection_image/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "error": { + "failed_browse": "Failed to browse media: {error}", + "selected_media_no_images": "The selected media has no images. Please select a media directory with images." + }, + "step": { + "user": { + "data": { + "media": "Media" + }, + "data_description": { + "media": "The media directory where images will be retrieved from." + }, + "description": "The Collection Image integration creates a single image entity by selecting an image from the selected media folder.", + "submit": "Create" + } + } + }, + "exceptions": { + "image_read_error": { + "message": "Error reading image from {path}: {error}" + } + }, + "title": "Collection Image" +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d55b389f1585..d09c98cd553e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -136,6 +136,7 @@ FLOWS = { "cloudflare_r2", "co2signal", "coinbase", + "collection_image", "color_extractor", "comelit", "compit", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7e837208ff15..210a0f388871 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1109,6 +1109,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "collection_image": { + "integration_type": "service", + "config_flow": true, + "iot_class": "calculated" + }, "color_extractor": { "name": "ColorExtractor", "integration_type": "hub", @@ -8576,6 +8581,7 @@ "alert", "aurora", "cert_expiry", + "collection_image", "counter", "cpuspeed", "demo", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index f9f2d1df308d..aaba675fa337 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -32,6 +32,7 @@ RE_URL = re.compile( # Only allow translation of integration names if they contain non-brand names ALLOW_NAME_TRANSLATION = { "cert_expiry", + "collection_image", "cpuspeed", "emulated_roku", "energenie_power_sockets", diff --git a/tests/components/collection_image/__init__.py b/tests/components/collection_image/__init__.py new file mode 100644 index 000000000000..bc96e785894a --- /dev/null +++ b/tests/components/collection_image/__init__.py @@ -0,0 +1 @@ +"""Tests for the Collection Image component.""" diff --git a/tests/components/collection_image/test.png b/tests/components/collection_image/test.png new file mode 100644 index 000000000000..252d9502d857 Binary files /dev/null and b/tests/components/collection_image/test.png differ diff --git a/tests/components/collection_image/test_config_flow.py b/tests/components/collection_image/test_config_flow.py new file mode 100644 index 000000000000..402b46475e44 --- /dev/null +++ b/tests/components/collection_image/test_config_flow.py @@ -0,0 +1,161 @@ +"""Test the Collection Image config flow.""" + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.collection_image.const import DOMAIN +from homeassistant.components.media_player import BrowseMedia, MediaClass +from homeassistant.components.media_source import BrowseMediaSource +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def _assert_successful_configure( + hass: HomeAssistant, previous_step: config_entries.ConfigFlowResult +) -> None: + with ( + patch( + "homeassistant.components.collection_image.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "homeassistant.components.collection_image.config_flow.async_browse_media", + return_value=BrowseMediaSource( + domain=None, + identifier=None, + media_class="", + media_content_type="", + title="My pictures", + can_play=False, + can_expand=True, + children=[ + BrowseMedia( + media_class=MediaClass.IMAGE, + media_content_id="media-source://mymedia/photo", + media_content_type="image/png", + title="a picture", + can_play=True, + can_expand=False, + ), + ], + ), + ), + ): + result = await hass.config_entries.flow.async_configure( + previous_step["flow_id"], + { + "media": { + "media_content_id": "media-source://mymedia", + "media_content_type": "", + }, + }, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "My pictures collection" + assert result.get("data") == { + "media": { + "media_content_id": "media-source://mymedia", + "media_content_type": "", + }, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test the config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {} + + await _assert_successful_configure(hass, result) + + +async def test_config_flow_with_error(hass: HomeAssistant) -> None: + """Test the config flow with an invalid directory.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {} + + with ( + patch( + "homeassistant.components.collection_image.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "homeassistant.components.collection_image.config_flow.async_browse_media", + return_value=BrowseMediaSource( + domain=None, + identifier=None, + media_class="", + media_content_type="", + title="", + can_play=False, + can_expand=True, + children=[], + ), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "media": { + "media_content_id": "media-source://mymedia_empty", + "media_content_type": "", + }, + }, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.FORM + assert result.get("title") is None + assert result.get("data") is None + assert result.get("errors") == {"media": "selected_media_no_images"} + assert len(mock_setup_entry.mock_calls) == 0 + + # Try again successfully to ensure we can recover from errors + await _assert_successful_configure(hass, result) + + +async def test_config_flow_with_exception(hass: HomeAssistant) -> None: + """Test the config flow with a browse failure.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {} + + with ( + patch( + "homeassistant.components.collection_image.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "media": { + "media_content_id": "media-source://mymedia", + "media_content_type": "", + }, + }, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.FORM + assert result.get("title") is None + assert result.get("data") is None + assert result.get("errors") == {"media": "failed_browse"} + assert result.get("description_placeholders") == { + "error": "Media Source not loaded" + } + assert len(mock_setup_entry.mock_calls) == 0 + + await _assert_successful_configure(hass, result) diff --git a/tests/components/collection_image/test_image.py b/tests/components/collection_image/test_image.py new file mode 100644 index 000000000000..ff276711c5b4 --- /dev/null +++ b/tests/components/collection_image/test_image.py @@ -0,0 +1,461 @@ +"""The tests for the Collection Image image platform.""" + +from http import HTTPStatus +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time +import pytest + +from homeassistant.components.collection_image.const import DOMAIN +from homeassistant.components.image import Image, async_get_image +from homeassistant.components.media_player import BrowseMedia, MediaClass +from homeassistant.components.media_source import BrowseMediaSource, PlayMedia +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE +from homeassistant.core import CoreState, HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + + +async def test_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test loading an image.""" + with freeze_time("2025-11-08T12:00:00.000"): + config_entry = MockConfigEntry( + data={ + "media": { + "media_content_id": "media-source://mymedia", + "media_content_type": "", + }, + }, + domain=DOMAIN, + title="Random Image", + ) + + with ( + patch( + "homeassistant.components.collection_image.image.async_browse_media", + return_value=BrowseMediaSource( + domain=None, + identifier=None, + media_class="", + media_content_type="", + title="", + can_play=False, + can_expand=True, + children=[ + BrowseMedia( + media_class=MediaClass.MUSIC, + media_content_id="media-source://mymedia/music", + media_content_type="audio/mp3", + title="a music track", + can_play=True, + can_expand=False, + ), + BrowseMedia( + media_class=MediaClass.IMAGE, + media_content_id="media-source://mymedia/photo", + media_content_type="image/png", + title="a picture", + can_play=True, + can_expand=False, + ), + ], + ), + ), + patch( + "homeassistant.components.collection_image.image.async_resolve_media", + return_value=PlayMedia( + url="", + mime_type="image/png", + path=Path(__file__).parent / "test.png", + ), + ), + ): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("image.random_image") + + assert state and state.state == "2025-11-08T12:00:00+00:00" + + client = await hass_client() + + resp = await client.get("/api/image_proxy/image.random_image") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/png" + image_path = Path(__file__).parent / "test.png" + expected_data = await hass.async_add_executor_job(image_path.read_bytes) + body = await resp.read() + assert body == expected_data + + +async def test_image_during_startup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test loading an image, ensuring that we don't browse until after startup is complete.""" + with freeze_time("2025-11-08T12:00:00.000"): + hass.set_state(CoreState.starting) + config_entry = MockConfigEntry( + data={ + "media": { + "media_content_id": "media-source://mymedia", + "media_content_type": "", + }, + }, + domain=DOMAIN, + title="Random Image", + ) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.collection_image.image.async_browse_media", + return_value=BrowseMediaSource( + domain=None, + identifier=None, + media_class="", + media_content_type="", + title="", + can_play=False, + can_expand=True, + children=[ + BrowseMedia( + media_class=MediaClass.MUSIC, + media_content_id="media-source://mymedia/music", + media_content_type="audio/mp3", + title="a music track", + can_play=True, + can_expand=False, + ), + BrowseMedia( + media_class=MediaClass.IMAGE, + media_content_id="media-source://mymedia/photo", + media_content_type="image/png", + title="a picture", + can_play=True, + can_expand=False, + ), + ], + ), + ), + patch( + "homeassistant.components.collection_image.image.async_resolve_media", + return_value=PlayMedia( + url="", + mime_type="image/png", + path=Path(__file__).parent / "test.png", + ), + ), + ): + hass.set_state(CoreState.running) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + state = hass.states.get("image.random_image") + + assert state and state.state == "2025-11-08T12:00:00+00:00" + + client = await hass_client() + + resp = await client.get("/api/image_proxy/image.random_image") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/png" + image_path = Path(__file__).parent / "test.png" + expected_data = await hass.async_add_executor_job(image_path.read_bytes) + body = await resp.read() + assert body == expected_data + + +async def test_image_url( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test loading an image, when media resolves to a URL.""" + + image_path = Path(__file__).parent / "test.png" + expected_data = await hass.async_add_executor_job(image_path.read_bytes) + + with freeze_time("2025-11-08T12:00:00.000"): + config_entry = MockConfigEntry( + data={ + "media": { + "media_content_id": "media-source://mymedia", + "media_content_type": "", + }, + }, + domain=DOMAIN, + title="Random Image", + ) + + with ( + patch( + "homeassistant.components.collection_image.image.async_browse_media", + return_value=BrowseMediaSource( + domain=None, + identifier=None, + media_class="", + media_content_type="", + title="", + can_play=False, + can_expand=True, + children=[ + BrowseMedia( + media_class=MediaClass.IMAGE, + media_content_id="media-source://mymedia/photo", + media_content_type="image/png", + title="a picture", + can_play=True, + can_expand=False, + ), + ], + ), + ), + patch( + "homeassistant.components.collection_image.image.async_resolve_media", + return_value=PlayMedia( + url="http://example.com/test.png", + mime_type="image/png", + ), + ), + ): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("image.random_image") + + assert state and state.state == "2025-11-08T12:00:00+00:00" + + client = await hass_client() + + with patch( + "homeassistant.components.collection_image.image.CollectionImageImageEntity._async_load_image_from_url", + new_callable=AsyncMock, + ) as mock_load: + mock_load.return_value = Image( + content_type="image/png", + content=expected_data, + ) + resp = await client.get("/api/image_proxy/image.random_image") + mock_load.assert_awaited_once_with("http://example.com/test.png") + + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/png" + body = await resp.read() + assert body == expected_data + + +async def test_no_images( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test when there are no images in the media folder.""" + config_entry = MockConfigEntry( + data={ + "media": { + "media_content_id": "media-source://mymedia/nopictures", + "media_content_type": "", + }, + }, + domain=DOMAIN, + title="Random No Image", + ) + + with patch( + "homeassistant.components.collection_image.image.async_browse_media", + return_value=BrowseMediaSource( + domain=None, + identifier=None, + media_class="", + media_content_type="", + title="", + can_play=False, + can_expand=True, + children=[], + ), + ): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("image.random_no_image") + + assert state and state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + "image.random_no_image: No valid images in media-source://mymedia/nopictures" + in caplog.text + ) + + client = await hass_client() + resp = await client.get("/api/image_proxy/image.random_no_image") + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_media_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test when media browse throws an error.""" + config_entry = MockConfigEntry( + data={ + "media": { + "media_content_id": "media-source://badpath", + "media_content_type": "", + }, + }, + domain=DOMAIN, + title="Random No Image", + ) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("image.random_no_image") + + assert state and state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done(wait_background_tasks=True) + + assert "image.random_no_image: Media Source not loaded" in caplog.text + + client = await hass_client() + resp = await client.get("/api/image_proxy/image.random_no_image") + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_unresolvable( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test when resolving an image fails.""" + config_entry = MockConfigEntry( + data={ + "media": { + "media_content_id": "media-source://mymedia", + "media_content_type": "", + }, + }, + domain=DOMAIN, + title="Random Image", + ) + + with ( + patch( + "homeassistant.components.collection_image.image.async_browse_media", + return_value=BrowseMediaSource( + domain=None, + identifier=None, + media_class="", + media_content_type="", + title="", + can_play=False, + can_expand=True, + children=[ + BrowseMedia( + media_class=MediaClass.IMAGE, + media_content_id="media-source://mymedia/badphoto", + media_content_type="image/png", + title="a picture", + can_play=True, + can_expand=False, + ), + ], + ), + ), + ): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("image.random_image") + + assert state and state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done(wait_background_tasks=True) + + assert "image.random_image: Media Source not loaded" in caplog.text + + +async def test_image_file_read_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test that a file read error is surfaced when serving the image.""" + missing_path = Path(__file__).parent / "does_not_exist.png" + + with freeze_time("2025-11-08T12:00:00.000"): + config_entry = MockConfigEntry( + data={ + "media": { + "media_content_id": "media-source://mymedia", + "media_content_type": "", + }, + }, + domain=DOMAIN, + title="Random Image", + ) + + with ( + patch( + "homeassistant.components.collection_image.image.async_browse_media", + return_value=BrowseMediaSource( + domain=None, + identifier=None, + media_class="", + media_content_type="", + title="", + can_play=False, + can_expand=True, + children=[ + BrowseMedia( + media_class=MediaClass.IMAGE, + media_content_id="media-source://mymedia/photo", + media_content_type="image/png", + title="a picture", + can_play=True, + can_expand=False, + ), + ], + ), + ), + patch( + "homeassistant.components.collection_image.image.async_resolve_media", + return_value=PlayMedia( + url="", + mime_type="image/png", + path=missing_path, + ), + ), + ): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Browse and resolve succeeded, so the entity is available with an image. + state = hass.states.get("image.random_image") + assert state and state.state == "2025-11-08T12:00:00+00:00" + + with pytest.raises(HomeAssistantError) as exc_info: + await async_get_image(hass, "image.random_image") + assert exc_info.value.translation_key == "image_read_error" + assert exc_info.value.translation_placeholders["path"] == str(missing_path) + + client = await hass_client() + resp = await client.get("/api/image_proxy/image.random_image") + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR