mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
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:
parent
14d052d242
commit
efd155dd3c
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user