mirror of
https://github.com/home-assistant/core.git
synced 2026-09-13 12:10:22 +01:00
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:
co-authored by
Copilot Autofix powered by AI
epenet
Simon Lamon
parent
d7b3513b9d
commit
8ffab3a5b9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user