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 9af95e8577)

* 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().
This commit is contained in:
ochlocracy 2020-03-28 00:19:11 -04:00 committed by GitHub
parent 369ffe2288
commit 28a2c9c653
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 254 additions and 16 deletions

View file

@ -98,11 +98,7 @@ async def async_setup(hass, config):
f"send command {data['request']['namespace']}/{data['request']['name']}" f"send command {data['request']['namespace']}/{data['request']['name']}"
) )
return { return {"name": "Amazon Alexa", "message": message, "entity_id": entity_id}
"name": "Amazon Alexa",
"message": message,
"entity_id": entity_id,
}
hass.components.logbook.async_describe_event( hass.components.logbook.async_describe_event(
DOMAIN, EVENT_ALEXA_SMART_HOME, async_describe_logbook_event DOMAIN, EVENT_ALEXA_SMART_HOME, async_describe_logbook_event

View file

@ -169,6 +169,11 @@ class AlexaCapability:
"""Return the supportedOperations object.""" """Return the supportedOperations object."""
return [] return []
@staticmethod
def camera_stream_configurations():
"""Applicable only to CameraStreamController."""
return None
def serialize_discovery(self): def serialize_discovery(self):
"""Serialize according to the Discovery API.""" """Serialize according to the Discovery API."""
result = {"type": "AlexaInterface", "interface": self.name(), "version": "3"} result = {"type": "AlexaInterface", "interface": self.name(), "version": "3"}
@ -222,6 +227,10 @@ class AlexaCapability:
if inputs: if inputs:
result["inputs"] = inputs result["inputs"] = inputs
camera_stream_configurations = self.camera_stream_configurations()
if camera_stream_configurations:
result["cameraStreamConfigurations"] = camera_stream_configurations
return result return result
def serialize_properties(self): def serialize_properties(self):
@ -1854,3 +1863,40 @@ class AlexaTimeHoldController(AlexaCapability):
When false, Alexa does not send the Resume directive. When false, Alexa does not send the Resume directive.
""" """
return {"allowRemoteResume": self._allow_remote_resume} 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

View file

@ -1,11 +1,14 @@
"""Alexa entity adapters.""" """Alexa entity adapters."""
import logging
from typing import List from typing import List
from urllib.parse import urlparse
from homeassistant.components import ( from homeassistant.components import (
alarm_control_panel, alarm_control_panel,
alert, alert,
automation, automation,
binary_sensor, binary_sensor,
camera,
cover, cover,
fan, fan,
group, group,
@ -33,11 +36,13 @@ from homeassistant.const import (
TEMP_FAHRENHEIT, TEMP_FAHRENHEIT,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import network
from homeassistant.util.decorator import Registry from homeassistant.util.decorator import Registry
from .capabilities import ( from .capabilities import (
Alexa, Alexa,
AlexaBrightnessController, AlexaBrightnessController,
AlexaCameraStreamController,
AlexaChannelController, AlexaChannelController,
AlexaColorController, AlexaColorController,
AlexaColorTemperatureController, AlexaColorTemperatureController,
@ -68,6 +73,8 @@ from .capabilities import (
) )
from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES
_LOGGER = logging.getLogger(__name__)
ENTITY_ADAPTERS = Registry() ENTITY_ADAPTERS = Registry()
TRANSLATION_TABLE = dict.fromkeys(map(ord, r"}{\/|\"()[]+~!><*%"), None) TRANSLATION_TABLE = dict.fromkeys(map(ord, r"}{\/|\"()[]+~!><*%"), None)
@ -763,3 +770,41 @@ class VacuumCapabilities(AlexaEntity):
yield AlexaEndpointHealth(self.hass, self.entity) yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.hass) 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

View file

@ -4,6 +4,7 @@ import math
from homeassistant import core as ha from homeassistant import core as ha
from homeassistant.components import ( from homeassistant.components import (
camera,
cover, cover,
fan, fan,
group, group,
@ -41,6 +42,7 @@ from homeassistant.const import (
TEMP_CELSIUS, TEMP_CELSIUS,
TEMP_FAHRENHEIT, TEMP_FAHRENHEIT,
) )
from homeassistant.helpers import network
import homeassistant.util.color as color_util import homeassistant.util.color as color_util
from homeassistant.util.decorator import Registry from homeassistant.util.decorator import Registry
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@ -1523,3 +1525,28 @@ async def async_api_resume(hass, config, directive, context):
) )
return directive.response() 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
)

View file

@ -4,6 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/alexa", "documentation": "https://www.home-assistant.io/integrations/alexa",
"requirements": [], "requirements": [],
"dependencies": ["http"], "dependencies": ["http"],
"after_dependencies": ["logbook"], "after_dependencies": ["logbook", "camera"],
"codeowners": ["@home-assistant/cloud", "@ochlocracy"] "codeowners": ["@home-assistant/cloud", "@ochlocracy"]
} }

View file

@ -19,6 +19,7 @@ class MockConfig(config.AbstractConfig):
"binary_sensor.test_contact_forced": {"display_categories": "CONTACT_SENSOR"}, "binary_sensor.test_contact_forced": {"display_categories": "CONTACT_SENSOR"},
"binary_sensor.test_motion_forced": {"display_categories": "MOTION_SENSOR"}, "binary_sensor.test_motion_forced": {"display_categories": "MOTION_SENSOR"},
"binary_sensor.test_motion_camera_event": {"display_categories": "CAMERA"}, "binary_sensor.test_motion_camera_event": {"display_categories": "CAMERA"},
"camera.test": {"display_categories": "CAMERA"},
} }
@property @property

View file

@ -1,7 +1,10 @@
"""Test for smart home alexa support.""" """Test for smart home alexa support."""
from unittest.mock import patch
import pytest import pytest
from homeassistant.components.alexa import messages, smart_home from homeassistant.components.alexa import messages, smart_home
import homeassistant.components.camera as camera
from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.const import (
SUPPORT_NEXT_TRACK, SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE, SUPPORT_PAUSE,
@ -22,6 +25,7 @@ import homeassistant.components.vacuum as vacuum
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.core import Context, callback from homeassistant.core import Context, callback
from homeassistant.helpers import entityfilter from homeassistant.helpers import entityfilter
from homeassistant.setup import async_setup_component
from . import ( from . import (
DEFAULT_CONFIG, DEFAULT_CONFIG,
@ -35,7 +39,7 @@ from . import (
reported_properties, reported_properties,
) )
from tests.common import async_mock_service from tests.common import async_mock_service, mock_coro
@pytest.fixture @pytest.fixture
@ -48,6 +52,22 @@ def events(hass):
yield events 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): def test_create_api_message_defaults(hass):
"""Create a API message response of a request with defaults.""" """Create a API message response of a request with defaults."""
request = get_new_request("Alexa.PowerController", "TurnOn", "switch#xy") 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") properties.assert_equal("Alexa.PowerController", "powerState", "OFF")
await assert_request_calls_service( 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( 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) appliance = await discovery_test(device, hass)
assert_endpoint_capabilities( assert_endpoint_capabilities(
appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa", appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"
) )
properties = await reported_properties(hass, "vacuum#test_5") properties = await reported_properties(hass, "vacuum#test_5")
properties.assert_equal("Alexa.PowerController", "powerState", "ON") properties.assert_equal("Alexa.PowerController", "powerState", "ON")
await assert_request_calls_service( 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( 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) appliance = await discovery_test(device, hass)
assert_endpoint_capabilities( assert_endpoint_capabilities(
appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa", appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"
) )
await assert_request_calls_service( 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( 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) appliance = await discovery_test(device, hass)
assert_endpoint_capabilities( assert_endpoint_capabilities(
appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa", appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"
) )
await assert_request_calls_service( 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( await assert_request_calls_service(
@ -3736,3 +3756,106 @@ async def test_vacuum_discovery_no_turn_on_or_off(hass):
"vacuum.return_to_base", "vacuum.return_to_base",
hass, 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"]
)