mirror of
https://github.com/home-assistant/core.git
synced 2025-07-20 03:37:07 +00:00
Google Assistant: Add camera stream trait (#22278)
* Add camera stream trait * Lint
This commit is contained in:
parent
d81df1f0ae
commit
c68b621972
@ -60,6 +60,7 @@ STATE_IDLE = 'idle'
|
|||||||
|
|
||||||
# Bitfield of features supported by the camera entity
|
# Bitfield of features supported by the camera entity
|
||||||
SUPPORT_ON_OFF = 1
|
SUPPORT_ON_OFF = 1
|
||||||
|
SUPPORT_STREAM = 2
|
||||||
|
|
||||||
DEFAULT_CONTENT_TYPE = 'image/jpeg'
|
DEFAULT_CONTENT_TYPE = 'image/jpeg'
|
||||||
ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}'
|
ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}'
|
||||||
@ -98,6 +99,18 @@ class Image:
|
|||||||
content = attr.ib(type=bytes)
|
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
|
@bind_hass
|
||||||
async def async_get_image(hass, entity_id, timeout=10):
|
async def async_get_image(hass, entity_id, timeout=10):
|
||||||
"""Fetch an image from a camera entity."""
|
"""Fetch an image from a camera entity."""
|
||||||
|
@ -18,7 +18,7 @@ from homeassistant.const import (
|
|||||||
HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_VERIFY_SSL)
|
HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_VERIFY_SSL)
|
||||||
from homeassistant.exceptions import TemplateError
|
from homeassistant.exceptions import TemplateError
|
||||||
from homeassistant.components.camera import (
|
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.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.util.async_ import run_coroutine_threadsafe
|
from homeassistant.util.async_ import run_coroutine_threadsafe
|
||||||
@ -68,6 +68,7 @@ class GenericCamera(Camera):
|
|||||||
self._still_image_url.hass = hass
|
self._still_image_url.hass = hass
|
||||||
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
|
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
|
||||||
self._frame_interval = 1 / device_info[CONF_FRAMERATE]
|
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.content_type = device_info[CONF_CONTENT_TYPE]
|
||||||
self.verify_ssl = device_info[CONF_VERIFY_SSL]
|
self.verify_ssl = device_info[CONF_VERIFY_SSL]
|
||||||
|
|
||||||
@ -85,6 +86,11 @@ class GenericCamera(Camera):
|
|||||||
self._last_url = None
|
self._last_url = None
|
||||||
self._last_image = None
|
self._last_image = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self):
|
||||||
|
"""Return supported features for this camera."""
|
||||||
|
return self._supported_features
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def frame_interval(self):
|
def frame_interval(self):
|
||||||
"""Return the interval between frames of the mjpeg stream."""
|
"""Return the interval between frames of the mjpeg stream."""
|
||||||
|
@ -21,6 +21,7 @@ DEFAULT_EXPOSED_DOMAINS = [
|
|||||||
DEFAULT_ALLOW_UNLOCK = False
|
DEFAULT_ALLOW_UNLOCK = False
|
||||||
|
|
||||||
PREFIX_TYPES = 'action.devices.types.'
|
PREFIX_TYPES = 'action.devices.types.'
|
||||||
|
TYPE_CAMERA = PREFIX_TYPES + 'CAMERA'
|
||||||
TYPE_LIGHT = PREFIX_TYPES + 'LIGHT'
|
TYPE_LIGHT = PREFIX_TYPES + 'LIGHT'
|
||||||
TYPE_SWITCH = PREFIX_TYPES + 'SWITCH'
|
TYPE_SWITCH = PREFIX_TYPES + 'SWITCH'
|
||||||
TYPE_VACUUM = PREFIX_TYPES + 'VACUUM'
|
TYPE_VACUUM = PREFIX_TYPES + 'VACUUM'
|
||||||
|
@ -12,6 +12,7 @@ from homeassistant.const import (
|
|||||||
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID,
|
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID,
|
||||||
)
|
)
|
||||||
from homeassistant.components import (
|
from homeassistant.components import (
|
||||||
|
camera,
|
||||||
climate,
|
climate,
|
||||||
cover,
|
cover,
|
||||||
fan,
|
fan,
|
||||||
@ -30,7 +31,7 @@ from homeassistant.components import (
|
|||||||
from . import trait
|
from . import trait
|
||||||
from .const import (
|
from .const import (
|
||||||
TYPE_LIGHT, TYPE_LOCK, TYPE_SCENE, TYPE_SWITCH, TYPE_VACUUM,
|
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,
|
CONF_ALIASES, CONF_ROOM_HINT,
|
||||||
ERR_FUNCTION_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE,
|
ERR_FUNCTION_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE,
|
||||||
ERR_UNKNOWN_ERROR,
|
ERR_UNKNOWN_ERROR,
|
||||||
@ -42,6 +43,7 @@ HANDLERS = Registry()
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DOMAIN_TO_GOOGLE_TYPES = {
|
DOMAIN_TO_GOOGLE_TYPES = {
|
||||||
|
camera.DOMAIN: TYPE_CAMERA,
|
||||||
climate.DOMAIN: TYPE_THERMOSTAT,
|
climate.DOMAIN: TYPE_THERMOSTAT,
|
||||||
cover.DOMAIN: TYPE_SWITCH,
|
cover.DOMAIN: TYPE_SWITCH,
|
||||||
fan.DOMAIN: TYPE_FAN,
|
fan.DOMAIN: TYPE_FAN,
|
||||||
@ -74,6 +76,7 @@ class _GoogleEntity:
|
|||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.config = config
|
self.config = config
|
||||||
self.state = state
|
self.state = state
|
||||||
|
self._traits = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def entity_id(self):
|
def entity_id(self):
|
||||||
@ -83,13 +86,17 @@ class _GoogleEntity:
|
|||||||
@callback
|
@callback
|
||||||
def traits(self):
|
def traits(self):
|
||||||
"""Return traits for entity."""
|
"""Return traits for entity."""
|
||||||
|
if self._traits is not None:
|
||||||
|
return self._traits
|
||||||
|
|
||||||
state = self.state
|
state = self.state
|
||||||
domain = state.domain
|
domain = state.domain
|
||||||
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
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
|
for Trait in trait.TRAITS
|
||||||
if Trait.supported(domain, features)]
|
if Trait.supported(domain, features)]
|
||||||
|
return self._traits
|
||||||
|
|
||||||
async def sync_serialize(self):
|
async def sync_serialize(self):
|
||||||
"""Serialize entity for a SYNC response.
|
"""Serialize entity for a SYNC response.
|
||||||
@ -202,6 +209,12 @@ class _GoogleEntity:
|
|||||||
"""Update the entity with latest info from Home Assistant."""
|
"""Update the entity with latest info from Home Assistant."""
|
||||||
self.state = self.hass.states.get(self.entity_id)
|
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):
|
async def async_handle_message(hass, config, user_id, message):
|
||||||
"""Handle incoming API messages."""
|
"""Handle incoming API messages."""
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from homeassistant.components import (
|
from homeassistant.components import (
|
||||||
|
camera,
|
||||||
cover,
|
cover,
|
||||||
group,
|
group,
|
||||||
fan,
|
fan,
|
||||||
@ -35,6 +36,7 @@ from .helpers import SmartHomeError
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PREFIX_TRAITS = 'action.devices.traits.'
|
PREFIX_TRAITS = 'action.devices.traits.'
|
||||||
|
TRAIT_CAMERA_STREAM = PREFIX_TRAITS + 'CameraStream'
|
||||||
TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff'
|
TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff'
|
||||||
TRAIT_DOCK = PREFIX_TRAITS + 'Dock'
|
TRAIT_DOCK = PREFIX_TRAITS + 'Dock'
|
||||||
TRAIT_STARTSTOP = PREFIX_TRAITS + 'StartStop'
|
TRAIT_STARTSTOP = PREFIX_TRAITS + 'StartStop'
|
||||||
@ -49,6 +51,7 @@ TRAIT_MODES = PREFIX_TRAITS + 'Modes'
|
|||||||
|
|
||||||
PREFIX_COMMANDS = 'action.devices.commands.'
|
PREFIX_COMMANDS = 'action.devices.commands.'
|
||||||
COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff'
|
COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff'
|
||||||
|
COMMAND_GET_CAMERA_STREAM = PREFIX_COMMANDS + 'GetCameraStream'
|
||||||
COMMAND_DOCK = PREFIX_COMMANDS + 'Dock'
|
COMMAND_DOCK = PREFIX_COMMANDS + 'Dock'
|
||||||
COMMAND_STARTSTOP = PREFIX_COMMANDS + 'StartStop'
|
COMMAND_STARTSTOP = PREFIX_COMMANDS + 'StartStop'
|
||||||
COMMAND_PAUSEUNPAUSE = PREFIX_COMMANDS + 'PauseUnpause'
|
COMMAND_PAUSEUNPAUSE = PREFIX_COMMANDS + 'PauseUnpause'
|
||||||
@ -185,6 +188,51 @@ class BrightnessTrait(_Trait):
|
|||||||
}, blocking=True, context=data.context)
|
}, 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
|
@register_trait
|
||||||
class OnOffTrait(_Trait):
|
class OnOffTrait(_Trait):
|
||||||
"""Trait to offer basic on and off functionality.
|
"""Trait to offer basic on and off functionality.
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
"""Test Google Smart Home."""
|
"""Test Google Smart Home."""
|
||||||
|
from unittest.mock import patch, Mock
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.core import State, EVENT_CALL_SERVICE
|
from homeassistant.core import State, EVENT_CALL_SERVICE
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS)
|
ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS)
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
from homeassistant.components import camera
|
||||||
from homeassistant.components.climate.const import (
|
from homeassistant.components.climate.const import (
|
||||||
ATTR_MIN_TEMP, ATTR_MAX_TEMP, STATE_HEAT, SUPPORT_OPERATION_MODE
|
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 homeassistant.helpers import device_registry
|
||||||
from tests.common import (mock_device_registry, mock_registry,
|
from tests.common import (mock_device_registry, mock_registry,
|
||||||
mock_area_registry)
|
mock_area_registry, mock_coro)
|
||||||
|
|
||||||
BASIC_CONFIG = helpers.Config(
|
BASIC_CONFIG = helpers.Config(
|
||||||
should_expose=lambda state: True,
|
should_expose=lambda state: True,
|
||||||
@ -557,3 +559,57 @@ async def test_query_disconnect(hass):
|
|||||||
})
|
})
|
||||||
|
|
||||||
assert result is None
|
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."""
|
"""Tests for the Google Assistant traits."""
|
||||||
|
from unittest.mock import patch, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components import (
|
from homeassistant.components import (
|
||||||
|
camera,
|
||||||
cover,
|
cover,
|
||||||
fan,
|
fan,
|
||||||
input_boolean,
|
input_boolean,
|
||||||
@ -21,7 +24,7 @@ from homeassistant.const import (
|
|||||||
TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE)
|
TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE)
|
||||||
from homeassistant.core import State, DOMAIN as HA_DOMAIN, EVENT_CALL_SERVICE
|
from homeassistant.core import State, DOMAIN as HA_DOMAIN, EVENT_CALL_SERVICE
|
||||||
from homeassistant.util import color
|
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(
|
BASIC_CONFIG = helpers.Config(
|
||||||
should_expose=lambda state: True,
|
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):
|
async def test_onoff_group(hass):
|
||||||
"""Test OnOff trait support for group domain."""
|
"""Test OnOff trait support for group domain."""
|
||||||
assert trait.OnOffTrait.supported(group.DOMAIN, 0)
|
assert trait.OnOffTrait.supported(group.DOMAIN, 0)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user