diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py new file mode 100644 index 00000000000..53de8764a12 --- /dev/null +++ b/homeassistant/components/google_assistant/__init__.py @@ -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 diff --git a/homeassistant/components/google_assistant/auth.py b/homeassistant/components/google_assistant/auth.py new file mode 100644 index 00000000000..4ef30ff53c8 --- /dev/null +++ b/homeassistant/components/google_assistant/auth.py @@ -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) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py new file mode 100644 index 00000000000..5cb66d882fe --- /dev/null +++ b/homeassistant/components/google_assistant/const.py @@ -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' diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py new file mode 100644 index 00000000000..adc626f73b7 --- /dev/null +++ b/homeassistant/components/google_assistant/http.py @@ -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} diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py new file mode 100644 index 00000000000..8d25a02cc95 --- /dev/null +++ b/homeassistant/components/google_assistant/smart_home.py @@ -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) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index c444cf1abbf..c9de284067f 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -358,19 +358,21 @@ class HomeAssistantView(object): requires_auth = True # Views inheriting from this class can override this # 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.""" msg = json.dumps( result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') 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.""" data = {'message': message} if message_code is not None: data['code'] = message_code - return self.json(data, status_code) + return self.json(data, status_code, headers=headers) @asyncio.coroutine # pylint: disable=no-self-use diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py new file mode 100644 index 00000000000..df4826470d0 --- /dev/null +++ b/tests/components/google_assistant/__init__.py @@ -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 +}] diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py new file mode 100644 index 00000000000..5a7cac6afc2 --- /dev/null +++ b/tests/components/google_assistant/test_google_assistant.py @@ -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' diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py new file mode 100644 index 00000000000..9b3c5eab037 --- /dev/null +++ b/tests/components/google_assistant/test_smart_home.py @@ -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']