diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index d6badf2e7ba..3294ff54c2e 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -99,6 +99,7 @@ ERR_PROTOCOL_ERROR = "protocolError" ERR_UNKNOWN_ERROR = "unknownError" ERR_FUNCTION_NOT_SUPPORTED = "functionNotSupported" ERR_UNSUPPORTED_INPUT = "unsupportedInput" +ERR_NO_AVAILABLE_CHANNEL = "noAvailableChannel" 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 64f803dab25..8286e527159 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -23,6 +23,7 @@ from homeassistant.components import ( ) from homeassistant.components.climate import const as climate from homeassistant.components.humidifier import const as humidifier +from homeassistant.components.media_player.const import MEDIA_TYPE_CHANNEL from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_CODE, @@ -71,6 +72,7 @@ from .const import ( ERR_ALREADY_DISARMED, ERR_ALREADY_STOPPED, ERR_CHALLENGE_NOT_SETUP, + ERR_NO_AVAILABLE_CHANNEL, ERR_NOT_SUPPORTED, ERR_UNSUPPORTED_INPUT, ERR_VALUE_OUT_OF_RANGE, @@ -99,6 +101,7 @@ TRAIT_ARMDISARM = f"{PREFIX_TRAITS}ArmDisarm" TRAIT_HUMIDITY_SETTING = f"{PREFIX_TRAITS}HumiditySetting" TRAIT_TRANSPORT_CONTROL = f"{PREFIX_TRAITS}TransportControl" TRAIT_MEDIA_STATE = f"{PREFIX_TRAITS}MediaState" +TRAIT_CHANNEL = f"{PREFIX_TRAITS}Channel" PREFIX_COMMANDS = "action.devices.commands." COMMAND_ONOFF = f"{PREFIX_COMMANDS}OnOff" @@ -137,7 +140,7 @@ COMMAND_MEDIA_SEEK_TO_POSITION = f"{PREFIX_COMMANDS}mediaSeekToPosition" COMMAND_MEDIA_SHUFFLE = f"{PREFIX_COMMANDS}mediaShuffle" COMMAND_MEDIA_STOP = f"{PREFIX_COMMANDS}mediaStop" COMMAND_SET_HUMIDITY = f"{PREFIX_COMMANDS}SetHumidity" - +COMMAND_SELECT_CHANNEL = f"{PREFIX_COMMANDS}selectChannel" TRAITS = [] @@ -2070,3 +2073,59 @@ class MediaStateTrait(_Trait): "activityState": self.activity_lookup.get(self.state.state, "INACTIVE"), "playbackState": self.playback_lookup.get(self.state.state, "STOPPED"), } + + +@register_trait +class ChannelTrait(_Trait): + """Trait to get media playback state. + + https://developers.google.com/actions/smarthome/traits/channel + """ + + name = TRAIT_CHANNEL + commands = [COMMAND_SELECT_CHANNEL] + + @staticmethod + def supported(domain, features, device_class, _): + """Test if state is supported.""" + if ( + domain == media_player.DOMAIN + and (features & media_player.SUPPORT_PLAY_MEDIA) + and device_class == media_player.DEVICE_CLASS_TV + ): + return True + + return False + + def sync_attributes(self): + """Return attributes for a sync request.""" + return {"availableChannels": [], "commandOnlyChannels": True} + + def query_attributes(self): + """Return channel query attributes.""" + return {} + + async def execute(self, command, data, params, challenge): + """Execute an setChannel command.""" + if command == COMMAND_SELECT_CHANNEL: + channel_number = params.get("channelNumber") + else: + raise SmartHomeError(ERR_NOT_SUPPORTED, "Unsupported command") + + if not channel_number: + raise SmartHomeError( + ERR_NO_AVAILABLE_CHANNEL, + "Channel is not available", + ) + + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_MEDIA_CONTENT_ID: channel_number, + media_player.ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_CHANNEL, + }, + blocking=True, + context=data.context, + ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 3d506be644d..c3678e7f99a 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -26,6 +26,10 @@ 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.components.media_player.const import ( + MEDIA_TYPE_CHANNEL, + SERVICE_PLAY_MEDIA, +) from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -2653,3 +2657,52 @@ async def test_media_state(hass, state): "activityState": trt.activity_lookup.get(state), "playbackState": trt.playback_lookup.get(state), } + + +async def test_channel(hass): + """Test Channel trait support.""" + assert helpers.get_google_type(media_player.DOMAIN, None) is not None + assert trait.ChannelTrait.supported( + media_player.DOMAIN, + media_player.SUPPORT_PLAY_MEDIA, + media_player.DEVICE_CLASS_TV, + None, + ) + assert ( + trait.ChannelTrait.supported( + media_player.DOMAIN, media_player.SUPPORT_PLAY_MEDIA, None, None + ) + is False + ) + assert trait.ChannelTrait.supported(media_player.DOMAIN, 0, None, None) is False + + trt = trait.ChannelTrait(hass, State("media_player.demo", STATE_ON), BASIC_CONFIG) + + assert trt.sync_attributes() == { + "availableChannels": [], + "commandOnlyChannels": True, + } + assert trt.query_attributes() == {} + + media_player_calls = async_mock_service( + hass, media_player.DOMAIN, SERVICE_PLAY_MEDIA + ) + await trt.execute( + trait.COMMAND_SELECT_CHANNEL, BASIC_DATA, {"channelNumber": "1"}, {} + ) + assert len(media_player_calls) == 1 + assert media_player_calls[0].data == { + ATTR_ENTITY_ID: "media_player.demo", + media_player.ATTR_MEDIA_CONTENT_ID: "1", + media_player.ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_CHANNEL, + } + + with pytest.raises(SmartHomeError, match="Channel is not available"): + await trt.execute( + trait.COMMAND_SELECT_CHANNEL, BASIC_DATA, {"channelCode": "Channel 3"}, {} + ) + assert len(media_player_calls) == 1 + + with pytest.raises(SmartHomeError, match="Unsupported command"): + await trt.execute("Unknown command", BASIC_DATA, {"channelNumber": "1"}, {}) + assert len(media_player_calls) == 1