Add 'Collection image' integration (#156192)

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
This commit is contained in:
karwosts
2026-08-24 20:04:48 +02:00
committed by GitHub
co-authored by Petar Petrov Erik Montnemery Copilot Josef Zweck
parent 158b539140
commit be67b84e23
15 changed files with 1033 additions and 0 deletions
Generated
+2
View File
@@ -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
@@ -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)
@@ -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,
)
@@ -0,0 +1,5 @@
"""Constants for the Collection Image integration."""
DOMAIN = "collection_image"
CONF_MEDIA = "media"
@@ -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
@@ -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"
}
@@ -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
@@ -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"
}
+1
View File
@@ -136,6 +136,7 @@ FLOWS = {
"cloudflare_r2",
"co2signal",
"coinbase",
"collection_image",
"color_extractor",
"comelit",
"compit",
@@ -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",
+1
View File
@@ -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",
@@ -0,0 +1 @@
"""Tests for the Collection Image component."""
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

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