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:
parent
369ffe2288
commit
28a2c9c653
7 changed files with 254 additions and 16 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue