From ff5b070f4b08abe64411ebb5f4d1713a0f5f59cd Mon Sep 17 00:00:00 2001 From: ochlocracy <5885236+ochlocracy@users.noreply.github.com> Date: Thu, 31 Oct 2019 05:38:44 -0400 Subject: [PATCH] Implement Alexa.SeekController Interface for media_player in Alexa (#28299) * Implement Alexa.SeekController Interface for Alexa * Added error handling and duration checks. * Split out media_player SeekController tests and added error test. --- .../components/alexa/capabilities.py | 11 ++ homeassistant/components/alexa/entities.py | 4 + homeassistant/components/alexa/errors.py | 7 + homeassistant/components/alexa/handlers.py | 43 ++++++ tests/components/alexa/test_smart_home.py | 134 ++++++++++++++++-- 5 files changed, 190 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index b5b7fa88ef8..7d74bb3f8cd 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1175,3 +1175,14 @@ class AlexaPlaybackStateReporter(AlexaCapability): return {"state": "PAUSED"} return {"state": "STOPPED"} + + +class AlexaSeekController(AlexaCapability): + """Implements Alexa.SeekController. + + https://developer.amazon.com/docs/device-apis/alexa-seekcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.SeekController" diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index ef6c2902053..6d2f9aef56a 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -52,6 +52,7 @@ from .capabilities import ( AlexaRangeController, AlexaSceneController, AlexaSecurityPanelController, + AlexaSeekController, AlexaSpeaker, AlexaStepSpeaker, AlexaTemperatureSensor, @@ -425,6 +426,9 @@ class MediaPlayerCapabilities(AlexaEntity): yield AlexaPlaybackController(self.entity) yield AlexaPlaybackStateReporter(self.entity) + if supported & media_player.const.SUPPORT_SEEK: + yield AlexaSeekController(self.entity) + if supported & media_player.SUPPORT_SELECT_SOURCE: yield AlexaInputController(self.entity) diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index b0600313fc2..29643bacc53 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -111,3 +111,10 @@ class AlexaInvalidDirectiveError(AlexaError): namespace = "Alexa" error_type = "INVALID_DIRECTIVE" + + +class AlexaVideoActionNotPermittedForContentError(AlexaError): + """Class to represent action not permitted for content errors.""" + + namespace = "Alexa.Video" + error_type = "ACTION_NOT_PERMITTED_FOR_CONTENT" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 3dadf51509a..c23e01f501f 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -54,6 +54,7 @@ from .errors import ( AlexaSecurityPanelUnauthorizedError, AlexaTempRangeError, AlexaUnsupportedThermostatModeError, + AlexaVideoActionNotPermittedForContentError, ) from .state_report import async_enable_proactive_mode @@ -1186,3 +1187,45 @@ async def async_api_skipchannel(hass, config, directive, context): ) return response + + +@HANDLERS.register(("Alexa.SeekController", "AdjustSeekPosition")) +async def async_api_seek(hass, config, directive, context): + """Process a seek request.""" + entity = directive.entity + position_delta = int(directive.payload["deltaPositionMilliseconds"]) + + current_position = entity.attributes.get(media_player.ATTR_MEDIA_POSITION) + if not current_position: + msg = f"{entity} did not return the current media position." + raise AlexaVideoActionNotPermittedForContentError(msg) + + seek_position = int(current_position) + int(position_delta / 1000) + + if seek_position < 0: + seek_position = 0 + + media_duration = entity.attributes.get(media_player.ATTR_MEDIA_DURATION) + if media_duration and 0 < int(media_duration) < seek_position: + seek_position = media_duration + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_SEEK_POSITION: seek_position, + } + + await hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_MEDIA_SEEK, + data, + blocking=False, + context=context, + ) + + # convert seconds to milliseconds for StateReport. + seek_position = int(seek_position * 1000) + + payload = {"properties": [{"name": "positionMilliseconds", "value": seek_position}]} + return directive.response( + name="StateReport", namespace="Alexa.SeekController", payload=payload + ) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 3994c3d9f5d..9b901288f26 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -10,6 +10,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, @@ -728,14 +729,14 @@ async def test_media_player(hass): capabilities = assert_endpoint_capabilities( appliance, + "Alexa.ChannelController", + "Alexa.EndpointHealth", "Alexa.InputController", + "Alexa.PlaybackController", + "Alexa.PlaybackStateReporter", "Alexa.PowerController", "Alexa.Speaker", "Alexa.StepSpeaker", - "Alexa.PlaybackController", - "Alexa.PlaybackStateReporter", - "Alexa.EndpointHealth", - "Alexa.ChannelController", ) playback_capability = get_capability(capabilities, "Alexa.PlaybackController") @@ -950,14 +951,15 @@ async def test_media_player_power(hass): assert_endpoint_capabilities( appliance, + "Alexa.ChannelController", + "Alexa.EndpointHealth", "Alexa.InputController", - "Alexa.PowerController", - "Alexa.Speaker", - "Alexa.StepSpeaker", "Alexa.PlaybackController", "Alexa.PlaybackStateReporter", - "Alexa.EndpointHealth", - "Alexa.ChannelController", + "Alexa.PowerController", + "Alexa.SeekController", + "Alexa.Speaker", + "Alexa.StepSpeaker", ) await assert_request_calls_service( @@ -996,6 +998,120 @@ async def test_media_player_speaker(hass): assert appliance["friendlyName"] == "Test media player" +async def test_media_player_seek(hass): + """Test media player seek capability.""" + device = ( + "media_player.test_seek", + "playing", + { + "friendly_name": "Test media player seek", + "supported_features": SUPPORT_SEEK, + "media_position": 300, # 5min + "media_duration": 600, # 10min + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "media_player#test_seek" + assert appliance["displayCategories"][0] == "TV" + assert appliance["friendlyName"] == "Test media player seek" + + assert_endpoint_capabilities( + appliance, + "Alexa.EndpointHealth", + "Alexa.PowerController", + "Alexa.SeekController", + ) + + # Test seek forward 30 seconds. + call, msg = await assert_request_calls_service( + "Alexa.SeekController", + "AdjustSeekPosition", + "media_player#test_seek", + "media_player.media_seek", + hass, + response_type="StateReport", + payload={"deltaPositionMilliseconds": 30000}, + ) + assert call.data["seek_position"] == 330 + assert "properties" in msg["event"]["payload"] + properties = msg["event"]["payload"]["properties"] + assert {"name": "positionMilliseconds", "value": 330000} in properties + + # Test seek reverse 30 seconds. + call, msg = await assert_request_calls_service( + "Alexa.SeekController", + "AdjustSeekPosition", + "media_player#test_seek", + "media_player.media_seek", + hass, + response_type="StateReport", + payload={"deltaPositionMilliseconds": -30000}, + ) + assert call.data["seek_position"] == 270 + assert "properties" in msg["event"]["payload"] + properties = msg["event"]["payload"]["properties"] + assert {"name": "positionMilliseconds", "value": 270000} in properties + + # Test seek backwards more than current position (5 min.) result = 0. + call, msg = await assert_request_calls_service( + "Alexa.SeekController", + "AdjustSeekPosition", + "media_player#test_seek", + "media_player.media_seek", + hass, + response_type="StateReport", + payload={"deltaPositionMilliseconds": -500000}, + ) + assert call.data["seek_position"] == 0 + assert "properties" in msg["event"]["payload"] + properties = msg["event"]["payload"]["properties"] + assert {"name": "positionMilliseconds", "value": 0} in properties + + # Test seek forward more than current duration (10 min.) result = 600 sec. + call, msg = await assert_request_calls_service( + "Alexa.SeekController", + "AdjustSeekPosition", + "media_player#test_seek", + "media_player.media_seek", + hass, + response_type="StateReport", + payload={"deltaPositionMilliseconds": 800000}, + ) + assert call.data["seek_position"] == 600 + assert "properties" in msg["event"]["payload"] + properties = msg["event"]["payload"]["properties"] + assert {"name": "positionMilliseconds", "value": 600000} in properties + + +async def test_media_player_seek_error(hass): + """Test media player seek capability for media_position Error.""" + device = ( + "media_player.test_seek", + "playing", + {"friendly_name": "Test media player seek", "supported_features": SUPPORT_SEEK}, + ) + await discovery_test(device, hass) + + # Test for media_position error. + with pytest.raises(AssertionError): + call, msg = await assert_request_calls_service( + "Alexa.SeekController", + "AdjustSeekPosition", + "media_player#test_seek", + "media_player.media_seek", + hass, + response_type="StateReport", + payload={"deltaPositionMilliseconds": 30000}, + ) + + assert "event" in msg + msg = msg["event"] + assert msg["header"]["name"] == "ErrorResponse" + assert msg["header"]["namespace"] == "Alexa.Video" + assert msg["payload"]["type"] == "ACTION_NOT_PERMITTED_FOR_CONTENT" + + async def test_alert(hass): """Test alert discovery.""" device = ("alert.test", "off", {"friendly_name": "Test alert"})