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>
This commit is contained in:
karwosts
2026-09-08 18:23:02 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI epenet Simon Lamon
parent d7b3513b9d
commit 8ffab3a5b9
6 changed files with 359 additions and 6 deletions
@@ -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"
}
@@ -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."""
@@ -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",
)
@@ -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:
@@ -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"
@@ -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