diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 95d6dba50c3..046b6d3947c 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -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.""" diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index c8d6721ac18..c9f8616f637 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -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.""" diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 220ed6dd58c..543404dd34e 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -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' diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index fa272c25012..88cbea345b1 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -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.""" diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 26d1ccc2088..bd903575762 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -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. diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 302e8d8674f..cccbe0d0a9d 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -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', + } + }] + } + } diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 9d067f3314f..e42e4bdc915 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -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)