From 8ffab3a5b9151e438ca5a0859bd199a40ec5ae63 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:23:02 +0800 Subject: [PATCH] Add first / last / next / previous actions (#181050) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- .../components/collection_image/icons.json | 12 ++ .../components/collection_image/image.py | 61 +++++- .../components/collection_image/services.py | 55 +++++- .../components/collection_image/services.yaml | 24 +++ .../components/collection_image/strings.json | 28 +++ .../collection_image/test_services.py | 185 +++++++++++++++++- 6 files changed, 359 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/collection_image/icons.json b/homeassistant/components/collection_image/icons.json index 630aebc88d0d..33ff6328707e 100644 --- a/homeassistant/components/collection_image/icons.json +++ b/homeassistant/components/collection_image/icons.json @@ -1,5 +1,17 @@ { "services": { + "select_first": { + "service": "mdi:page-first" + }, + "select_last": { + "service": "mdi:page-last" + }, + "select_next": { + "service": "mdi:chevron-right" + }, + "select_previous": { + "service": "mdi:chevron-left" + }, "shuffle": { "service": "mdi:shuffle" } diff --git a/homeassistant/components/collection_image/image.py b/homeassistant/components/collection_image/image.py index f3e0821b3893..7284083ec6fd 100644 --- a/homeassistant/components/collection_image/image.py +++ b/homeassistant/components/collection_image/image.py @@ -3,7 +3,7 @@ import logging from pathlib import Path import random -from typing import override +from typing import Literal, override from homeassistant.components.image import DEFAULT_CONTENT_TYPE, ImageEntity from homeassistant.components.media_player import ( @@ -120,6 +120,65 @@ class CollectionImageImageEntity(ImageEntity): self._attr_available = True await self.update_image(child.media_content_id) + async def get_first_image(self) -> None: + """Get the first image.""" + await self._get_image_at_position(0) + + async def get_last_image(self) -> None: + """Get the last image.""" + await self._get_image_at_position(-1) + + async def get_next_image(self, wrap: bool = False) -> None: + """Get the next image.""" + await self._get_next_sequential_image(False, wrap) + + async def get_previous_image(self, wrap: bool = False) -> None: + """Get the previous image.""" + await self._get_next_sequential_image(True, wrap) + + async def _get_image_at_position(self, position: Literal[0, -1]) -> None: + """Get the first or last image.""" + + filtered = await self.get_valid_images() + if not filtered: + self.set_unavailable() + return + + child = filtered[position] + self._attr_available = True + await self.update_image(child.media_content_id) + + async def _get_next_sequential_image( + self, reverse: bool = False, wrap: bool = False + ) -> None: + """Get the next or previous image.""" + + filtered = await self.get_valid_images() + if not filtered: + self.set_unavailable() + return + + current_index = next( + ( + i + for i, item in enumerate(filtered) + if item.media_content_id == self._current_image_id + ), + None, + ) + if current_index is None: + new_index = -1 if reverse else 0 + else: + new_index = current_index + (-1 if reverse else 1) + if new_index < 0: + new_index = -1 if wrap else 0 + elif new_index >= len(filtered): + new_index = 0 if wrap else (len(filtered) - 1) + + child = filtered[new_index] + self._attr_available = True + await self.update_image(child.media_content_id) + async def update_image(self, image_id: str) -> None: """Update the entity from the image_id.""" diff --git a/homeassistant/components/collection_image/services.py b/homeassistant/components/collection_image/services.py index 9b4e9976dd3b..0872a42c3e7d 100644 --- a/homeassistant/components/collection_image/services.py +++ b/homeassistant/components/collection_image/services.py @@ -1,12 +1,31 @@ """Collection image services.""" +from enum import StrEnum + +import voluptuous as vol + from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import service +import homeassistant.helpers.config_validation as cv from .const import DOMAIN -SERVICE_SHUFFLE = "shuffle" + +class CollectionImageService(StrEnum): + """Store keys for Collection image services.""" + + SHUFFLE = "shuffle" + SELECT_FIRST = "select_first" + SELECT_LAST = "select_last" + SELECT_NEXT = "select_next" + SELECT_PREVIOUS = "select_previous" + + +class CollectionImageServiceArgument(StrEnum): + """Store keys for Collection image service arguments.""" + + WRAP = "wrap" @callback @@ -16,8 +35,40 @@ def async_setup_services(hass: HomeAssistant) -> None: service.async_register_platform_entity_service( hass, DOMAIN, - SERVICE_SHUFFLE, + CollectionImageService.SHUFFLE, entity_domain=IMAGE_DOMAIN, schema={}, func="get_random_image", ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + CollectionImageService.SELECT_FIRST, + entity_domain=IMAGE_DOMAIN, + schema={}, + func="get_first_image", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + CollectionImageService.SELECT_LAST, + entity_domain=IMAGE_DOMAIN, + schema={}, + func="get_last_image", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + CollectionImageService.SELECT_NEXT, + entity_domain=IMAGE_DOMAIN, + schema={vol.Optional(CollectionImageServiceArgument.WRAP): cv.boolean}, + func="get_next_image", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + CollectionImageService.SELECT_PREVIOUS, + entity_domain=IMAGE_DOMAIN, + schema={vol.Optional(CollectionImageServiceArgument.WRAP): cv.boolean}, + func="get_previous_image", + ) diff --git a/homeassistant/components/collection_image/services.yaml b/homeassistant/components/collection_image/services.yaml index 2ddea745a5e5..6d031d83d27a 100644 --- a/homeassistant/components/collection_image/services.yaml +++ b/homeassistant/components/collection_image/services.yaml @@ -2,3 +2,27 @@ shuffle: target: entity: integration: collection_image +select_first: + target: + entity: + integration: collection_image +select_last: + target: + entity: + integration: collection_image +select_next: + target: + entity: + integration: collection_image + fields: + wrap: + selector: + boolean: +select_previous: + target: + entity: + integration: collection_image + fields: + wrap: + selector: + boolean: diff --git a/homeassistant/components/collection_image/strings.json b/homeassistant/components/collection_image/strings.json index 641a46eb3c96..2dfab4ea68e7 100644 --- a/homeassistant/components/collection_image/strings.json +++ b/homeassistant/components/collection_image/strings.json @@ -23,6 +23,34 @@ } }, "services": { + "select_first": { + "description": "Update the image entity to the first image in the configured media.", + "name": "Select first" + }, + "select_last": { + "description": "Update the image entity to the last image in the configured media.", + "name": "Select last" + }, + "select_next": { + "description": "Update the image entity to the next image in the configured media.", + "fields": { + "wrap": { + "description": "Advances to the first image after reaching the end of the list.", + "name": "Wrap" + } + }, + "name": "Select next" + }, + "select_previous": { + "description": "Update the image entity to the previous image in the configured media.", + "fields": { + "wrap": { + "description": "Advances to the last image after reaching the beginning of the list.", + "name": "Wrap" + } + }, + "name": "Select previous" + }, "shuffle": { "description": "Update the image entity to a random image from the configured media.", "name": "Shuffle" diff --git a/tests/components/collection_image/test_services.py b/tests/components/collection_image/test_services.py index b23890e0cae5..97ff1ffd54fb 100644 --- a/tests/components/collection_image/test_services.py +++ b/tests/components/collection_image/test_services.py @@ -4,12 +4,19 @@ from unittest.mock import AsyncMock, patch from homeassistant.components.collection_image.const import DOMAIN from homeassistant.components.collection_image.image import CollectionImageImageEntity -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.components.collection_image.services import ( + CollectionImageService, + CollectionImageServiceArgument, +) +from homeassistant.components.media_source import PlayMedia +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from .const import DEFAULT_ENTITY_ID +from .conftest import MediaSourceMocks, MediaSourceState +from .const import DEFAULT_ENTITY_ID, MOCK_MEDIA_DIR_URI_1 +from .helpers import directory, image -from tests.common import MockConfigEntry +from tests.common import Mock, MockConfigEntry async def _setup_integration( @@ -43,3 +50,175 @@ async def test_shuffle_action( ) mock_get_random_image.assert_awaited_once() + + +async def test_navigation( + hass: HomeAssistant, + config_entry: MockConfigEntry, + media_source_state: MediaSourceState, + mock_media_source: MediaSourceMocks, +) -> None: + """Test first/last/next/previous actions.""" + + images = [ + image("media-source://mymedia/1"), + image("media-source://mymedia/2"), + image("media-source://mymedia/3"), + ] + + media_source_state.browse_results = { + MOCK_MEDIA_DIR_URI_1: directory("My pictures", *images) + } + media_source_state.resolve_results = { + img.media_content_id: PlayMedia( + url="", + mime_type="image/png", + ) + for img in images + } + + with patch( + "homeassistant.components.collection_image.image.random.choice", + new=Mock(return_value=images[1]), + ): + await _setup_integration(hass, config_entry) + await hass.async_block_till_done() + + assert mock_media_source.image_browse.call_count == 1 + assert mock_media_source.resolve.call_count == 1 + + def assert_resolve_index(idx: int): + args, _kwargs = mock_media_source.resolve.call_args + assert args[1] == images[idx].media_content_id + + assert_resolve_index(1) + + wrap = {CollectionImageServiceArgument.WRAP: True} + steps = ( + (CollectionImageService.SELECT_FIRST, {}, 0), + (CollectionImageService.SELECT_LAST, {}, 2), + (CollectionImageService.SELECT_PREVIOUS, {}, 1), + (CollectionImageService.SELECT_PREVIOUS, {}, 0), + (CollectionImageService.SELECT_PREVIOUS, {}, 0), + (CollectionImageService.SELECT_PREVIOUS, wrap, 2), + (CollectionImageService.SELECT_PREVIOUS, wrap, 1), + (CollectionImageService.SELECT_PREVIOUS, wrap, 0), + (CollectionImageService.SELECT_NEXT, {}, 1), + (CollectionImageService.SELECT_NEXT, {}, 2), + (CollectionImageService.SELECT_NEXT, {}, 2), + (CollectionImageService.SELECT_NEXT, wrap, 0), + (CollectionImageService.SELECT_NEXT, wrap, 1), + ) + + for service, wrap, expected_index in steps: + data = {ATTR_ENTITY_ID: DEFAULT_ENTITY_ID, **wrap} + await hass.services.async_call( + DOMAIN, + service, + data, + blocking=True, + ) + assert_resolve_index(expected_index) + + # Change to new images and verify that next resets count to 0 + images = [ + image("media-source://mymedia/4"), + image("media-source://mymedia/5"), + image("media-source://mymedia/6"), + ] + + media_source_state.browse_results = { + MOCK_MEDIA_DIR_URI_1: directory("My pictures", *images) + } + media_source_state.resolve_results = { + img.media_content_id: PlayMedia( + url="", + mime_type="image/png", + ) + for img in images + } + + data = {ATTR_ENTITY_ID: DEFAULT_ENTITY_ID} + await hass.services.async_call( + DOMAIN, + CollectionImageService.SELECT_NEXT, + data, + blocking=True, + ) + assert_resolve_index(0) + + # Change to new images and verify that previous resets count to -1 + images = [ + image("media-source://mymedia/7"), + image("media-source://mymedia/8"), + image("media-source://mymedia/9"), + ] + + media_source_state.browse_results = { + MOCK_MEDIA_DIR_URI_1: directory("My pictures", *images) + } + media_source_state.resolve_results = { + img.media_content_id: PlayMedia( + url="", + mime_type="image/png", + ) + for img in images + } + + data = {ATTR_ENTITY_ID: DEFAULT_ENTITY_ID} + await hass.services.async_call( + DOMAIN, + CollectionImageService.SELECT_PREVIOUS, + data, + blocking=True, + ) + assert_resolve_index(2) + + # Now there are no images, go to unavailable + media_source_state.browse_results = {MOCK_MEDIA_DIR_URI_1: directory("My pictures")} + await hass.services.async_call( + DOMAIN, + CollectionImageService.SELECT_NEXT, + data, + blocking=True, + ) + + state = hass.states.get(DEFAULT_ENTITY_ID) + assert state and state.state == STATE_UNAVAILABLE + + +async def test_first_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + media_source_state: MediaSourceState, + mock_media_source: MediaSourceMocks, +) -> None: + """Check that calling first on empty directory sets unavailable.""" + images = [ + image("media-source://mymedia/1"), + ] + media_source_state.browse_results = { + MOCK_MEDIA_DIR_URI_1: directory("My pictures", *images) + } + media_source_state.resolve_results = { + img.media_content_id: PlayMedia( + url="", + mime_type="image/png", + ) + for img in images + } + await _setup_integration(hass, config_entry) + await hass.async_block_till_done() + + state = hass.states.get(DEFAULT_ENTITY_ID) + assert state and state.state != STATE_UNAVAILABLE + + media_source_state.browse_results = {MOCK_MEDIA_DIR_URI_1: directory("My pictures")} + await hass.services.async_call( + DOMAIN, + CollectionImageService.SELECT_FIRST, + {ATTR_ENTITY_ID: DEFAULT_ENTITY_ID}, + blocking=True, + ) + state = hass.states.get(DEFAULT_ENTITY_ID) + assert state and state.state == STATE_UNAVAILABLE