Google Assistant: Add camera stream trait (#22278)

* Add camera stream trait

* Lint
This commit is contained in:
Paulus Schoutsen 2019-03-23 09:16:43 -07:00 committed by GitHub
parent d81df1f0ae
commit c68b621972
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 176 additions and 7 deletions

View File

@ -60,6 +60,7 @@ STATE_IDLE = 'idle'
# Bitfield of features supported by the camera entity
SUPPORT_ON_OFF = 1
SUPPORT_STREAM = 2
DEFAULT_CONTENT_TYPE = 'image/jpeg'
ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}'
@ -98,6 +99,18 @@ class Image:
content = attr.ib(type=bytes)
@bind_hass
async def async_request_stream(hass, entity_id, fmt):
"""Request a stream for a camera entity."""
camera = _get_camera_from_entity_id(hass, entity_id)
if not camera.stream_source:
raise HomeAssistantError("{} does not support play stream service"
.format(camera.entity_id))
return request_stream(hass, camera.stream_source, fmt=fmt)
@bind_hass
async def async_get_image(hass, entity_id, timeout=10):
"""Fetch an image from a camera entity."""

View File

@ -18,7 +18,7 @@ from homeassistant.const import (
HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_VERIFY_SSL)
from homeassistant.exceptions import TemplateError
from homeassistant.components.camera import (
PLATFORM_SCHEMA, DEFAULT_CONTENT_TYPE, Camera)
PLATFORM_SCHEMA, DEFAULT_CONTENT_TYPE, SUPPORT_STREAM, Camera)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers import config_validation as cv
from homeassistant.util.async_ import run_coroutine_threadsafe
@ -68,6 +68,7 @@ class GenericCamera(Camera):
self._still_image_url.hass = hass
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
self._frame_interval = 1 / device_info[CONF_FRAMERATE]
self._supported_features = SUPPORT_STREAM if self._stream_source else 0
self.content_type = device_info[CONF_CONTENT_TYPE]
self.verify_ssl = device_info[CONF_VERIFY_SSL]
@ -85,6 +86,11 @@ class GenericCamera(Camera):
self._last_url = None
self._last_image = None
@property
def supported_features(self):
"""Return supported features for this camera."""
return self._supported_features
@property
def frame_interval(self):
"""Return the interval between frames of the mjpeg stream."""

View File

@ -21,6 +21,7 @@ DEFAULT_EXPOSED_DOMAINS = [
DEFAULT_ALLOW_UNLOCK = False
PREFIX_TYPES = 'action.devices.types.'
TYPE_CAMERA = PREFIX_TYPES + 'CAMERA'
TYPE_LIGHT = PREFIX_TYPES + 'LIGHT'
TYPE_SWITCH = PREFIX_TYPES + 'SWITCH'
TYPE_VACUUM = PREFIX_TYPES + 'VACUUM'

View File

@ -12,6 +12,7 @@ from homeassistant.const import (
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID,
)
from homeassistant.components import (
camera,
climate,
cover,
fan,
@ -30,7 +31,7 @@ from homeassistant.components import (
from . import trait
from .const import (
TYPE_LIGHT, TYPE_LOCK, TYPE_SCENE, TYPE_SWITCH, TYPE_VACUUM,
TYPE_THERMOSTAT, TYPE_FAN,
TYPE_THERMOSTAT, TYPE_FAN, TYPE_CAMERA,
CONF_ALIASES, CONF_ROOM_HINT,
ERR_FUNCTION_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE,
ERR_UNKNOWN_ERROR,
@ -42,6 +43,7 @@ HANDLERS = Registry()
_LOGGER = logging.getLogger(__name__)
DOMAIN_TO_GOOGLE_TYPES = {
camera.DOMAIN: TYPE_CAMERA,
climate.DOMAIN: TYPE_THERMOSTAT,
cover.DOMAIN: TYPE_SWITCH,
fan.DOMAIN: TYPE_FAN,
@ -74,6 +76,7 @@ class _GoogleEntity:
self.hass = hass
self.config = config
self.state = state
self._traits = None
@property
def entity_id(self):
@ -83,13 +86,17 @@ class _GoogleEntity:
@callback
def traits(self):
"""Return traits for entity."""
if self._traits is not None:
return self._traits
state = self.state
domain = state.domain
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
return [Trait(self.hass, state, self.config)
for Trait in trait.TRAITS
if Trait.supported(domain, features)]
self._traits = [Trait(self.hass, state, self.config)
for Trait in trait.TRAITS
if Trait.supported(domain, features)]
return self._traits
async def sync_serialize(self):
"""Serialize entity for a SYNC response.
@ -202,6 +209,12 @@ class _GoogleEntity:
"""Update the entity with latest info from Home Assistant."""
self.state = self.hass.states.get(self.entity_id)
if self._traits is None:
return
for trt in self._traits:
trt.state = self.state
async def async_handle_message(hass, config, user_id, message):
"""Handle incoming API messages."""

View File

@ -2,6 +2,7 @@
import logging
from homeassistant.components import (
camera,
cover,
group,
fan,
@ -35,6 +36,7 @@ from .helpers import SmartHomeError
_LOGGER = logging.getLogger(__name__)
PREFIX_TRAITS = 'action.devices.traits.'
TRAIT_CAMERA_STREAM = PREFIX_TRAITS + 'CameraStream'
TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff'
TRAIT_DOCK = PREFIX_TRAITS + 'Dock'
TRAIT_STARTSTOP = PREFIX_TRAITS + 'StartStop'
@ -49,6 +51,7 @@ TRAIT_MODES = PREFIX_TRAITS + 'Modes'
PREFIX_COMMANDS = 'action.devices.commands.'
COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff'
COMMAND_GET_CAMERA_STREAM = PREFIX_COMMANDS + 'GetCameraStream'
COMMAND_DOCK = PREFIX_COMMANDS + 'Dock'
COMMAND_STARTSTOP = PREFIX_COMMANDS + 'StartStop'
COMMAND_PAUSEUNPAUSE = PREFIX_COMMANDS + 'PauseUnpause'
@ -185,6 +188,51 @@ class BrightnessTrait(_Trait):
}, blocking=True, context=data.context)
@register_trait
class CameraStreamTrait(_Trait):
"""Trait to stream from cameras.
https://developers.google.com/actions/smarthome/traits/camerastream
"""
name = TRAIT_CAMERA_STREAM
commands = [
COMMAND_GET_CAMERA_STREAM
]
stream_info = None
@staticmethod
def supported(domain, features):
"""Test if state is supported."""
if domain == camera.DOMAIN:
return features & camera.SUPPORT_STREAM
return False
def sync_attributes(self):
"""Return stream attributes for a sync request."""
return {
'cameraStreamSupportedProtocols': [
"hls",
],
'cameraStreamNeedAuthToken': False,
'cameraStreamNeedDrmEncryption': False,
}
def query_attributes(self):
"""Return camera stream attributes."""
return self.stream_info or {}
async def execute(self, command, data, params):
"""Execute a get camera stream command."""
url = await self.hass.components.camera.async_request_stream(
self.state.entity_id, 'hls')
self.stream_info = {
'cameraStreamAccessUrl': self.hass.config.api.base_url + url
}
@register_trait
class OnOffTrait(_Trait):
"""Trait to offer basic on and off functionality.

View File

@ -1,10 +1,12 @@
"""Test Google Smart Home."""
from unittest.mock import patch, Mock
import pytest
from homeassistant.core import State, EVENT_CALL_SERVICE
from homeassistant.const import (
ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS)
from homeassistant.setup import async_setup_component
from homeassistant.components import camera
from homeassistant.components.climate.const import (
ATTR_MIN_TEMP, ATTR_MAX_TEMP, STATE_HEAT, SUPPORT_OPERATION_MODE
)
@ -15,7 +17,7 @@ from homeassistant.components.light.demo import DemoLight
from homeassistant.helpers import device_registry
from tests.common import (mock_device_registry, mock_registry,
mock_area_registry)
mock_area_registry, mock_coro)
BASIC_CONFIG = helpers.Config(
should_expose=lambda state: True,
@ -557,3 +559,57 @@ async def test_query_disconnect(hass):
})
assert result is None
async def test_trait_execute_adding_query_data(hass):
"""Test a trait execute influencing query data."""
hass.config.api = Mock(base_url='http://1.1.1.1:8123')
hass.states.async_set('camera.office', 'idle', {
'supported_features': camera.SUPPORT_STREAM
})
with patch('homeassistant.components.camera.async_request_stream',
return_value=mock_coro('/api/streams/bla')):
result = await sh.async_handle_message(
hass, BASIC_CONFIG, None,
{
"requestId": REQ_ID,
"inputs": [{
"intent": "action.devices.EXECUTE",
"payload": {
"commands": [{
"devices": [
{"id": "camera.office"},
],
"execution": [{
"command":
"action.devices.commands.GetCameraStream",
"params": {
"StreamToChromecast": True,
"SupportedStreamProtocols": [
"progressive_mp4",
"hls",
"dash",
"smooth_stream"
]
}
}]
}]
}
}]
})
assert result == {
"requestId": REQ_ID,
"payload": {
"commands": [{
"ids": ['camera.office'],
"status": "SUCCESS",
"states": {
"online": True,
'cameraStreamAccessUrl':
'http://1.1.1.1:8123/api/streams/bla',
}
}]
}
}

View File

@ -1,7 +1,10 @@
"""Tests for the Google Assistant traits."""
from unittest.mock import patch, Mock
import pytest
from homeassistant.components import (
camera,
cover,
fan,
input_boolean,
@ -21,7 +24,7 @@ from homeassistant.const import (
TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE)
from homeassistant.core import State, DOMAIN as HA_DOMAIN, EVENT_CALL_SERVICE
from homeassistant.util import color
from tests.common import async_mock_service
from tests.common import async_mock_service, mock_coro
BASIC_CONFIG = helpers.Config(
should_expose=lambda state: True,
@ -135,6 +138,35 @@ async def test_brightness_media_player(hass):
}
async def test_camera_stream(hass):
"""Test camera stream trait support for camera domain."""
hass.config.api = Mock(base_url='http://1.1.1.1:8123')
assert trait.CameraStreamTrait.supported(camera.DOMAIN,
camera.SUPPORT_STREAM)
trt = trait.CameraStreamTrait(
hass, State('camera.bla', camera.STATE_IDLE, {}), BASIC_CONFIG
)
assert trt.sync_attributes() == {
'cameraStreamSupportedProtocols': [
"hls",
],
'cameraStreamNeedAuthToken': False,
'cameraStreamNeedDrmEncryption': False,
}
assert trt.query_attributes() == {}
with patch('homeassistant.components.camera.async_request_stream',
return_value=mock_coro('/api/streams/bla')):
await trt.execute(trait.COMMAND_GET_CAMERA_STREAM, BASIC_DATA, {})
assert trt.query_attributes() == {
'cameraStreamAccessUrl': 'http://1.1.1.1:8123/api/streams/bla'
}
async def test_onoff_group(hass):
"""Test OnOff trait support for group domain."""
assert trait.OnOffTrait.supported(group.DOMAIN, 0)