Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Google Assistant: Add camera stream trait #22278

Merged
merged 2 commits into from
Mar 23, 2019
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions homeassistant/components/camera/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@

# 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}'
Expand Down Expand Up @@ -98,6 +99,18 @@ class Image:
content = attr.ib(type=bytes)


@bind_hass
async def async_request_stream(hass, entity_id, format):
balloob marked this conversation as resolved.
Show resolved Hide resolved
"""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=format)


@bind_hass
async def async_get_image(hass, entity_id, timeout=10):
"""Fetch an image from a camera entity."""
Expand Down
8 changes: 7 additions & 1 deletion homeassistant/components/generic/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
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
Expand Down Expand Up @@ -68,6 +68,7 @@ def __init__(self, hass, device_info):
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]

Expand All @@ -85,6 +86,11 @@ def __init__(self, hass, device_info):
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."""
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/google_assistant/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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'
Expand Down
21 changes: 17 additions & 4 deletions homeassistant/components/google_assistant/smart_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID,
)
from homeassistant.components import (
camera,
climate,
cover,
fan,
Expand All @@ -30,7 +31,7 @@
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,
Expand All @@ -42,6 +43,7 @@
_LOGGER = logging.getLogger(__name__)

DOMAIN_TO_GOOGLE_TYPES = {
camera.DOMAIN: TYPE_CAMERA,
climate.DOMAIN: TYPE_THERMOSTAT,
cover.DOMAIN: TYPE_SWITCH,
fan.DOMAIN: TYPE_FAN,
Expand Down Expand Up @@ -74,6 +76,7 @@ def __init__(self, hass, config, state):
self.hass = hass
self.config = config
self.state = state
self._traits = None

@property
def entity_id(self):
Expand All @@ -83,13 +86,17 @@ def entity_id(self):
@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.
Expand Down Expand Up @@ -202,6 +209,12 @@ def async_update(self):
"""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."""
Expand Down
48 changes: 48 additions & 0 deletions homeassistant/components/google_assistant/trait.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging

from homeassistant.components import (
camera,
cover,
group,
fan,
Expand Down Expand Up @@ -35,6 +36,7 @@
_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'
Expand All @@ -49,6 +51,7 @@

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'
Expand Down Expand Up @@ -185,6 +188,51 @@ async def execute(self, command, data, params):
}, 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.
Expand Down
58 changes: 57 additions & 1 deletion tests/components/google_assistant/test_smart_home.py
Original file line number Diff line number Diff line change
@@ -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
)
Expand All @@ -15,7 +17,7 @@

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,
Expand Down Expand Up @@ -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',
}
}]
}
}
34 changes: 33 additions & 1 deletion tests/components/google_assistant/test_trait.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,7 +24,7 @@
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,
Expand Down Expand Up @@ -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)
Expand Down