Google Actions for Assistant (#9632)
* http: Add headers key to json[_message] * Add google_assistant component This component provides API endpoints for the Actions on Google Smart Home API to interact with Google Assistant. * google_assistant: Re-add fan support * google_assistant: Fix Scene handling - The way I originally wrote the MAPPING_COMPONENT and the way it's actual used changed so the comment was updated to match that. - Use const's in more places - Handle the ActivateScene command correctly * google_assistant: Fix flakey compare test Was failing on 3.4.2 and 3.5, this is more correct anyway. * google_assistant: Use volume attr for media_player
This commit is contained in:
parent
1d68777981
commit
9d20a53d63
9 changed files with 996 additions and 4 deletions
52
homeassistant/components/google_assistant/__init__.py
Normal file
52
homeassistant/components/google_assistant/__init__.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
"""
|
||||||
|
Support for Actions on Google Assistant Smart Home Control.
|
||||||
|
|
||||||
|
For more details about this component, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/google_assistant/
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
# Typing imports
|
||||||
|
# pylint: disable=using-constant-test,unused-import,ungrouped-imports
|
||||||
|
# if False:
|
||||||
|
from homeassistant.core import HomeAssistant # NOQA
|
||||||
|
from typing import Dict, Any # NOQA
|
||||||
|
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DOMAIN, CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN,
|
||||||
|
CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS
|
||||||
|
)
|
||||||
|
from .auth import GoogleAssistantAuthView
|
||||||
|
from .http import GoogleAssistantView
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEPENDENCIES = ['http']
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
DOMAIN: {
|
||||||
|
vol.Required(CONF_PROJECT_ID): cv.string,
|
||||||
|
vol.Required(CONF_CLIENT_ID): cv.string,
|
||||||
|
vol.Required(CONF_ACCESS_TOKEN): cv.string,
|
||||||
|
vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean,
|
||||||
|
vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA)
|
||||||
|
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]):
|
||||||
|
"""Activate Google Actions component."""
|
||||||
|
config = yaml_config.get(DOMAIN, {})
|
||||||
|
|
||||||
|
hass.http.register_view(GoogleAssistantAuthView(hass, config))
|
||||||
|
hass.http.register_view(GoogleAssistantView(hass, config))
|
||||||
|
|
||||||
|
return True
|
86
homeassistant/components/google_assistant/auth.py
Normal file
86
homeassistant/components/google_assistant/auth.py
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
"""Google Assistant OAuth View."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Typing imports
|
||||||
|
# pylint: disable=using-constant-test,unused-import,ungrouped-imports
|
||||||
|
# if False:
|
||||||
|
from homeassistant.core import HomeAssistant # NOQA
|
||||||
|
from aiohttp.web import Request, Response # NOQA
|
||||||
|
from typing import Dict, Any # NOQA
|
||||||
|
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
from homeassistant.const import (
|
||||||
|
HTTP_BAD_REQUEST,
|
||||||
|
HTTP_UNAUTHORIZED,
|
||||||
|
HTTP_MOVED_PERMANENTLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
GOOGLE_ASSISTANT_API_ENDPOINT,
|
||||||
|
CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE_OAUTH_URL = 'https://oauth-redirect.googleusercontent.com'
|
||||||
|
REDIRECT_TEMPLATE_URL = \
|
||||||
|
'{}/r/{}#access_token={}&token_type=bearer&state={}'
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleAssistantAuthView(HomeAssistantView):
|
||||||
|
"""Handle Google Actions auth requests."""
|
||||||
|
|
||||||
|
url = GOOGLE_ASSISTANT_API_ENDPOINT + '/auth'
|
||||||
|
name = 'api:google_assistant:auth'
|
||||||
|
requires_auth = False
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, cfg: Dict[str, Any]) -> None:
|
||||||
|
"""Initialize instance of the view."""
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.project_id = cfg.get(CONF_PROJECT_ID)
|
||||||
|
self.client_id = cfg.get(CONF_CLIENT_ID)
|
||||||
|
self.access_token = cfg.get(CONF_ACCESS_TOKEN)
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def get(self, request: Request) -> Response:
|
||||||
|
"""Handle oauth token request."""
|
||||||
|
query = request.query
|
||||||
|
redirect_uri = query.get('redirect_uri')
|
||||||
|
if not redirect_uri:
|
||||||
|
msg = 'missing redirect_uri field'
|
||||||
|
_LOGGER.warning(msg)
|
||||||
|
return self.json_message(msg, status_code=HTTP_BAD_REQUEST)
|
||||||
|
|
||||||
|
if self.project_id not in redirect_uri:
|
||||||
|
msg = 'missing project_id in redirect_uri'
|
||||||
|
_LOGGER.warning(msg)
|
||||||
|
return self.json_message(msg, status_code=HTTP_BAD_REQUEST)
|
||||||
|
|
||||||
|
state = query.get('state')
|
||||||
|
if not state:
|
||||||
|
msg = 'oauth request missing state'
|
||||||
|
_LOGGER.warning(msg)
|
||||||
|
return self.json_message(msg, status_code=HTTP_BAD_REQUEST)
|
||||||
|
|
||||||
|
client_id = query.get('client_id')
|
||||||
|
if self.client_id != client_id:
|
||||||
|
msg = 'invalid client id'
|
||||||
|
_LOGGER.warning(msg)
|
||||||
|
return self.json_message(msg, status_code=HTTP_UNAUTHORIZED)
|
||||||
|
|
||||||
|
generated_url = redirect_url(self.project_id, self.access_token, state)
|
||||||
|
|
||||||
|
_LOGGER.info('user login in from Google Assistant')
|
||||||
|
return self.json_message(
|
||||||
|
'redirect success',
|
||||||
|
status_code=HTTP_MOVED_PERMANENTLY,
|
||||||
|
headers={'Location': generated_url})
|
||||||
|
|
||||||
|
|
||||||
|
def redirect_url(project_id: str, access_token: str, state: str) -> str:
|
||||||
|
"""Generate the redirect format for the oauth request."""
|
||||||
|
return REDIRECT_TEMPLATE_URL.format(BASE_OAUTH_URL, project_id,
|
||||||
|
access_token, state)
|
37
homeassistant/components/google_assistant/const.py
Normal file
37
homeassistant/components/google_assistant/const.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
"""Constants for Google Assistant."""
|
||||||
|
DOMAIN = 'google_assistant'
|
||||||
|
|
||||||
|
GOOGLE_ASSISTANT_API_ENDPOINT = '/api/google_assistant'
|
||||||
|
|
||||||
|
ATTR_GOOGLE_ASSISTANT = 'google_assistant'
|
||||||
|
ATTR_GOOGLE_ASSISTANT_NAME = 'google_assistant_name'
|
||||||
|
|
||||||
|
CONF_EXPOSE_BY_DEFAULT = 'expose_by_default'
|
||||||
|
CONF_EXPOSED_DOMAINS = 'exposed_domains'
|
||||||
|
CONF_PROJECT_ID = 'project_id'
|
||||||
|
CONF_ACCESS_TOKEN = 'access_token'
|
||||||
|
CONF_CLIENT_ID = 'client_id'
|
||||||
|
CONF_ALIASES = 'aliases'
|
||||||
|
|
||||||
|
DEFAULT_EXPOSE_BY_DEFAULT = True
|
||||||
|
DEFAULT_EXPOSED_DOMAINS = [
|
||||||
|
'switch', 'light', 'group', 'media_player', 'fan', 'cover'
|
||||||
|
]
|
||||||
|
|
||||||
|
PREFIX_TRAITS = 'action.devices.traits.'
|
||||||
|
TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff'
|
||||||
|
TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness'
|
||||||
|
TRAIT_RGB_COLOR = PREFIX_TRAITS + 'ColorSpectrum'
|
||||||
|
TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature'
|
||||||
|
TRAIT_SCENE = PREFIX_TRAITS + 'Scene'
|
||||||
|
|
||||||
|
PREFIX_COMMANDS = 'action.devices.commands.'
|
||||||
|
COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff'
|
||||||
|
COMMAND_BRIGHTNESS = PREFIX_COMMANDS + 'BrightnessAbsolute'
|
||||||
|
COMMAND_COLOR = PREFIX_COMMANDS + 'ColorAbsolute'
|
||||||
|
COMMAND_ACTIVATESCENE = PREFIX_COMMANDS + 'ActivateScene'
|
||||||
|
|
||||||
|
PREFIX_TYPES = 'action.devices.types.'
|
||||||
|
TYPE_LIGHT = PREFIX_TYPES + 'LIGHT'
|
||||||
|
TYPE_SWITCH = PREFIX_TYPES + 'SWITCH'
|
||||||
|
TYPE_SCENE = PREFIX_TYPES + 'SCENE'
|
180
homeassistant/components/google_assistant/http.py
Normal file
180
homeassistant/components/google_assistant/http.py
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
"""
|
||||||
|
Support for Google Actions Smart Home Control.
|
||||||
|
|
||||||
|
For more details about this component, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/google_assistant/
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Typing imports
|
||||||
|
# pylint: disable=using-constant-test,unused-import,ungrouped-imports
|
||||||
|
# if False:
|
||||||
|
from homeassistant.core import HomeAssistant # NOQA
|
||||||
|
from aiohttp.web import Request, Response # NOQA
|
||||||
|
from typing import Dict, Tuple, Any # NOQA
|
||||||
|
from homeassistant.helpers.entity import Entity # NOQA
|
||||||
|
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
|
||||||
|
from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED)
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
GOOGLE_ASSISTANT_API_ENDPOINT,
|
||||||
|
CONF_ACCESS_TOKEN,
|
||||||
|
DEFAULT_EXPOSE_BY_DEFAULT,
|
||||||
|
DEFAULT_EXPOSED_DOMAINS,
|
||||||
|
CONF_EXPOSE_BY_DEFAULT,
|
||||||
|
CONF_EXPOSED_DOMAINS,
|
||||||
|
ATTR_GOOGLE_ASSISTANT)
|
||||||
|
from .smart_home import entity_to_device, query_device, determine_service
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleAssistantView(HomeAssistantView):
|
||||||
|
"""Handle Google Assistant requests."""
|
||||||
|
|
||||||
|
url = GOOGLE_ASSISTANT_API_ENDPOINT
|
||||||
|
name = 'api:google_assistant'
|
||||||
|
requires_auth = False # Uses access token from oauth flow
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, cfg: Dict[str, Any]) -> None:
|
||||||
|
"""Initialize Google Assistant view."""
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.access_token = cfg.get(CONF_ACCESS_TOKEN)
|
||||||
|
self.expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT,
|
||||||
|
DEFAULT_EXPOSE_BY_DEFAULT)
|
||||||
|
self.exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS,
|
||||||
|
DEFAULT_EXPOSED_DOMAINS)
|
||||||
|
|
||||||
|
def is_entity_exposed(self, entity) -> bool:
|
||||||
|
"""Determine if an entity should be exposed to Google Assistant."""
|
||||||
|
if entity.attributes.get('view') is not None:
|
||||||
|
# Ignore entities that are views
|
||||||
|
return False
|
||||||
|
|
||||||
|
domain = entity.domain.lower()
|
||||||
|
explicit_expose = entity.attributes.get(ATTR_GOOGLE_ASSISTANT, None)
|
||||||
|
|
||||||
|
domain_exposed_by_default = \
|
||||||
|
self.expose_by_default and domain in self.exposed_domains
|
||||||
|
|
||||||
|
# Expose an entity if the entity's domain is exposed by default and
|
||||||
|
# the configuration doesn't explicitly exclude it from being
|
||||||
|
# exposed, or if the entity is explicitly exposed
|
||||||
|
is_default_exposed = \
|
||||||
|
domain_exposed_by_default and explicit_expose is not False
|
||||||
|
|
||||||
|
return is_default_exposed or explicit_expose
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def handle_sync(self, hass: HomeAssistant, request_id: str):
|
||||||
|
"""Handle SYNC action."""
|
||||||
|
devices = []
|
||||||
|
for entity in hass.states.async_all():
|
||||||
|
if not self.is_entity_exposed(entity):
|
||||||
|
continue
|
||||||
|
|
||||||
|
device = entity_to_device(entity)
|
||||||
|
if device is None:
|
||||||
|
_LOGGER.warning("No mapping for %s domain", entity.domain)
|
||||||
|
continue
|
||||||
|
|
||||||
|
devices.append(device)
|
||||||
|
|
||||||
|
return self.json(
|
||||||
|
make_actions_response(request_id, {'devices': devices}))
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def handle_query(self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
request_id: str,
|
||||||
|
requested_devices: list):
|
||||||
|
"""Handle the QUERY action."""
|
||||||
|
devices = {}
|
||||||
|
for device in requested_devices:
|
||||||
|
devid = device.get('id')
|
||||||
|
# In theory this should never happpen
|
||||||
|
if not devid:
|
||||||
|
_LOGGER.error('Device missing ID: %s', device)
|
||||||
|
continue
|
||||||
|
|
||||||
|
state = hass.states.get(devid)
|
||||||
|
if not state:
|
||||||
|
# If we can't find a state, the device is offline
|
||||||
|
devices[devid] = {'online': False}
|
||||||
|
|
||||||
|
devices[devid] = query_device(state)
|
||||||
|
|
||||||
|
return self.json(
|
||||||
|
make_actions_response(request_id, {'devices': devices}))
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def handle_execute(self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
request_id: str,
|
||||||
|
requested_commands: list):
|
||||||
|
"""Handle the EXECUTE action."""
|
||||||
|
commands = []
|
||||||
|
for command in requested_commands:
|
||||||
|
ent_ids = [ent.get('id') for ent in command.get('devices', [])]
|
||||||
|
execution = command.get('execution')[0]
|
||||||
|
for eid in ent_ids:
|
||||||
|
domain = eid.split('.')[0]
|
||||||
|
(service, service_data) = determine_service(
|
||||||
|
eid, execution.get('command'), execution.get('params'))
|
||||||
|
success = yield from hass.services.async_call(
|
||||||
|
domain, service, service_data, blocking=True)
|
||||||
|
result = {"ids": [eid], "states": {}}
|
||||||
|
if success:
|
||||||
|
result['status'] = 'SUCCESS'
|
||||||
|
else:
|
||||||
|
result['status'] = 'ERROR'
|
||||||
|
commands.append(result)
|
||||||
|
|
||||||
|
return self.json(
|
||||||
|
make_actions_response(request_id, {'commands': commands}))
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def post(self, request: Request) -> Response:
|
||||||
|
"""Handle Google Assistant requests."""
|
||||||
|
auth = request.headers.get('Authorization', None)
|
||||||
|
if 'Bearer {}'.format(self.access_token) != auth:
|
||||||
|
return self.json_message(
|
||||||
|
"missing authorization", status_code=HTTP_UNAUTHORIZED)
|
||||||
|
|
||||||
|
data = yield from request.json() # type: dict
|
||||||
|
|
||||||
|
inputs = data.get('inputs') # type: list
|
||||||
|
if len(inputs) != 1:
|
||||||
|
_LOGGER.error('Too many inputs in request %d', len(inputs))
|
||||||
|
return self.json_message(
|
||||||
|
"too many inputs", status_code=HTTP_BAD_REQUEST)
|
||||||
|
|
||||||
|
request_id = data.get('requestId') # type: str
|
||||||
|
intent = inputs[0].get('intent')
|
||||||
|
payload = inputs[0].get('payload')
|
||||||
|
|
||||||
|
hass = request.app['hass'] # type: HomeAssistant
|
||||||
|
res = None
|
||||||
|
if intent == 'action.devices.SYNC':
|
||||||
|
res = yield from self.handle_sync(hass, request_id)
|
||||||
|
elif intent == 'action.devices.QUERY':
|
||||||
|
res = yield from self.handle_query(hass, request_id,
|
||||||
|
payload.get('devices', []))
|
||||||
|
elif intent == 'action.devices.EXECUTE':
|
||||||
|
res = yield from self.handle_execute(hass, request_id,
|
||||||
|
payload.get('commands', []))
|
||||||
|
|
||||||
|
if res:
|
||||||
|
return res
|
||||||
|
|
||||||
|
return self.json_message(
|
||||||
|
"invalid intent", status_code=HTTP_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
def make_actions_response(request_id: str, payload: dict) -> dict:
|
||||||
|
"""Helper to simplify format for response."""
|
||||||
|
return {'requestId': request_id, 'payload': payload}
|
161
homeassistant/components/google_assistant/smart_home.py
Normal file
161
homeassistant/components/google_assistant/smart_home.py
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
"""Support for Google Assistant Smart Home API."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Typing imports
|
||||||
|
# pylint: disable=using-constant-test,unused-import,ungrouped-imports
|
||||||
|
# if False:
|
||||||
|
from aiohttp.web import Request, Response # NOQA
|
||||||
|
from typing import Dict, Tuple, Any # NOQA
|
||||||
|
from homeassistant.helpers.entity import Entity # NOQA
|
||||||
|
from homeassistant.core import HomeAssistant # NOQA
|
||||||
|
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID,
|
||||||
|
CONF_FRIENDLY_NAME, STATE_OFF,
|
||||||
|
SERVICE_TURN_OFF, SERVICE_TURN_ON
|
||||||
|
)
|
||||||
|
from homeassistant.components import (
|
||||||
|
switch, light, cover, media_player, group, fan, scene
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ATTR_GOOGLE_ASSISTANT_NAME,
|
||||||
|
COMMAND_BRIGHTNESS, COMMAND_ONOFF, COMMAND_ACTIVATESCENE,
|
||||||
|
TRAIT_ONOFF, TRAIT_BRIGHTNESS, TRAIT_COLOR_TEMP,
|
||||||
|
TRAIT_RGB_COLOR, TRAIT_SCENE,
|
||||||
|
TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH,
|
||||||
|
CONF_ALIASES,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Mapping is [actions schema, primary trait, optional features]
|
||||||
|
# optional is SUPPORT_* = (trait, command)
|
||||||
|
MAPPING_COMPONENT = {
|
||||||
|
group.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None],
|
||||||
|
scene.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None],
|
||||||
|
switch.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None],
|
||||||
|
fan.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None],
|
||||||
|
light.DOMAIN: [
|
||||||
|
TYPE_LIGHT, TRAIT_ONOFF, {
|
||||||
|
light.SUPPORT_BRIGHTNESS: TRAIT_BRIGHTNESS,
|
||||||
|
light.SUPPORT_RGB_COLOR: TRAIT_RGB_COLOR,
|
||||||
|
light.SUPPORT_COLOR_TEMP: TRAIT_COLOR_TEMP,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
cover.DOMAIN: [
|
||||||
|
TYPE_LIGHT, TRAIT_ONOFF, {
|
||||||
|
cover.SUPPORT_SET_POSITION: TRAIT_BRIGHTNESS
|
||||||
|
}
|
||||||
|
],
|
||||||
|
media_player.DOMAIN: [
|
||||||
|
TYPE_LIGHT, TRAIT_ONOFF, {
|
||||||
|
media_player.SUPPORT_VOLUME_SET: TRAIT_BRIGHTNESS
|
||||||
|
}
|
||||||
|
],
|
||||||
|
} # type: Dict[str, list]
|
||||||
|
|
||||||
|
|
||||||
|
def make_actions_response(request_id: str, payload: dict) -> dict:
|
||||||
|
"""Helper to simplify format for response."""
|
||||||
|
return {'requestId': request_id, 'payload': payload}
|
||||||
|
|
||||||
|
|
||||||
|
def entity_to_device(entity: Entity):
|
||||||
|
"""Convert a hass entity into an google actions device."""
|
||||||
|
class_data = MAPPING_COMPONENT.get(entity.domain)
|
||||||
|
if class_data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
device = {
|
||||||
|
'id': entity.entity_id,
|
||||||
|
'name': {},
|
||||||
|
'traits': [],
|
||||||
|
'willReportState': False,
|
||||||
|
}
|
||||||
|
device['type'] = class_data[0]
|
||||||
|
device['traits'].append(class_data[1])
|
||||||
|
|
||||||
|
# handle custom names
|
||||||
|
device['name']['name'] = \
|
||||||
|
entity.attributes.get(ATTR_GOOGLE_ASSISTANT_NAME) or \
|
||||||
|
entity.attributes.get(CONF_FRIENDLY_NAME)
|
||||||
|
|
||||||
|
# use aliases
|
||||||
|
aliases = entity.attributes.get(CONF_ALIASES)
|
||||||
|
if isinstance(aliases, list):
|
||||||
|
device['name']['nicknames'] = aliases
|
||||||
|
else:
|
||||||
|
_LOGGER.warning("%s must be a list", CONF_ALIASES)
|
||||||
|
|
||||||
|
# add trait if entity supports feature
|
||||||
|
if class_data[2]:
|
||||||
|
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||||
|
for feature, trait in class_data[2].items():
|
||||||
|
if feature & supported > 0:
|
||||||
|
device['traits'].append(trait)
|
||||||
|
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def query_device(entity: Entity) -> dict:
|
||||||
|
"""Take an entity and return a properly formatted device object."""
|
||||||
|
final_state = entity.state != STATE_OFF
|
||||||
|
final_brightness = entity.attributes.get(light.ATTR_BRIGHTNESS, 255
|
||||||
|
if final_state else 0)
|
||||||
|
|
||||||
|
if entity.domain == media_player.DOMAIN:
|
||||||
|
level = entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL, 1.0
|
||||||
|
if final_state else 0.0)
|
||||||
|
# Convert 0.0-1.0 to 0-255
|
||||||
|
final_brightness = round(min(1.0, level) * 255)
|
||||||
|
|
||||||
|
if final_brightness is None:
|
||||||
|
final_brightness = 255 if final_state else 0
|
||||||
|
|
||||||
|
final_brightness = 100 * (final_brightness / 255)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"on": final_state,
|
||||||
|
"online": True,
|
||||||
|
"brightness": int(final_brightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# erroneous bug on old pythons and pylint
|
||||||
|
# https://github.com/PyCQA/pylint/issues/1212
|
||||||
|
# pylint: disable=invalid-sequence-index
|
||||||
|
def determine_service(entity_id: str, command: str,
|
||||||
|
params: dict) -> Tuple[str, dict]:
|
||||||
|
"""
|
||||||
|
Determine service and service_data.
|
||||||
|
|
||||||
|
Attempt to return a tuple of service and service_data based on the entity
|
||||||
|
and action requested.
|
||||||
|
"""
|
||||||
|
domain = entity_id.split('.')[0]
|
||||||
|
service_data = {ATTR_ENTITY_ID: entity_id} # type: Dict[str, Any]
|
||||||
|
# special media_player handling
|
||||||
|
if domain == media_player.DOMAIN and command == COMMAND_BRIGHTNESS:
|
||||||
|
brightness = params.get('brightness', 0)
|
||||||
|
service_data[media_player.ATTR_MEDIA_VOLUME_LEVEL] = brightness / 100
|
||||||
|
return (media_player.SERVICE_VOLUME_SET, service_data)
|
||||||
|
|
||||||
|
# special cover handling
|
||||||
|
if domain == cover.DOMAIN:
|
||||||
|
if command == COMMAND_BRIGHTNESS:
|
||||||
|
service_data['position'] = params.get('brightness', 0)
|
||||||
|
return (cover.SERVICE_SET_COVER_POSITION, service_data)
|
||||||
|
if command == COMMAND_ONOFF and params.get('on') is True:
|
||||||
|
return (cover.SERVICE_OPEN_COVER, service_data)
|
||||||
|
return (cover.SERVICE_CLOSE_COVER, service_data)
|
||||||
|
|
||||||
|
if command == COMMAND_BRIGHTNESS:
|
||||||
|
brightness = params.get('brightness')
|
||||||
|
service_data['brightness'] = int(brightness / 100 * 255)
|
||||||
|
return (SERVICE_TURN_ON, service_data)
|
||||||
|
|
||||||
|
if command == COMMAND_ACTIVATESCENE or (COMMAND_ONOFF == command and
|
||||||
|
params.get('on') is True):
|
||||||
|
return (SERVICE_TURN_ON, service_data)
|
||||||
|
return (SERVICE_TURN_OFF, service_data)
|
|
@ -358,19 +358,21 @@ class HomeAssistantView(object):
|
||||||
requires_auth = True # Views inheriting from this class can override this
|
requires_auth = True # Views inheriting from this class can override this
|
||||||
|
|
||||||
# pylint: disable=no-self-use
|
# pylint: disable=no-self-use
|
||||||
def json(self, result, status_code=200):
|
def json(self, result, status_code=200, headers=None):
|
||||||
"""Return a JSON response."""
|
"""Return a JSON response."""
|
||||||
msg = json.dumps(
|
msg = json.dumps(
|
||||||
result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8')
|
result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8')
|
||||||
return web.Response(
|
return web.Response(
|
||||||
body=msg, content_type=CONTENT_TYPE_JSON, status=status_code)
|
body=msg, content_type=CONTENT_TYPE_JSON, status=status_code,
|
||||||
|
headers=headers)
|
||||||
|
|
||||||
def json_message(self, message, status_code=200, message_code=None):
|
def json_message(self, message, status_code=200, message_code=None,
|
||||||
|
headers=None):
|
||||||
"""Return a JSON message response."""
|
"""Return a JSON message response."""
|
||||||
data = {'message': message}
|
data = {'message': message}
|
||||||
if message_code is not None:
|
if message_code is not None:
|
||||||
data['code'] = message_code
|
data['code'] = message_code
|
||||||
return self.json(data, status_code)
|
return self.json(data, status_code, headers=headers)
|
||||||
|
|
||||||
@asyncio.coroutine
|
@asyncio.coroutine
|
||||||
# pylint: disable=no-self-use
|
# pylint: disable=no-self-use
|
||||||
|
|
173
tests/components/google_assistant/__init__.py
Normal file
173
tests/components/google_assistant/__init__.py
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
"""Tests for the Google Assistant integration."""
|
||||||
|
|
||||||
|
DEMO_DEVICES = [{
|
||||||
|
'id':
|
||||||
|
'light.kitchen_lights',
|
||||||
|
'name': {
|
||||||
|
'name': 'Kitchen Lights'
|
||||||
|
},
|
||||||
|
'traits': [
|
||||||
|
'action.devices.traits.OnOff', 'action.devices.traits.Brightness',
|
||||||
|
'action.devices.traits.ColorSpectrum',
|
||||||
|
'action.devices.traits.ColorTemperature'
|
||||||
|
],
|
||||||
|
'type':
|
||||||
|
'action.devices.types.LIGHT',
|
||||||
|
'willReportState':
|
||||||
|
False
|
||||||
|
}, {
|
||||||
|
'id':
|
||||||
|
'light.ceiling_lights',
|
||||||
|
'name': {
|
||||||
|
'name': 'Roof Lights',
|
||||||
|
'nicknames': ['top lights', 'ceiling lights']
|
||||||
|
},
|
||||||
|
'traits': [
|
||||||
|
'action.devices.traits.OnOff', 'action.devices.traits.Brightness',
|
||||||
|
'action.devices.traits.ColorSpectrum',
|
||||||
|
'action.devices.traits.ColorTemperature'
|
||||||
|
],
|
||||||
|
'type':
|
||||||
|
'action.devices.types.LIGHT',
|
||||||
|
'willReportState':
|
||||||
|
False
|
||||||
|
}, {
|
||||||
|
'id':
|
||||||
|
'light.bed_light',
|
||||||
|
'name': {
|
||||||
|
'name': 'Bed Light'
|
||||||
|
},
|
||||||
|
'traits': [
|
||||||
|
'action.devices.traits.OnOff', 'action.devices.traits.Brightness',
|
||||||
|
'action.devices.traits.ColorSpectrum',
|
||||||
|
'action.devices.traits.ColorTemperature'
|
||||||
|
],
|
||||||
|
'type':
|
||||||
|
'action.devices.types.LIGHT',
|
||||||
|
'willReportState':
|
||||||
|
False
|
||||||
|
}, {
|
||||||
|
'id': 'group.all_lights',
|
||||||
|
'name': {
|
||||||
|
'name': 'all lights'
|
||||||
|
},
|
||||||
|
'traits': ['action.devices.traits.Scene'],
|
||||||
|
'type': 'action.devices.types.SCENE',
|
||||||
|
'willReportState': False
|
||||||
|
}, {
|
||||||
|
'id':
|
||||||
|
'cover.living_room_window',
|
||||||
|
'name': {
|
||||||
|
'name': 'Living Room Window'
|
||||||
|
},
|
||||||
|
'traits':
|
||||||
|
['action.devices.traits.OnOff', 'action.devices.traits.Brightness'],
|
||||||
|
'type':
|
||||||
|
'action.devices.types.LIGHT',
|
||||||
|
'willReportState':
|
||||||
|
False
|
||||||
|
}, {
|
||||||
|
'id':
|
||||||
|
'cover.hall_window',
|
||||||
|
'name': {
|
||||||
|
'name': 'Hall Window'
|
||||||
|
},
|
||||||
|
'traits':
|
||||||
|
['action.devices.traits.OnOff', 'action.devices.traits.Brightness'],
|
||||||
|
'type':
|
||||||
|
'action.devices.types.LIGHT',
|
||||||
|
'willReportState':
|
||||||
|
False
|
||||||
|
}, {
|
||||||
|
'id': 'cover.garage_door',
|
||||||
|
'name': {
|
||||||
|
'name': 'Garage Door'
|
||||||
|
},
|
||||||
|
'traits': ['action.devices.traits.OnOff'],
|
||||||
|
'type': 'action.devices.types.LIGHT',
|
||||||
|
'willReportState': False
|
||||||
|
}, {
|
||||||
|
'id': 'cover.kitchen_window',
|
||||||
|
'name': {
|
||||||
|
'name': 'Kitchen Window'
|
||||||
|
},
|
||||||
|
'traits': ['action.devices.traits.OnOff'],
|
||||||
|
'type': 'action.devices.types.LIGHT',
|
||||||
|
'willReportState': False
|
||||||
|
}, {
|
||||||
|
'id': 'group.all_covers',
|
||||||
|
'name': {
|
||||||
|
'name': 'all covers'
|
||||||
|
},
|
||||||
|
'traits': ['action.devices.traits.Scene'],
|
||||||
|
'type': 'action.devices.types.SCENE',
|
||||||
|
'willReportState': False
|
||||||
|
}, {
|
||||||
|
'id':
|
||||||
|
'media_player.bedroom',
|
||||||
|
'name': {
|
||||||
|
'name': 'Bedroom'
|
||||||
|
},
|
||||||
|
'traits':
|
||||||
|
['action.devices.traits.OnOff', 'action.devices.traits.Brightness'],
|
||||||
|
'type':
|
||||||
|
'action.devices.types.LIGHT',
|
||||||
|
'willReportState':
|
||||||
|
False
|
||||||
|
}, {
|
||||||
|
'id':
|
||||||
|
'media_player.living_room',
|
||||||
|
'name': {
|
||||||
|
'name': 'Living Room'
|
||||||
|
},
|
||||||
|
'traits':
|
||||||
|
['action.devices.traits.OnOff', 'action.devices.traits.Brightness'],
|
||||||
|
'type':
|
||||||
|
'action.devices.types.LIGHT',
|
||||||
|
'willReportState':
|
||||||
|
False
|
||||||
|
}, {
|
||||||
|
'id': 'media_player.lounge_room',
|
||||||
|
'name': {
|
||||||
|
'name': 'Lounge room'
|
||||||
|
},
|
||||||
|
'traits': ['action.devices.traits.OnOff'],
|
||||||
|
'type': 'action.devices.types.LIGHT',
|
||||||
|
'willReportState': False
|
||||||
|
}, {
|
||||||
|
'id':
|
||||||
|
'media_player.walkman',
|
||||||
|
'name': {
|
||||||
|
'name': 'Walkman'
|
||||||
|
},
|
||||||
|
'traits':
|
||||||
|
['action.devices.traits.OnOff', 'action.devices.traits.Brightness'],
|
||||||
|
'type':
|
||||||
|
'action.devices.types.LIGHT',
|
||||||
|
'willReportState':
|
||||||
|
False
|
||||||
|
}, {
|
||||||
|
'id': 'fan.living_room_fan',
|
||||||
|
'name': {
|
||||||
|
'name': 'Living Room Fan'
|
||||||
|
},
|
||||||
|
'traits': ['action.devices.traits.OnOff'],
|
||||||
|
'type': 'action.devices.types.SWITCH',
|
||||||
|
'willReportState': False
|
||||||
|
}, {
|
||||||
|
'id': 'fan.ceiling_fan',
|
||||||
|
'name': {
|
||||||
|
'name': 'Ceiling Fan'
|
||||||
|
},
|
||||||
|
'traits': ['action.devices.traits.OnOff'],
|
||||||
|
'type': 'action.devices.types.SWITCH',
|
||||||
|
'willReportState': False
|
||||||
|
}, {
|
||||||
|
'id': 'group.all_fans',
|
||||||
|
'name': {
|
||||||
|
'name': 'all fans'
|
||||||
|
},
|
||||||
|
'traits': ['action.devices.traits.Scene'],
|
||||||
|
'type': 'action.devices.types.SCENE',
|
||||||
|
'willReportState': False
|
||||||
|
}]
|
214
tests/components/google_assistant/test_google_assistant.py
Normal file
214
tests/components/google_assistant/test_google_assistant.py
Normal file
|
@ -0,0 +1,214 @@
|
||||||
|
"""The tests for the Google Actions component."""
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant import setup, const, core
|
||||||
|
from homeassistant.components import (
|
||||||
|
http, async_setup, light, cover, media_player, fan
|
||||||
|
)
|
||||||
|
from homeassistant.components import google_assistant as ga
|
||||||
|
from tests.common import get_test_instance_port
|
||||||
|
|
||||||
|
from . import DEMO_DEVICES
|
||||||
|
|
||||||
|
|
||||||
|
API_PASSWORD = "test1234"
|
||||||
|
SERVER_PORT = get_test_instance_port()
|
||||||
|
BASE_API_URL = "http://127.0.0.1:{}".format(SERVER_PORT)
|
||||||
|
|
||||||
|
HA_HEADERS = {
|
||||||
|
const.HTTP_HEADER_HA_AUTH: API_PASSWORD,
|
||||||
|
const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTHCFG = {
|
||||||
|
'project_id': 'hasstest-1234',
|
||||||
|
'client_id': 'helloworld',
|
||||||
|
'access_token': 'superdoublesecret'
|
||||||
|
}
|
||||||
|
AUTH_HEADER = {'Authorization': 'Bearer {}'.format(AUTHCFG['access_token'])}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def assistant_client(loop, hass_fixture, test_client):
|
||||||
|
"""Create web client for emulated hue api."""
|
||||||
|
hass = hass_fixture
|
||||||
|
web_app = hass.http.app
|
||||||
|
|
||||||
|
ga.http.GoogleAssistantView(hass, AUTHCFG).register(web_app.router)
|
||||||
|
ga.auth.GoogleAssistantAuthView(hass, AUTHCFG).register(web_app.router)
|
||||||
|
|
||||||
|
return loop.run_until_complete(test_client(web_app))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def hass_fixture(loop, hass):
|
||||||
|
"""Setup a hass instance for these tests."""
|
||||||
|
# We need to do this to get access to homeassistant/turn_(on,off)
|
||||||
|
loop.run_until_complete(async_setup(hass, {core.DOMAIN: {}}))
|
||||||
|
|
||||||
|
loop.run_until_complete(
|
||||||
|
setup.async_setup_component(hass, http.DOMAIN, {
|
||||||
|
http.DOMAIN: {
|
||||||
|
http.CONF_SERVER_PORT: SERVER_PORT
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
loop.run_until_complete(
|
||||||
|
setup.async_setup_component(hass, light.DOMAIN, {
|
||||||
|
'light': [{
|
||||||
|
'platform': 'demo'
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
loop.run_until_complete(
|
||||||
|
setup.async_setup_component(hass, cover.DOMAIN, {
|
||||||
|
'cover': [{
|
||||||
|
'platform': 'demo'
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
loop.run_until_complete(
|
||||||
|
setup.async_setup_component(hass, media_player.DOMAIN, {
|
||||||
|
'media_player': [{
|
||||||
|
'platform': 'demo'
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
loop.run_until_complete(
|
||||||
|
setup.async_setup_component(hass, fan.DOMAIN, {
|
||||||
|
'fan': [{
|
||||||
|
'platform': 'demo'
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Kitchen light is explicitly excluded from being exposed
|
||||||
|
ceiling_lights_entity = hass.states.get('light.ceiling_lights')
|
||||||
|
attrs = dict(ceiling_lights_entity.attributes)
|
||||||
|
attrs[ga.const.ATTR_GOOGLE_ASSISTANT_NAME] = "Roof Lights"
|
||||||
|
attrs[ga.const.CONF_ALIASES] = ['top lights', 'ceiling lights']
|
||||||
|
hass.states.async_set(
|
||||||
|
ceiling_lights_entity.entity_id,
|
||||||
|
ceiling_lights_entity.state,
|
||||||
|
attributes=attrs)
|
||||||
|
|
||||||
|
return hass
|
||||||
|
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def test_auth(hass_fixture, assistant_client):
|
||||||
|
"""Test the auth process."""
|
||||||
|
result = yield from assistant_client.get(
|
||||||
|
ga.const.GOOGLE_ASSISTANT_API_ENDPOINT + '/auth',
|
||||||
|
params={
|
||||||
|
'redirect_uri':
|
||||||
|
'http://testurl/r/{}'.format(AUTHCFG['project_id']),
|
||||||
|
'client_id': AUTHCFG['client_id'],
|
||||||
|
'state': 'random1234',
|
||||||
|
},
|
||||||
|
allow_redirects=False)
|
||||||
|
assert result.status == 301
|
||||||
|
loc = result.headers.get('Location')
|
||||||
|
assert AUTHCFG['access_token'] in loc
|
||||||
|
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def test_sync_request(hass_fixture, assistant_client):
|
||||||
|
"""Test a sync request."""
|
||||||
|
reqid = '5711642932632160983'
|
||||||
|
data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]}
|
||||||
|
result = yield from assistant_client.post(
|
||||||
|
ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
headers=AUTH_HEADER)
|
||||||
|
assert result.status == 200
|
||||||
|
body = yield from result.json()
|
||||||
|
assert body.get('requestId') == reqid
|
||||||
|
devices = body['payload']['devices']
|
||||||
|
# assert len(devices) == 4
|
||||||
|
assert len(devices) == len(DEMO_DEVICES)
|
||||||
|
# HACK this is kind of slow and lazy
|
||||||
|
for dev in devices:
|
||||||
|
for demo in DEMO_DEVICES:
|
||||||
|
if dev['id'] == demo['id']:
|
||||||
|
assert dev['name'] == demo['name']
|
||||||
|
assert set(dev['traits']) == set(demo['traits'])
|
||||||
|
assert dev['type'] == demo['type']
|
||||||
|
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def test_query_request(hass_fixture, assistant_client):
|
||||||
|
"""Test a query request."""
|
||||||
|
# hass.states.set("light.bedroom", "on")
|
||||||
|
# hass.states.set("switch.outside", "off")
|
||||||
|
# res = _sync_req()
|
||||||
|
reqid = '5711642932632160984'
|
||||||
|
data = {
|
||||||
|
'requestId':
|
||||||
|
reqid,
|
||||||
|
'inputs': [{
|
||||||
|
'intent': 'action.devices.QUERY',
|
||||||
|
'payload': {
|
||||||
|
'devices': [{
|
||||||
|
'id': "light.ceiling_lights",
|
||||||
|
}, {
|
||||||
|
'id': "light.bed_light",
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
result = yield from assistant_client.post(
|
||||||
|
ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
headers=AUTH_HEADER)
|
||||||
|
assert result.status == 200
|
||||||
|
body = yield from result.json()
|
||||||
|
assert body.get('requestId') == reqid
|
||||||
|
devices = body['payload']['devices']
|
||||||
|
assert len(devices) == 2
|
||||||
|
assert devices['light.bed_light']['on'] is False
|
||||||
|
assert devices['light.ceiling_lights']['on'] is True
|
||||||
|
assert devices['light.ceiling_lights']['brightness'] == 70
|
||||||
|
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def test_execute_request(hass_fixture, assistant_client):
|
||||||
|
"""Test a execute request."""
|
||||||
|
# hass.states.set("light.bedroom", "on")
|
||||||
|
# hass.states.set("switch.outside", "off")
|
||||||
|
# res = _sync_req()
|
||||||
|
reqid = '5711642932632160985'
|
||||||
|
data = {
|
||||||
|
'requestId':
|
||||||
|
reqid,
|
||||||
|
'inputs': [{
|
||||||
|
'intent': 'action.devices.EXECUTE',
|
||||||
|
'payload': {
|
||||||
|
"commands": [{
|
||||||
|
"devices": [{
|
||||||
|
"id": "light.ceiling_lights",
|
||||||
|
}, {
|
||||||
|
"id": "light.bed_light",
|
||||||
|
}],
|
||||||
|
"execution": [{
|
||||||
|
"command": "action.devices.commands.OnOff",
|
||||||
|
"params": {
|
||||||
|
"on": False
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
result = yield from assistant_client.post(
|
||||||
|
ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
|
||||||
|
data=json.dumps(data),
|
||||||
|
headers=AUTH_HEADER)
|
||||||
|
assert result.status == 200
|
||||||
|
body = yield from result.json()
|
||||||
|
assert body.get('requestId') == reqid
|
||||||
|
commands = body['payload']['commands']
|
||||||
|
assert len(commands) == 2
|
||||||
|
ceiling = hass_fixture.states.get('light.ceiling_lights')
|
||||||
|
assert ceiling.state == 'off'
|
87
tests/components/google_assistant/test_smart_home.py
Normal file
87
tests/components/google_assistant/test_smart_home.py
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
"""The tests for the Google Actions component."""
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from homeassistant import const
|
||||||
|
from homeassistant.components import google_assistant as ga
|
||||||
|
|
||||||
|
DETERMINE_SERVICE_TESTS = [{ # Test light brightness
|
||||||
|
'entity_id': 'light.test',
|
||||||
|
'command': ga.const.COMMAND_BRIGHTNESS,
|
||||||
|
'params': {
|
||||||
|
'brightness': 95
|
||||||
|
},
|
||||||
|
'expected': (
|
||||||
|
const.SERVICE_TURN_ON,
|
||||||
|
{'entity_id': 'light.test', 'brightness': 242}
|
||||||
|
)
|
||||||
|
}, { # Test light on / off
|
||||||
|
'entity_id': 'light.test',
|
||||||
|
'command': ga.const.COMMAND_ONOFF,
|
||||||
|
'params': {
|
||||||
|
'on': False
|
||||||
|
},
|
||||||
|
'expected': (const.SERVICE_TURN_OFF, {'entity_id': 'light.test'})
|
||||||
|
}, {
|
||||||
|
'entity_id': 'light.test',
|
||||||
|
'command': ga.const.COMMAND_ONOFF,
|
||||||
|
'params': {
|
||||||
|
'on': True
|
||||||
|
},
|
||||||
|
'expected': (const.SERVICE_TURN_ON, {'entity_id': 'light.test'})
|
||||||
|
}, { # Test Cover open close
|
||||||
|
'entity_id': 'cover.bedroom',
|
||||||
|
'command': ga.const.COMMAND_ONOFF,
|
||||||
|
'params': {
|
||||||
|
'on': True
|
||||||
|
},
|
||||||
|
'expected': (const.SERVICE_OPEN_COVER, {'entity_id': 'cover.bedroom'}),
|
||||||
|
}, {
|
||||||
|
'entity_id': 'cover.bedroom',
|
||||||
|
'command': ga.const.COMMAND_ONOFF,
|
||||||
|
'params': {
|
||||||
|
'on': False
|
||||||
|
},
|
||||||
|
'expected': (const.SERVICE_CLOSE_COVER, {'entity_id': 'cover.bedroom'}),
|
||||||
|
}, { # Test cover position
|
||||||
|
'entity_id': 'cover.bedroom',
|
||||||
|
'command': ga.const.COMMAND_BRIGHTNESS,
|
||||||
|
'params': {
|
||||||
|
'brightness': 50
|
||||||
|
},
|
||||||
|
'expected': (
|
||||||
|
const.SERVICE_SET_COVER_POSITION,
|
||||||
|
{'entity_id': 'cover.bedroom', 'position': 50}
|
||||||
|
),
|
||||||
|
}, { # Test media_player volume
|
||||||
|
'entity_id': 'media_player.living_room',
|
||||||
|
'command': ga.const.COMMAND_BRIGHTNESS,
|
||||||
|
'params': {
|
||||||
|
'brightness': 30
|
||||||
|
},
|
||||||
|
'expected': (
|
||||||
|
const.SERVICE_VOLUME_SET,
|
||||||
|
{'entity_id': 'media_player.living_room', 'volume_level': 0.3}
|
||||||
|
),
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def test_make_actions_response():
|
||||||
|
"""Test make response helper."""
|
||||||
|
reqid = 1234
|
||||||
|
payload = 'hello'
|
||||||
|
result = ga.smart_home.make_actions_response(reqid, payload)
|
||||||
|
assert result['requestId'] == reqid
|
||||||
|
assert result['payload'] == payload
|
||||||
|
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def test_determine_service():
|
||||||
|
"""Test all branches of determine service."""
|
||||||
|
for test in DETERMINE_SERVICE_TESTS:
|
||||||
|
result = ga.smart_home.determine_service(
|
||||||
|
test['entity_id'],
|
||||||
|
test['command'],
|
||||||
|
test['params'])
|
||||||
|
assert result == test['expected']
|
Loading…
Add table
Add a link
Reference in a new issue