mirror of
https://github.com/home-assistant/core.git
synced 2026-08-27 18:14:46 -05:00
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:
co-authored by
Petar Petrov
Erik Montnemery
Copilot
Josef Zweck
parent
158b539140
commit
be67b84e23
Generated
+2
@@ -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"
|
||||
}
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user