Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Intent: Set light color #12633

Merged
merged 8 commits into from
Feb 28, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion homeassistant/components/light/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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

Expand Down Expand Up @@ -135,6 +136,8 @@
vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte))
)

INTENT_SET = 'HassLightSet'

_LOGGER = logging.getLogger(__name__)


Expand Down Expand Up @@ -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:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

color_name_to_rgb will now raise instead of silently returning white if no match found. I've added that logic back in the only place that depended on it.

_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:
Expand All @@ -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(
Expand Down Expand Up @@ -291,6 +360,8 @@ async def async_handle_light_service(service):
DOMAIN, SERVICE_TOGGLE, async_handle_light_service,
schema=LIGHT_TOGGLE_SCHEMA)

hass.helpers.intent.async_register(SetIntentHandler())

return True


Expand Down
82 changes: 47 additions & 35 deletions homeassistant/helpers/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

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
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -122,16 +149,17 @@ def __repr__(self):
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):
Expand All @@ -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):
Expand All @@ -155,30 +183,14 @@ async def async_handle(self, intent_obj):
"""Handle the hass intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
response = intent_obj.create_response()

name = slots['name']['value']
entities = {state.entity_id: state.name for state
in hass.states.async_all()}
state = async_match_state(hass, slots['name']['value'])

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


Expand Down
6 changes: 1 addition & 5 deletions homeassistant/util/color.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
96 changes: 94 additions & 2 deletions tests/components/light/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
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):
Expand Down Expand Up @@ -302,3 +304,93 @@ def test_light_profiles(self):
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
Loading