From 46deaf3c0d426f7cb5bca79554440504c4f92efa Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:40:11 +0800 Subject: [PATCH] Add collection_image.shuffle service (#180075) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/collection_image/__init__.py | 13 ++ .../components/collection_image/icons.json | 7 + .../collection_image/quality_scale.yaml | 12 +- .../components/collection_image/services.py | 23 ++++ .../components/collection_image/services.yaml | 4 + .../components/collection_image/strings.json | 6 + .../collection_image/test_services.py | 120 ++++++++++++++++++ 7 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/collection_image/icons.json create mode 100644 homeassistant/components/collection_image/services.py create mode 100644 homeassistant/components/collection_image/services.yaml create mode 100644 tests/components/collection_image/test_services.py diff --git a/homeassistant/components/collection_image/__init__.py b/homeassistant/components/collection_image/__init__.py index 80760273a083..8803c3336bc7 100644 --- a/homeassistant/components/collection_image/__init__.py +++ b/homeassistant/components/collection_image/__init__.py @@ -3,9 +3,22 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN +from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.IMAGE] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up is called when Home Assistant is loading our component.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" diff --git a/homeassistant/components/collection_image/icons.json b/homeassistant/components/collection_image/icons.json new file mode 100644 index 000000000000..630aebc88d0d --- /dev/null +++ b/homeassistant/components/collection_image/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "shuffle": { + "service": "mdi:shuffle" + } + } +} diff --git a/homeassistant/components/collection_image/quality_scale.yaml b/homeassistant/components/collection_image/quality_scale.yaml index 612fa2421d90..3911ceea5647 100644 --- a/homeassistant/components/collection_image/quality_scale.yaml +++ b/homeassistant/components/collection_image/quality_scale.yaml @@ -1,8 +1,6 @@ rules: # Bronze - action-setup: - status: exempt - comment: Integration does not register custom actions. + action-setup: done appropriate-polling: status: exempt comment: Integration does not poll. @@ -15,9 +13,7 @@ rules: dependency-transparency: status: exempt comment: No dependencies. - docs-actions: - status: exempt - comment: No actions. + docs-actions: done docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -48,9 +44,7 @@ rules: comment: Integration does not connect to any physical object or service. # Silver - action-exceptions: - status: exempt - comment: No current actions. + action-exceptions: todo config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/collection_image/services.py b/homeassistant/components/collection_image/services.py new file mode 100644 index 000000000000..081768e2b345 --- /dev/null +++ b/homeassistant/components/collection_image/services.py @@ -0,0 +1,23 @@ +"""Collection image services.""" + +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import service + +from .const import DOMAIN + +SERVICE_SHUFFLE = "shuffle" + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register Collection image services.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SHUFFLE, + entity_domain=IMAGE_DOMAIN, + schema={}, + func="get_next_image", + ) diff --git a/homeassistant/components/collection_image/services.yaml b/homeassistant/components/collection_image/services.yaml new file mode 100644 index 000000000000..2ddea745a5e5 --- /dev/null +++ b/homeassistant/components/collection_image/services.yaml @@ -0,0 +1,4 @@ +shuffle: + target: + entity: + integration: collection_image diff --git a/homeassistant/components/collection_image/strings.json b/homeassistant/components/collection_image/strings.json index 1eda792e2a14..facf104c02ad 100644 --- a/homeassistant/components/collection_image/strings.json +++ b/homeassistant/components/collection_image/strings.json @@ -22,5 +22,11 @@ "message": "Error reading image from {path}: {error}" } }, + "services": { + "shuffle": { + "description": "Update the image entity to a random image from the configured media.", + "name": "Shuffle" + } + }, "title": "Collection Image" } diff --git a/tests/components/collection_image/test_services.py b/tests/components/collection_image/test_services.py new file mode 100644 index 000000000000..d7b000525ad2 --- /dev/null +++ b/tests/components/collection_image/test_services.py @@ -0,0 +1,120 @@ +"""Tests for the Collection Image integration services.""" + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.collection_image.const import DOMAIN +from homeassistant.components.collection_image.image import CollectionImageImageEntity +from homeassistant.components.media_player import BrowseMedia, MediaClass +from homeassistant.components.media_source import BrowseMediaSource, PlayMedia +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +ENTITY_ID = "image.random_image" +TEST_IMAGE = Path(__file__).parent / "test.png" + + +@pytest.fixture +def config_entry() -> MockConfigEntry: + """Return a Collection Image config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Random Image", + data={ + "media": { + "media_content_id": "media-source://mymedia", + "media_content_type": "", + } + }, + ) + + +@pytest.fixture +def browse_media() -> BrowseMediaSource: + """Return a media folder containing one image and one non-image.""" + return 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, + ), + ], + ) + + +@pytest.fixture +def mock_media_source(browse_media: BrowseMediaSource): + """Mock browsing and resolving the configured media source.""" + with ( + patch( + "homeassistant.components.collection_image.image.async_browse_media", + new=AsyncMock(return_value=browse_media), + ) as mock_browse, + patch( + "homeassistant.components.collection_image.image.async_resolve_media", + new=AsyncMock( + return_value=PlayMedia( + url="", + mime_type="image/png", + path=TEST_IMAGE, + ) + ), + ) as mock_resolve, + ): + yield mock_browse, mock_resolve + + +async def _setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the Collection Image integration.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_shuffle_action( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_media_source, +) -> None: + """Test that shuffle calls get_next_image on the target entity.""" + await _setup_integration(hass, config_entry) + + with patch.object( + CollectionImageImageEntity, + "get_next_image", + new_callable=AsyncMock, + ) as mock_get_next_image: + await hass.services.async_call( + DOMAIN, + "shuffle", + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_get_next_image.assert_awaited_once()