Google Assistant: Add camera stream trait (#22278)
* Add camera stream trait * Lint
This commit is contained in:
parent
d81df1f0ae
commit
c68b621972
7 changed files with 176 additions and 7 deletions
|
@ -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."""
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
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."""
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue