diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index ab70a339d241fb..e1a790c3b5d23f 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -1,4 +1,4 @@ -"""Proides the constants needed for component.""" +"""Provides the constants needed for component.""" ATTR_APP_ID = "app_id" ATTR_APP_NAME = "app_name" @@ -27,6 +27,17 @@ ATTR_SOUND_MODE = "sound_mode" ATTR_SOUND_MODE_LIST = "sound_mode_list" +CONF_CLEAR_PLAYLIST = "clear_playlist" +CONF_MEDIA_PLAY_PAUSE = "media_play_pause" +CONF_MEDIA_PLAY = "media_play" +CONF_MEDIA_PAUSE = "media_pause" +CONF_MEDIA_STOP = "media_stop" +CONF_MEDIA_NEXT_TRACK = "media_next_track" +CONF_MEDIA_PREVIOUS_TRACK = "media_previous_track" +CONF_SUPPORTED_FEATURES = "supported_features" +CONF_VOLUME_UP = "volume_up" +CONF_VOLUME_DOWN = "volume_down" + DOMAIN = "media_player" MEDIA_TYPE_MUSIC = "music" diff --git a/homeassistant/components/media_player/device_action.py b/homeassistant/components/media_player/device_action.py new file mode 100644 index 00000000000000..682be89ed44fd4 --- /dev/null +++ b/homeassistant/components/media_player/device_action.py @@ -0,0 +1,165 @@ +"""Provides device actions for Media Player.""" +from typing import Any, Dict, List, Optional + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_SERVICE, + CONF_TYPE, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PLAY_PAUSE, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_UP, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from . import ( + DOMAIN, + SUPPORT_CLEAR_PLAYLIST, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +from ..device_automation.const import CONF_TOGGLE, CONF_TURN_OFF, CONF_TURN_ON +from .const import ( + CONF_CLEAR_PLAYLIST, + CONF_MEDIA_NEXT_TRACK, + CONF_MEDIA_PAUSE, + CONF_MEDIA_PLAY, + CONF_MEDIA_PLAY_PAUSE, + CONF_MEDIA_PREVIOUS_TRACK, + CONF_MEDIA_STOP, + CONF_SUPPORTED_FEATURES, + CONF_VOLUME_DOWN, + CONF_VOLUME_UP, + SERVICE_CLEAR_PLAYLIST, +) + +ACTION_TYPES: Dict[str, Dict[str, Any]] = { + CONF_TURN_ON: { + CONF_SERVICE: SERVICE_TURN_ON, + CONF_SUPPORTED_FEATURES: [SUPPORT_TURN_ON], + }, + CONF_TURN_OFF: { + CONF_SERVICE: SERVICE_TURN_OFF, + CONF_SUPPORTED_FEATURES: [SUPPORT_TURN_OFF], + }, + CONF_TOGGLE: { + CONF_SERVICE: SERVICE_TOGGLE, + CONF_SUPPORTED_FEATURES: [SUPPORT_TURN_ON | SUPPORT_TURN_OFF], + }, + CONF_VOLUME_UP: { + CONF_SERVICE: SERVICE_VOLUME_UP, + CONF_SUPPORTED_FEATURES: [SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP], + }, + CONF_VOLUME_DOWN: { + CONF_SERVICE: SERVICE_VOLUME_DOWN, + CONF_SUPPORTED_FEATURES: [SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP], + }, + CONF_MEDIA_PLAY_PAUSE: { + CONF_SERVICE: SERVICE_MEDIA_PLAY_PAUSE, + CONF_SUPPORTED_FEATURES: [SUPPORT_PLAY | SUPPORT_PAUSE], + }, + CONF_MEDIA_PLAY: { + CONF_SERVICE: SERVICE_MEDIA_PLAY, + CONF_SUPPORTED_FEATURES: [SUPPORT_PLAY], + }, + CONF_MEDIA_PAUSE: { + CONF_SERVICE: SERVICE_MEDIA_PAUSE, + CONF_SUPPORTED_FEATURES: [SUPPORT_PAUSE], + }, + CONF_MEDIA_STOP: { + CONF_SERVICE: SERVICE_MEDIA_STOP, + CONF_SUPPORTED_FEATURES: [SUPPORT_STOP], + }, + CONF_MEDIA_NEXT_TRACK: { + CONF_SERVICE: SERVICE_MEDIA_NEXT_TRACK, + CONF_SUPPORTED_FEATURES: [SUPPORT_NEXT_TRACK], + }, + CONF_MEDIA_PREVIOUS_TRACK: { + CONF_SERVICE: SERVICE_MEDIA_PREVIOUS_TRACK, + CONF_SUPPORTED_FEATURES: [SUPPORT_PREVIOUS_TRACK], + }, + CONF_CLEAR_PLAYLIST: { + CONF_SERVICE: SERVICE_CLEAR_PLAYLIST, + CONF_SUPPORTED_FEATURES: [SUPPORT_CLEAR_PLAYLIST], + }, +} + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(list(ACTION_TYPES.keys())), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + } +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device actions for Media Player devices.""" + registry = await entity_registry.async_get_registry(hass) + actions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + if state: + supported_features = state.attributes.get(CONF_SUPPORTED_FEATURES, 0) + else: + supported_features = entry.supported_features + + # Add actions for each entity that belongs to this integration + for action, action_config in ACTION_TYPES.items(): + if any( + (x & supported_features) == x + for x in action_config[CONF_SUPPORTED_FEATURES] + ): + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: action, + } + ) + + return actions + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} + + config_type = config[CONF_TYPE] + action_config = ACTION_TYPES[config_type] + service: str = action_config[CONF_SERVICE] + + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 14f1eea131c7f7..ffa8151be6d3b2 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -1,6 +1,20 @@ { "title": "Media player", "device_automation": { + "action_type": { + "turn_on": "Turn on {entity_name}", + "turn_off": "Turn off {entity_name}", + "toggle": "Toggle {entity_name}", + "volume_up": "Increase volume of {entity_name}", + "volume_down": "Decrease volume of {entity_name}", + "media_play_pause": "Toggle play {entity_name}", + "media_pause": "Pause {entity_name}", + "media_play": "Play {entity_name}", + "media_stop": "Stop {entity_name}", + "media_next_track": "Play next track on {entity_name}", + "media_previous_track": "Play previous track on {entity_name}", + "clear_playlist": "Clear playlist on {entity_name}" + }, "condition_type": { "is_on": "{entity_name} is on", "is_off": "{entity_name} is off", diff --git a/tests/components/media_player/test_device_action.py b/tests/components/media_player/test_device_action.py new file mode 100644 index 00000000000000..94180de8d98ae6 --- /dev/null +++ b/tests/components/media_player/test_device_action.py @@ -0,0 +1,359 @@ +"""The tests for Media Player device actions.""" +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.media_player import ( + DOMAIN, + SUPPORT_CLEAR_PLAYLIST, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +from homeassistant.helpers import device_registry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + +EXPECTED_ACTION_TYPES = [ + "turn_on", + "turn_off", + "toggle", + "volume_up", + "volume_down", + "media_play", + "media_pause", + "media_play_pause", + "media_stop", + "media_next_track", + "media_previous_track", + "clear_playlist", +] + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +async def test_get_actions(hass, device_reg, entity_reg): + """Test we get the expected actions from a media_player.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create( + DOMAIN, + "test", + "5678", + device_id=device_entry.id, + supported_features=SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_STEP + | SUPPORT_PLAY + | SUPPORT_PAUSE + | SUPPORT_STOP + | SUPPORT_NEXT_TRACK + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_CLEAR_PLAYLIST, + ) + + expected_actions = [ + { + "domain": DOMAIN, + "type": action_type, + "device_id": device_entry.id, + "entity_id": "media_player.test_5678", + } + for action_type in EXPECTED_ACTION_TYPES + ] + + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_action_turn_on(hass, device_reg, entity_reg): + """Test we get the expected actions from a media_player.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create( + DOMAIN, + "test", + "5678", + device_id=device_entry.id, + supported_features=SUPPORT_TURN_ON, + ) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "turn_on", + "device_id": device_entry.id, + "entity_id": "media_player.test_5678", + } + ] + + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_actions_toggle(hass, device_reg, entity_reg): + """Test we get the expected actions from a media_player.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create( + DOMAIN, + "test", + "5678", + device_id=device_entry.id, + supported_features=SUPPORT_TURN_ON | SUPPORT_TURN_OFF, + ) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "turn_on", + "device_id": device_entry.id, + "entity_id": "media_player.test_5678", + }, + { + "domain": DOMAIN, + "type": "turn_off", + "device_id": device_entry.id, + "entity_id": "media_player.test_5678", + }, + { + "domain": DOMAIN, + "type": "toggle", + "device_id": device_entry.id, + "entity_id": "media_player.test_5678", + }, + ] + + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_actions_volume_step(hass, device_reg, entity_reg): + """Test we get the expected actions from a media_player.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create( + DOMAIN, + "test", + "5678", + device_id=device_entry.id, + supported_features=SUPPORT_VOLUME_STEP, + ) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "volume_up", + "device_id": device_entry.id, + "entity_id": "media_player.test_5678", + }, + { + "domain": DOMAIN, + "type": "volume_down", + "device_id": device_entry.id, + "entity_id": "media_player.test_5678", + }, + ] + + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_actions_volume_set(hass, device_reg, entity_reg): + """Test we get the expected actions from a media_player.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create( + DOMAIN, + "test", + "5678", + device_id=device_entry.id, + supported_features=SUPPORT_VOLUME_SET, + ) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "volume_up", + "device_id": device_entry.id, + "entity_id": "media_player.test_5678", + }, + { + "domain": DOMAIN, + "type": "volume_down", + "device_id": device_entry.id, + "entity_id": "media_player.test_5678", + }, + ] + + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_action(hass): + """Test for turn_on and turn_off actions.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": f"test_event_{action_type}", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "media_player.entity", + "type": action_type, + }, + } + for action_type in EXPECTED_ACTION_TYPES + ] + }, + ) + + turn_off_calls = async_mock_service(hass, DOMAIN, "turn_off") + turn_on_calls = async_mock_service(hass, DOMAIN, "turn_on") + toggle_calls = async_mock_service(hass, DOMAIN, "toggle") + + volume_up_calls = async_mock_service(hass, DOMAIN, "volume_up") + volume_down_calls = async_mock_service(hass, DOMAIN, "volume_down") + + clear_playlist_calls = async_mock_service(hass, DOMAIN, "clear_playlist") + + next_track_calls = async_mock_service(hass, DOMAIN, "media_next_track") + previous_track_calls = async_mock_service(hass, DOMAIN, "media_previous_track") + + media_play_calls = async_mock_service(hass, DOMAIN, "media_play") + media_pause_calls = async_mock_service(hass, DOMAIN, "media_pause") + media_play_pause_calls = async_mock_service(hass, DOMAIN, "media_play_pause") + media_stop_calls = async_mock_service(hass, DOMAIN, "media_stop") + + hass.bus.async_fire("test_event_turn_off") + await hass.async_block_till_done() + assert len(turn_off_calls) == 1 + assert len(turn_on_calls) == 0 + + hass.bus.async_fire("test_event_turn_on") + await hass.async_block_till_done() + assert len(turn_off_calls) == 1 + assert len(turn_on_calls) == 1 + + hass.bus.async_fire("test_event_toggle") + await hass.async_block_till_done() + assert len(turn_off_calls) == 1 + assert len(turn_on_calls) == 1 + assert len(toggle_calls) == 1 + + hass.bus.async_fire("test_event_toggle") + await hass.async_block_till_done() + assert len(turn_off_calls) == 1 + assert len(turn_on_calls) == 1 + assert len(toggle_calls) == 2 + + hass.bus.async_fire("test_event_volume_up") + await hass.async_block_till_done() + assert len(volume_up_calls) == 1 + assert len(volume_down_calls) == 0 + + hass.bus.async_fire("test_event_volume_down") + await hass.async_block_till_done() + assert len(volume_up_calls) == 1 + assert len(volume_down_calls) == 1 + + hass.bus.async_fire("test_event_clear_playlist") + await hass.async_block_till_done() + assert len(clear_playlist_calls) == 1 + + hass.bus.async_fire("test_event_media_next_track") + await hass.async_block_till_done() + assert len(next_track_calls) == 1 + assert len(previous_track_calls) == 0 + + hass.bus.async_fire("test_event_media_previous_track") + await hass.async_block_till_done() + assert len(next_track_calls) == 1 + assert len(previous_track_calls) == 1 + + hass.bus.async_fire("test_event_media_play") + await hass.async_block_till_done() + assert len(media_play_calls) == 1 + assert len(media_pause_calls) == 0 + assert len(media_play_pause_calls) == 0 + assert len(media_stop_calls) == 0 + + hass.bus.async_fire("test_event_media_pause") + await hass.async_block_till_done() + assert len(media_play_calls) == 1 + assert len(media_pause_calls) == 1 + assert len(media_play_pause_calls) == 0 + assert len(media_stop_calls) == 0 + + hass.bus.async_fire("test_event_media_play_pause") + await hass.async_block_till_done() + assert len(media_play_calls) == 1 + assert len(media_pause_calls) == 1 + assert len(media_play_pause_calls) == 1 + assert len(media_stop_calls) == 0 + + hass.bus.async_fire("test_event_media_stop") + await hass.async_block_till_done() + assert len(media_play_calls) == 1 + assert len(media_pause_calls) == 1 + assert len(media_play_pause_calls) == 1 + assert len(media_stop_calls) == 1 + + assert len(turn_off_calls) == 1 + assert len(turn_on_calls) == 1 + assert len(toggle_calls) == 2 + assert len(volume_up_calls) == 1 + assert len(volume_down_calls) == 1 + assert len(clear_playlist_calls) == 1 + assert len(next_track_calls) == 1 + assert len(previous_track_calls) == 1