Intent: Set light color (#12633)

* Make color_name_to_rgb raise

* Add Light Set Color intent

* Move some methods around

* Cleanup

* Prevent 1 more func call

* Make a generic Set intent for light

* Lint

* lint
This commit is contained in:
Paulus Schoutsen 2018-02-27 18:02:21 -08:00 committed by GitHub
parent 14d052d242
commit efd155dd3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 231 additions and 47 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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)