diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index b7e8966394e..14e86eeb1fb 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -21,6 +21,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers import intent from homeassistant.loader import bind_hass import homeassistant.util.color as color_util @@ -135,6 +136,8 @@ PROFILE_SCHEMA = vol.Schema( vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte)) ) +INTENT_SET = 'HassLightSet' + _LOGGER = logging.getLogger(__name__) @@ -228,7 +231,12 @@ def preprocess_turn_on_alternatives(params): color_name = params.pop(ATTR_COLOR_NAME, None) if color_name is not None: - params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + try: + params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + except ValueError: + _LOGGER.warning('Got unknown color %s, falling back to white', + color_name) + params[ATTR_RGB_COLOR] = (255, 255, 255) kelvin = params.pop(ATTR_KELVIN, None) if kelvin is not None: @@ -240,6 +248,67 @@ def preprocess_turn_on_alternatives(params): params[ATTR_BRIGHTNESS] = int(255 * brightness_pct/100) +class SetIntentHandler(intent.IntentHandler): + """Handle set color intents.""" + + intent_type = INTENT_SET + slot_schema = { + vol.Required('name'): cv.string, + vol.Optional('color'): color_util.color_name_to_rgb, + vol.Optional('brightness'): vol.All(vol.Coerce(int), vol.Range(0, 100)) + } + + async def async_handle(self, intent_obj): + """Handle the hass intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + state = hass.helpers.intent.async_match_state( + slots['name']['value'], + [state for state in hass.states.async_all() + if state.domain == DOMAIN]) + + service_data = { + ATTR_ENTITY_ID: state.entity_id, + } + speech_parts = [] + + if 'color' in slots: + intent.async_test_feature( + state, SUPPORT_RGB_COLOR, 'changing colors') + service_data[ATTR_RGB_COLOR] = slots['color']['value'] + # Use original passed in value of the color because we don't have + # human readable names for that internally. + speech_parts.append('the color {}'.format( + intent_obj.slots['color']['value'])) + + if 'brightness' in slots: + intent.async_test_feature( + state, SUPPORT_BRIGHTNESS, 'changing brightness') + service_data[ATTR_BRIGHTNESS_PCT] = slots['brightness']['value'] + speech_parts.append('{}% brightness'.format( + slots['brightness']['value'])) + + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, service_data) + + response = intent_obj.create_response() + + if not speech_parts: # No attributes changed + speech = 'Turned on {}'.format(state.name) + else: + parts = ['Changed {} to'.format(state.name)] + for index, part in enumerate(speech_parts): + if index == 0: + parts.append(' {}'.format(part)) + elif index != len(speech_parts) - 1: + parts.append(', {}'.format(part)) + else: + parts.append(' and {}'.format(part)) + speech = ''.join(parts) + + response.async_set_speech(speech) + return response + + async def async_setup(hass, config): """Expose light control via state machine and services.""" component = EntityComponent( @@ -291,6 +360,8 @@ async def async_setup(hass, config): DOMAIN, SERVICE_TOGGLE, async_handle_light_service, schema=LIGHT_TOGGLE_SCHEMA) + hass.helpers.intent.async_register(SetIntentHandler()) + return True diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index dfbce4e82a5..26c2bca34c7 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -4,6 +4,7 @@ import re import voluptuous as vol +from homeassistant.const import ATTR_SUPPORTED_FEATURES from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -33,6 +34,8 @@ def async_register(hass, handler): if intents is None: intents = hass.data[DATA_KEY] = {} + assert handler.intent_type is not None, 'intent_type cannot be None' + if handler.intent_type in intents: _LOGGER.warning('Intent %s is being overwritten by %s.', handler.intent_type, handler) @@ -56,35 +59,59 @@ async def async_handle(hass, platform, intent_type, slots=None, result = await handler.async_handle(intent) return result except vol.Invalid as err: + _LOGGER.warning('Received invalid slot info for %s: %s', + intent_type, err) raise InvalidSlotInfo( 'Received invalid slot info for {}'.format(intent_type)) from err + except IntentHandleError: + raise except Exception as err: - raise IntentHandleError( + raise IntentUnexpectedError( 'Error handling {}'.format(intent_type)) from err class IntentError(HomeAssistantError): """Base class for intent related errors.""" - pass - class UnknownIntent(IntentError): """When the intent is not registered.""" - pass - class InvalidSlotInfo(IntentError): """When the slot data is invalid.""" - pass - class IntentHandleError(IntentError): """Error while handling intent.""" - pass + +class IntentUnexpectedError(IntentError): + """Unexpected error while handling intent.""" + + +@callback +@bind_hass +def async_match_state(hass, name, states=None): + """Find a state that matches the name.""" + if states is None: + states = hass.states.async_all() + + entity = _fuzzymatch(name, states, lambda state: state.name) + + if entity is None: + raise IntentHandleError('Unable to find entity {}'.format(name)) + + return entity + + +@callback +def async_test_feature(state, feature, feature_name): + """Test is state supports a feature.""" + if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0: + raise IntentHandleError( + 'Entity {} does not support {}'.format( + state.name, feature_name)) class IntentHandler: @@ -122,16 +149,17 @@ class IntentHandler: return '<{} - {}>'.format(self.__class__.__name__, self.intent_type) -def fuzzymatch(name, entities): +def _fuzzymatch(name, items, key): """Fuzzy matching function.""" matches = [] pattern = '.*?'.join(name) regex = re.compile(pattern, re.IGNORECASE) - for entity_id, entity_name in entities.items(): - match = regex.search(entity_name) + for item in items: + match = regex.search(key(item)) if match: - matches.append((len(match.group()), match.start(), entity_id)) - return [x for _, _, x in sorted(matches)] + matches.append((len(match.group()), match.start(), item)) + + return sorted(matches)[0][2] if matches else None class ServiceIntentHandler(IntentHandler): @@ -141,7 +169,7 @@ class ServiceIntentHandler(IntentHandler): """ slot_schema = { - 'name': cv.string, + vol.Required('name'): cv.string, } def __init__(self, intent_type, domain, service, speech): @@ -155,30 +183,14 @@ class ServiceIntentHandler(IntentHandler): """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - response = intent_obj.create_response() + state = async_match_state(hass, slots['name']['value']) - name = slots['name']['value'] - entities = {state.entity_id: state.name for state - in hass.states.async_all()} - - matches = fuzzymatch(name, entities) - entity_id = matches[0] if matches else None - _LOGGER.debug("%s matched entity: %s", name, entity_id) + await hass.services.async_call(self.domain, self.service, { + ATTR_ENTITY_ID: state.entity_id + }) response = intent_obj.create_response() - if not entity_id: - response.async_set_speech( - "Could not find entity id matching {}.".format(name)) - _LOGGER.error("Could not find entity id matching %s", name) - return response - - await hass.services.async_call( - self.domain, self.service, { - ATTR_ENTITY_ID: entity_id - }) - - response.async_set_speech( - self.speech.format(name)) + response.async_set_speech(self.speech.format(state.name)) return response diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 24621772050..70863a0ab90 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -1,12 +1,9 @@ """Color util methods.""" -import logging import math import colorsys from typing import Tuple -_LOGGER = logging.getLogger(__name__) - # Official CSS3 colors from w3.org: # https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 # names do not have spaces in them so that we can compare against @@ -171,8 +168,7 @@ def color_name_to_rgb(color_name): # spaces in it as well for matching purposes hex_value = COLORS.get(color_name.replace(' ', '').lower()) if not hex_value: - _LOGGER.error('unknown color supplied %s default to white', color_name) - hex_value = COLORS['white'] + raise ValueError('Unknown color') return hex_value diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index ecfe3f36761..d35321b4479 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -7,10 +7,12 @@ from homeassistant.setup import setup_component import homeassistant.loader as loader from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ON, STATE_OFF, CONF_PLATFORM, - SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE) + SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_SUPPORTED_FEATURES) import homeassistant.components.light as light +from homeassistant.helpers.intent import IntentHandleError -from tests.common import mock_service, get_test_home_assistant +from tests.common import ( + async_mock_service, mock_service, get_test_home_assistant) class TestLight(unittest.TestCase): @@ -302,3 +304,93 @@ class TestLight(unittest.TestCase): self.assertEqual( {light.ATTR_XY_COLOR: (.4, .6), light.ATTR_BRIGHTNESS: 100}, data) + + +async def test_intent_set_color(hass): + """Test the set color intent.""" + hass.states.async_set('light.hello_2', 'off', { + ATTR_SUPPORTED_FEATURES: light.SUPPORT_RGB_COLOR + }) + hass.states.async_set('switch.hello', 'off') + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + hass.helpers.intent.async_register(light.SetIntentHandler()) + + result = await hass.helpers.intent.async_handle( + 'test', light.INTENT_SET, { + 'name': { + 'value': 'Hello', + }, + 'color': { + 'value': 'blue' + } + }) + await hass.async_block_till_done() + + assert result.speech['plain']['speech'] == \ + 'Changed hello 2 to the color blue' + + assert len(calls) == 1 + call = calls[0] + assert call.domain == light.DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data.get(ATTR_ENTITY_ID) == 'light.hello_2' + assert call.data.get(light.ATTR_RGB_COLOR) == (0, 0, 255) + + +async def test_intent_set_color_tests_feature(hass): + """Test the set color intent.""" + hass.states.async_set('light.hello', 'off') + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + hass.helpers.intent.async_register(light.SetIntentHandler()) + + try: + await hass.helpers.intent.async_handle( + 'test', light.INTENT_SET, { + 'name': { + 'value': 'Hello', + }, + 'color': { + 'value': 'blue' + } + }) + assert False, 'handling intent should have raised' + except IntentHandleError as err: + assert str(err) == 'Entity hello does not support changing colors' + + assert len(calls) == 0 + + +async def test_intent_set_color_and_brightness(hass): + """Test the set color intent.""" + hass.states.async_set('light.hello_2', 'off', { + ATTR_SUPPORTED_FEATURES: ( + light.SUPPORT_RGB_COLOR | light.SUPPORT_BRIGHTNESS) + }) + hass.states.async_set('switch.hello', 'off') + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + hass.helpers.intent.async_register(light.SetIntentHandler()) + + result = await hass.helpers.intent.async_handle( + 'test', light.INTENT_SET, { + 'name': { + 'value': 'Hello', + }, + 'color': { + 'value': 'blue' + }, + 'brightness': { + 'value': '20' + } + }) + await hass.async_block_till_done() + + assert result.speech['plain']['speech'] == \ + 'Changed hello 2 to the color blue and 20% brightness' + + assert len(calls) == 1 + call = calls[0] + assert call.domain == light.DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data.get(ATTR_ENTITY_ID) == 'light.hello_2' + assert call.data.get(light.ATTR_RGB_COLOR) == (0, 0, 255) + assert call.data.get(light.ATTR_BRIGHTNESS_PCT) == 20 diff --git a/tests/components/test_init.py b/tests/components/test_init.py index fff3b74c831..2005f658a71 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -283,7 +283,7 @@ def test_turn_on_multiple_intent(hass): ) yield from hass.async_block_till_done() - assert response.speech['plain']['speech'] == 'Turned on test lights' + assert response.speech['plain']['speech'] == 'Turned on test lights 2' assert len(calls) == 1 call = calls[0] assert call.domain == 'light' diff --git a/tests/util/test_color.py b/tests/util/test_color.py index acdeaa72b95..86d303c23b7 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -2,6 +2,9 @@ import unittest import homeassistant.util.color as color_util +import pytest +import voluptuous as vol + class TestColorUtil(unittest.TestCase): """Test color util methods.""" @@ -150,10 +153,10 @@ class TestColorUtil(unittest.TestCase): self.assertEqual((72, 61, 139), color_util.color_name_to_rgb('darkslate blue')) - def test_color_name_to_rgb_unknown_name_default_white(self): + def test_color_name_to_rgb_unknown_name_raises_value_error(self): """Test color_name_to_rgb.""" - self.assertEqual((255, 255, 255), - color_util.color_name_to_rgb('not a color')) + with pytest.raises(ValueError): + color_util.color_name_to_rgb('not a color') def test_color_rgb_to_rgbw(self): """Test color_rgb_to_rgbw.""" @@ -280,3 +283,13 @@ class ColorTemperatureToRGB(unittest.TestCase): rgb = color_util.color_temperature_to_rgb(6500) self.assertGreater(rgb[0], rgb[1]) self.assertGreater(rgb[0], rgb[2]) + + +def test_get_color_in_voluptuous(): + """Test using the get method in color validation.""" + schema = vol.Schema(color_util.color_name_to_rgb) + + with pytest.raises(vol.Invalid): + schema('not a color') + + assert schema('red') == (255, 0, 0)