From 28a2c9c653961439185ea71018a90440df535f85 Mon Sep 17 00:00:00 2001 From: ochlocracy <5885236+ochlocracy@users.noreply.github.com> Date: Sat, 28 Mar 2020 00:19:11 -0400 Subject: [PATCH] Implement Alexa.CameraStreamController in Alexa (#32772) * Implement Alexa.CameraStreamController. * Add dependencies. * Add camera helpers for image url, and mjpeg url. * Remove unsupported AlexaPowerController from cameras. * Refactor camera_stream_url to hass_url * Declare HLS instead of RTSP. * Add test for async_get_image_url() and async_get_mpeg_stream_url(). * Sort imports. * Fix camera.options to camera.stream_options. (#32767) (cherry picked from commit 9af95e85779c1cfabc6cb779df10cab72c3e7a69) * Remove URL configuration option for AlexaCameraStreamController. * Update test_initialize_camera_stream. * Optimize camera stream configuration. * Update Tests for optimized camera stream configuration. * Sort imports. * Add check for Stream integration. * Checks and Balances. * Remove unnecessary camera helpers. * Return None instead of empty list for camera_stream_configurations(). --- homeassistant/components/alexa/__init__.py | 6 +- .../components/alexa/capabilities.py | 46 ++++++ homeassistant/components/alexa/entities.py | 45 ++++++ homeassistant/components/alexa/handlers.py | 27 ++++ homeassistant/components/alexa/manifest.json | 2 +- tests/components/alexa/__init__.py | 1 + tests/components/alexa/test_smart_home.py | 143 ++++++++++++++++-- 7 files changed, 254 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 1355b0123b8..de5a67087ca 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -98,11 +98,7 @@ async def async_setup(hass, config): f"send command {data['request']['namespace']}/{data['request']['name']}" ) - return { - "name": "Amazon Alexa", - "message": message, - "entity_id": entity_id, - } + return {"name": "Amazon Alexa", "message": message, "entity_id": entity_id} hass.components.logbook.async_describe_event( DOMAIN, EVENT_ALEXA_SMART_HOME, async_describe_logbook_event diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 6ab086ddda3..63be7df2ead 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -169,6 +169,11 @@ class AlexaCapability: """Return the supportedOperations object.""" return [] + @staticmethod + def camera_stream_configurations(): + """Applicable only to CameraStreamController.""" + return None + def serialize_discovery(self): """Serialize according to the Discovery API.""" result = {"type": "AlexaInterface", "interface": self.name(), "version": "3"} @@ -222,6 +227,10 @@ class AlexaCapability: if inputs: result["inputs"] = inputs + camera_stream_configurations = self.camera_stream_configurations() + if camera_stream_configurations: + result["cameraStreamConfigurations"] = camera_stream_configurations + return result def serialize_properties(self): @@ -1854,3 +1863,40 @@ class AlexaTimeHoldController(AlexaCapability): When false, Alexa does not send the Resume directive. """ return {"allowRemoteResume": self._allow_remote_resume} + + +class AlexaCameraStreamController(AlexaCapability): + """Implements Alexa.CameraStreamController. + + https://developer.amazon.com/docs/device-apis/alexa-camerastreamcontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.CameraStreamController" + + def camera_stream_configurations(self): + """Return cameraStreamConfigurations object.""" + camera_stream_configurations = [ + { + "protocols": ["HLS"], + "resolutions": [{"width": 1280, "height": 720}], + "authorizationTypes": ["NONE"], + "videoCodecs": ["H264"], + "audioCodecs": ["AAC"], + } + ] + return camera_stream_configurations diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index aa9fe40164c..fce05c8dc86 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -1,11 +1,14 @@ """Alexa entity adapters.""" +import logging from typing import List +from urllib.parse import urlparse from homeassistant.components import ( alarm_control_panel, alert, automation, binary_sensor, + camera, cover, fan, group, @@ -33,11 +36,13 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import callback +from homeassistant.helpers import network from homeassistant.util.decorator import Registry from .capabilities import ( Alexa, AlexaBrightnessController, + AlexaCameraStreamController, AlexaChannelController, AlexaColorController, AlexaColorTemperatureController, @@ -68,6 +73,8 @@ from .capabilities import ( ) from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES +_LOGGER = logging.getLogger(__name__) + ENTITY_ADAPTERS = Registry() TRANSLATION_TABLE = dict.fromkeys(map(ord, r"}{\/|\"()[]+~!><*%"), None) @@ -763,3 +770,41 @@ class VacuumCapabilities(AlexaEntity): yield AlexaEndpointHealth(self.hass, self.entity) yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(camera.DOMAIN) +class CameraCapabilities(AlexaEntity): + """Class to represent Camera capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.CAMERA] + + def interfaces(self): + """Yield the supported interfaces.""" + if self._check_requirements(): + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & camera.SUPPORT_STREAM: + yield AlexaCameraStreamController(self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + def _check_requirements(self): + """Check the hass URL for HTTPS scheme and port 443.""" + if "stream" not in self.hass.config.components: + _LOGGER.error( + "%s requires stream component for AlexaCameraStreamController", + self.entity_id, + ) + return False + + url = urlparse(network.async_get_external_url(self.hass)) + if url.scheme != "https" or (url.port is not None and url.port != 443): + _LOGGER.error( + "%s requires HTTPS support on port 443 for AlexaCameraStreamController", + self.entity_id, + ) + return False + + return True diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 67083607769..b3885588b0f 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -4,6 +4,7 @@ import math from homeassistant import core as ha from homeassistant.components import ( + camera, cover, fan, group, @@ -41,6 +42,7 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, ) +from homeassistant.helpers import network import homeassistant.util.color as color_util from homeassistant.util.decorator import Registry import homeassistant.util.dt as dt_util @@ -1523,3 +1525,28 @@ async def async_api_resume(hass, config, directive, context): ) return directive.response() + + +@HANDLERS.register(("Alexa.CameraStreamController", "InitializeCameraStreams")) +async def async_api_initialize_camera_stream(hass, config, directive, context): + """Process a InitializeCameraStreams request.""" + entity = directive.entity + stream_source = await camera.async_request_stream(hass, entity.entity_id, fmt="hls") + camera_image = hass.states.get(entity.entity_id).attributes["entity_picture"] + external_url = network.async_get_external_url(hass) + payload = { + "cameraStreams": [ + { + "uri": f"{external_url}{stream_source}", + "protocol": "HLS", + "resolution": {"width": 1280, "height": 720}, + "authorizationType": "NONE", + "videoCodec": "H264", + "audioCodec": "AAC", + } + ], + "imageUri": f"{external_url}{camera_image}", + } + return directive.response( + name="Response", namespace="Alexa.CameraStreamController", payload=payload + ) diff --git a/homeassistant/components/alexa/manifest.json b/homeassistant/components/alexa/manifest.json index bf8d4b08ba4..d47e5dea96a 100644 --- a/homeassistant/components/alexa/manifest.json +++ b/homeassistant/components/alexa/manifest.json @@ -4,6 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/alexa", "requirements": [], "dependencies": ["http"], - "after_dependencies": ["logbook"], + "after_dependencies": ["logbook", "camera"], "codeowners": ["@home-assistant/cloud", "@ochlocracy"] } diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index fa2917edcad..04f90476c57 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -19,6 +19,7 @@ class MockConfig(config.AbstractConfig): "binary_sensor.test_contact_forced": {"display_categories": "CONTACT_SENSOR"}, "binary_sensor.test_motion_forced": {"display_categories": "MOTION_SENSOR"}, "binary_sensor.test_motion_camera_event": {"display_categories": "CAMERA"}, + "camera.test": {"display_categories": "CAMERA"}, } @property diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index fa8f7fbdc9a..a0d40460373 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1,7 +1,10 @@ """Test for smart home alexa support.""" +from unittest.mock import patch + import pytest from homeassistant.components.alexa import messages, smart_home +import homeassistant.components.camera as camera from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, @@ -22,6 +25,7 @@ import homeassistant.components.vacuum as vacuum from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import Context, callback from homeassistant.helpers import entityfilter +from homeassistant.setup import async_setup_component from . import ( DEFAULT_CONFIG, @@ -35,7 +39,7 @@ from . import ( reported_properties, ) -from tests.common import async_mock_service +from tests.common import async_mock_service, mock_coro @pytest.fixture @@ -48,6 +52,22 @@ def events(hass): yield events +@pytest.fixture +def mock_camera(hass): + """Initialize a demo camera platform.""" + assert hass.loop.run_until_complete( + async_setup_component(hass, "camera", {camera.DOMAIN: {"platform": "demo"}}) + ) + + +@pytest.fixture +def mock_stream(hass): + """Initialize a demo camera platform with streaming.""" + assert hass.loop.run_until_complete( + async_setup_component(hass, "stream", {"stream": {}}) + ) + + def test_create_api_message_defaults(hass): """Create a API message response of a request with defaults.""" request = get_new_request("Alexa.PowerController", "TurnOn", "switch#xy") @@ -3445,11 +3465,11 @@ async def test_vacuum_discovery(hass): properties.assert_equal("Alexa.PowerController", "powerState", "OFF") await assert_request_calls_service( - "Alexa.PowerController", "TurnOn", "vacuum#test_1", "vacuum.turn_on", hass, + "Alexa.PowerController", "TurnOn", "vacuum#test_1", "vacuum.turn_on", hass ) await assert_request_calls_service( - "Alexa.PowerController", "TurnOff", "vacuum#test_1", "vacuum.turn_off", hass, + "Alexa.PowerController", "TurnOff", "vacuum#test_1", "vacuum.turn_off", hass ) @@ -3663,18 +3683,18 @@ async def test_vacuum_discovery_no_turn_on(hass): appliance = await discovery_test(device, hass) assert_endpoint_capabilities( - appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa", + appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa" ) properties = await reported_properties(hass, "vacuum#test_5") properties.assert_equal("Alexa.PowerController", "powerState", "ON") await assert_request_calls_service( - "Alexa.PowerController", "TurnOn", "vacuum#test_5", "vacuum.start", hass, + "Alexa.PowerController", "TurnOn", "vacuum#test_5", "vacuum.start", hass ) await assert_request_calls_service( - "Alexa.PowerController", "TurnOff", "vacuum#test_5", "vacuum.turn_off", hass, + "Alexa.PowerController", "TurnOff", "vacuum#test_5", "vacuum.turn_off", hass ) @@ -3693,11 +3713,11 @@ async def test_vacuum_discovery_no_turn_off(hass): appliance = await discovery_test(device, hass) assert_endpoint_capabilities( - appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa", + appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa" ) await assert_request_calls_service( - "Alexa.PowerController", "TurnOn", "vacuum#test_6", "vacuum.turn_on", hass, + "Alexa.PowerController", "TurnOn", "vacuum#test_6", "vacuum.turn_on", hass ) await assert_request_calls_service( @@ -3722,11 +3742,11 @@ async def test_vacuum_discovery_no_turn_on_or_off(hass): appliance = await discovery_test(device, hass) assert_endpoint_capabilities( - appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa", + appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa" ) await assert_request_calls_service( - "Alexa.PowerController", "TurnOn", "vacuum#test_7", "vacuum.start", hass, + "Alexa.PowerController", "TurnOn", "vacuum#test_7", "vacuum.start", hass ) await assert_request_calls_service( @@ -3736,3 +3756,106 @@ async def test_vacuum_discovery_no_turn_on_or_off(hass): "vacuum.return_to_base", hass, ) + + +async def test_camera_discovery(hass, mock_stream): + """Test camera discovery.""" + device = ( + "camera.test", + "idle", + {"friendly_name": "Test camera", "supported_features": 3}, + ) + with patch( + "homeassistant.helpers.network.async_get_external_url", + return_value="https://example.nabu.casa", + ): + appliance = await discovery_test(device, hass) + + capabilities = assert_endpoint_capabilities( + appliance, "Alexa.CameraStreamController", "Alexa.EndpointHealth", "Alexa" + ) + + camera_stream_capability = get_capability( + capabilities, "Alexa.CameraStreamController" + ) + configuration = camera_stream_capability["cameraStreamConfigurations"][0] + assert "HLS" in configuration["protocols"] + assert {"width": 1280, "height": 720} in configuration["resolutions"] + assert "NONE" in configuration["authorizationTypes"] + assert "H264" in configuration["videoCodecs"] + assert "AAC" in configuration["audioCodecs"] + + +async def test_camera_discovery_without_stream(hass): + """Test camera discovery without stream integration.""" + device = ( + "camera.test", + "idle", + {"friendly_name": "Test camera", "supported_features": 3}, + ) + with patch( + "homeassistant.helpers.network.async_get_external_url", + return_value="https://example.nabu.casa", + ): + appliance = await discovery_test(device, hass) + # assert Alexa.CameraStreamController is not yielded. + assert_endpoint_capabilities(appliance, "Alexa.EndpointHealth", "Alexa") + + +@pytest.mark.parametrize( + "url,result", + [ + ("http://nohttpswrongport.org:8123", 2), + ("https://httpswrongport.org:8123", 2), + ("http://nohttpsport443.org:443", 2), + ("tls://nohttpsport443.org:443", 2), + ("https://correctschemaandport.org:443", 3), + ("https://correctschemaandport.org", 3), + ], +) +async def test_camera_hass_urls(hass, mock_stream, url, result): + """Test camera discovery with unsupported urls.""" + device = ( + "camera.test", + "idle", + {"friendly_name": "Test camera", "supported_features": 3}, + ) + with patch( + "homeassistant.helpers.network.async_get_external_url", return_value=url + ): + appliance = await discovery_test(device, hass) + assert len(appliance["capabilities"]) == result + + +async def test_initialize_camera_stream(hass, mock_camera, mock_stream): + """Test InitializeCameraStreams handler.""" + request = get_new_request( + "Alexa.CameraStreamController", "InitializeCameraStreams", "camera#demo_camera" + ) + + with patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value=mock_coro("rtsp://example.local"), + ), patch( + "homeassistant.helpers.network.async_get_external_url", + return_value="https://mycamerastream.test", + ): + msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert "event" in msg + response = msg["event"] + assert response["header"]["namespace"] == "Alexa.CameraStreamController" + assert response["header"]["name"] == "Response" + camera_streams = response["payload"]["cameraStreams"] + assert "https://mycamerastream.test/api/hls/" in camera_streams[0]["uri"] + assert camera_streams[0]["protocol"] == "HLS" + assert camera_streams[0]["resolution"]["width"] == 1280 + assert camera_streams[0]["resolution"]["height"] == 720 + assert camera_streams[0]["authorizationType"] == "NONE" + assert camera_streams[0]["videoCodec"] == "H264" + assert camera_streams[0]["audioCodec"] == "AAC" + assert ( + "https://mycamerastream.test/api/camera_proxy/camera.demo_camera?token=" + in response["payload"]["imageUri"] + )