diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index c1b8d704608..88b704eb518 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -97,6 +97,7 @@ ERR_NOT_SUPPORTED = "notSupported" ERR_PROTOCOL_ERROR = "protocolError" ERR_UNKNOWN_ERROR = "unknownError" ERR_FUNCTION_NOT_SUPPORTED = "functionNotSupported" +ERR_UNSUPPORTED_INPUT = "unsupportedInput" ERR_ALREADY_DISARMED = "alreadyDisarmed" ERR_ALREADY_ARMED = "alreadyArmed" diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 90b5016260d..9afdff4045f 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1,5 +1,6 @@ """Implement the Google Smart Home traits.""" import logging +from typing import List, Optional from homeassistant.components import ( alarm_control_panel, @@ -68,6 +69,7 @@ from .const import ( ERR_CHALLENGE_NOT_SETUP, ERR_FUNCTION_NOT_SUPPORTED, ERR_NOT_SUPPORTED, + ERR_UNSUPPORTED_INPUT, ERR_VALUE_OUT_OF_RANGE, ) from .error import ChallengeNeeded, SmartHomeError @@ -114,6 +116,8 @@ COMMAND_LOCKUNLOCK = f"{PREFIX_COMMANDS}LockUnlock" COMMAND_FANSPEED = f"{PREFIX_COMMANDS}SetFanSpeed" COMMAND_MODES = f"{PREFIX_COMMANDS}SetModes" COMMAND_INPUT = f"{PREFIX_COMMANDS}SetInput" +COMMAND_NEXT_INPUT = f"{PREFIX_COMMANDS}NextInput" +COMMAND_PREVIOUS_INPUT = f"{PREFIX_COMMANDS}PreviousInput" COMMAND_OPENCLOSE = f"{PREFIX_COMMANDS}OpenClose" COMMAND_SET_VOLUME = f"{PREFIX_COMMANDS}setVolume" COMMAND_VOLUME_RELATIVE = f"{PREFIX_COMMANDS}volumeRelative" @@ -145,6 +149,20 @@ def _google_temp_unit(units): return "C" +def _next_selected(items: List[str], selected: Optional[str]) -> Optional[str]: + """Return the next item in a item list starting at given value. + + If selected is missing in items, None is returned + """ + try: + index = items.index(selected) + except ValueError: + return None + + next_item = 0 if index == len(items) - 1 else index + 1 + return items[next_item] + + class _Trait: """Represents a Trait inside Google Assistant skill.""" @@ -1395,7 +1413,7 @@ class InputSelectorTrait(_Trait): """ name = TRAIT_INPUTSELECTOR - commands = [COMMAND_INPUT] + commands = [COMMAND_INPUT, COMMAND_NEXT_INPUT, COMMAND_PREVIOUS_INPUT] SYNONYMS = {} @@ -1428,7 +1446,20 @@ class InputSelectorTrait(_Trait): async def execute(self, command, data, params, challenge): """Execute an SetInputSource command.""" - requested_source = params.get("newInput") + sources = self.state.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or [] + source = self.state.attributes.get(media_player.ATTR_INPUT_SOURCE) + + if command == COMMAND_INPUT: + requested_source = params.get("newInput") + elif command == COMMAND_NEXT_INPUT: + requested_source = _next_selected(sources, source) + elif command == COMMAND_PREVIOUS_INPUT: + requested_source = _next_selected(list(reversed(sources)), source) + else: + raise SmartHomeError(ERR_NOT_SUPPORTED, "Unsupported command") + + if requested_source not in sources: + raise SmartHomeError(ERR_UNSUPPORTED_INPUT, "Unsupported input") await self.hass.services.async_call( media_player.DOMAIN, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index faad53fbc66..0ca53d256a4 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -24,6 +24,7 @@ from homeassistant.components import ( ) from homeassistant.components.climate import const as climate from homeassistant.components.google_assistant import const, error, helpers, trait +from homeassistant.components.google_assistant.error import SmartHomeError from homeassistant.components.humidifier import const as humidifier from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( @@ -1428,6 +1429,87 @@ async def test_inputselector(hass): assert calls[0].data == {"entity_id": "media_player.living_room", "source": "media"} +@pytest.mark.parametrize( + "sources,source,source_next,source_prev", + [ + (["a"], "a", "a", "a"), + (["a", "b"], "a", "b", "b"), + (["a", "b", "c"], "a", "b", "c"), + ], +) +async def test_inputselector_nextprev(hass, sources, source, source_next, source_prev): + """Test input selector trait.""" + trt = trait.InputSelectorTrait( + hass, + State( + "media_player.living_room", + media_player.STATE_PLAYING, + attributes={ + media_player.ATTR_INPUT_SOURCE_LIST: sources, + media_player.ATTR_INPUT_SOURCE: source, + }, + ), + BASIC_CONFIG, + ) + + assert trt.can_execute("action.devices.commands.NextInput", params={}) + assert trt.can_execute("action.devices.commands.PreviousInput", params={}) + + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE + ) + await trt.execute( + "action.devices.commands.NextInput", BASIC_DATA, {}, {}, + ) + await trt.execute( + "action.devices.commands.PreviousInput", BASIC_DATA, {}, {}, + ) + + assert len(calls) == 2 + assert calls[0].data == { + "entity_id": "media_player.living_room", + "source": source_next, + } + assert calls[1].data == { + "entity_id": "media_player.living_room", + "source": source_prev, + } + + +@pytest.mark.parametrize( + "sources,source", [(None, "a"), (["a", "b"], None), (["a", "b"], "c")] +) +async def test_inputselector_nextprev_invalid(hass, sources, source): + """Test input selector trait.""" + trt = trait.InputSelectorTrait( + hass, + State( + "media_player.living_room", + media_player.STATE_PLAYING, + attributes={ + media_player.ATTR_INPUT_SOURCE_LIST: sources, + media_player.ATTR_INPUT_SOURCE: source, + }, + ), + BASIC_CONFIG, + ) + + with pytest.raises(SmartHomeError): + await trt.execute( + "action.devices.commands.NextInput", BASIC_DATA, {}, {}, + ) + + with pytest.raises(SmartHomeError): + await trt.execute( + "action.devices.commands.PreviousInput", BASIC_DATA, {}, {}, + ) + + with pytest.raises(SmartHomeError): + await trt.execute( + "action.devices.commands.InvalidCommand", BASIC_DATA, {}, {}, + ) + + async def test_modes_input_select(hass): """Test Input Select Mode trait.""" assert helpers.get_google_type(input_select.DOMAIN, None) is not None