diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index a1c6811afe7..6b306adad5b 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -15,6 +15,7 @@ import homeassistant.core as ha import homeassistant.config as conf_util from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers import intent from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, @@ -154,6 +155,12 @@ def async_setup(hass, config): ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) hass.services.async_register( ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned on {}")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, "Turned off {}")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}")) @asyncio.coroutine def async_handle_core_service(call): diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 5187b4782ef..c1dd89d31cd 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -7,19 +7,15 @@ https://home-assistant.io/components/conversation/ import asyncio import logging import re -import warnings import voluptuous as vol from homeassistant import core from homeassistant.components import http -from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) from homeassistant.helpers import config_validation as cv from homeassistant.helpers import intent -from homeassistant.loader import bind_hass -REQUIREMENTS = ['fuzzywuzzy==0.16.0'] +from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) @@ -28,9 +24,6 @@ ATTR_TEXT = 'text' DEPENDENCIES = ['http'] DOMAIN = 'conversation' -INTENT_TURN_OFF = 'HassTurnOff' -INTENT_TURN_ON = 'HassTurnOn' - REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') REGEX_TYPE = type(re.compile('')) @@ -50,7 +43,7 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({ @core.callback @bind_hass def async_register(hass, intent_type, utterances): - """Register an intent. + """Register utterances and any custom intents. Registrations don't require conversations to be loaded. They will become active once the conversation component is loaded. @@ -75,8 +68,6 @@ def async_register(hass, intent_type, utterances): @asyncio.coroutine def async_setup(hass, config): """Register the process service.""" - warnings.filterwarnings('ignore', module='fuzzywuzzy') - config = config.get(DOMAIN, {}) intents = hass.data.get(DOMAIN) @@ -102,12 +93,12 @@ def async_setup(hass, config): hass.http.register_view(ConversationProcessView) - hass.helpers.intent.async_register(TurnOnIntent()) - hass.helpers.intent.async_register(TurnOffIntent()) - async_register(hass, INTENT_TURN_ON, + async_register(hass, intent.INTENT_TURN_ON, ['Turn {name} on', 'Turn on {name}']) - async_register(hass, INTENT_TURN_OFF, [ - 'Turn {name} off', 'Turn off {name}']) + async_register(hass, intent.INTENT_TURN_OFF, + ['Turn {name} off', 'Turn off {name}']) + async_register(hass, intent.INTENT_TOGGLE, + ['Toggle {name}', '{name} toggle']) return True @@ -151,79 +142,6 @@ def _process(hass, text): return response -@core.callback -def _match_entity(hass, name): - """Match a name to an entity.""" - from fuzzywuzzy import process as fuzzyExtract - entities = {state.entity_id: state.name for state - in hass.states.async_all()} - entity_id = fuzzyExtract.extractOne( - name, entities, score_cutoff=65)[2] - return hass.states.get(entity_id) if entity_id else None - - -class TurnOnIntent(intent.IntentHandler): - """Handle turning item on intents.""" - - intent_type = INTENT_TURN_ON - slot_schema = { - 'name': cv.string, - } - - @asyncio.coroutine - def async_handle(self, intent_obj): - """Handle turn on intent.""" - hass = intent_obj.hass - slots = self.async_validate_slots(intent_obj.slots) - name = slots['name']['value'] - entity = _match_entity(hass, name) - - if not entity: - _LOGGER.error("Could not find entity id for %s", name) - return None - - yield from hass.services.async_call( - core.DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - }, blocking=True) - - response = intent_obj.create_response() - response.async_set_speech( - 'Turned on {}'.format(entity.name)) - return response - - -class TurnOffIntent(intent.IntentHandler): - """Handle turning item off intents.""" - - intent_type = INTENT_TURN_OFF - slot_schema = { - 'name': cv.string, - } - - @asyncio.coroutine - def async_handle(self, intent_obj): - """Handle turn off intent.""" - hass = intent_obj.hass - slots = self.async_validate_slots(intent_obj.slots) - name = slots['name']['value'] - entity = _match_entity(hass, name) - - if not entity: - _LOGGER.error("Could not find entity id for %s", name) - return None - - yield from hass.services.async_call( - core.DOMAIN, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: entity.entity_id, - }, blocking=True) - - response = intent_obj.create_response() - response.async_set_speech( - 'Turned off {}'.format(entity.name)) - return response - - class ConversationProcessView(http.HomeAssistantView): """View to retrieve shopping list content.""" diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 0cf9d83863f..bf2773d32b8 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,20 +1,27 @@ """Module to coordinate user intentions.""" import asyncio import logging +import re import voluptuous as vol from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.loader import bind_hass +from homeassistant.const import ATTR_ENTITY_ID - -DATA_KEY = 'intent' _LOGGER = logging.getLogger(__name__) +INTENT_TURN_OFF = 'HassTurnOff' +INTENT_TURN_ON = 'HassTurnOn' +INTENT_TOGGLE = 'HassToggle' + SLOT_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +DATA_KEY = 'intent' + SPEECH_TYPE_PLAIN = 'plain' SPEECH_TYPE_SSML = 'ssml' @@ -87,7 +94,7 @@ class IntentHandler: intent_type = None slot_schema = None _slot_schema = None - platforms = None + platforms = [] @callback def async_can_handle(self, intent_obj): @@ -117,6 +124,67 @@ class IntentHandler: return '<{} - {}>'.format(self.__class__.__name__, self.intent_type) +def fuzzymatch(name, entities): + """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) + if match: + matches.append((len(match.group()), match.start(), entity_id)) + return [x for _, _, x in sorted(matches)] + + +class ServiceIntentHandler(IntentHandler): + """Service Intent handler registration. + + Service specific intent handler that calls a service by name/entity_id. + """ + + slot_schema = { + 'name': cv.string, + } + + def __init__(self, intent_type, domain, service, speech): + """Create Service Intent Handler.""" + self.intent_type = intent_type + self.domain = domain + self.service = service + self.speech = speech + + @asyncio.coroutine + 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()} + + matches = fuzzymatch(name, entities) + entity_id = matches[0] if matches else None + _LOGGER.debug("%s matched entity: %s", name, 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 + + yield from hass.services.async_call( + self.domain, self.service, { + ATTR_ENTITY_ID: entity_id + }) + + response.async_set_speech( + self.speech.format(name)) + return response + + class Intent: """Hold the intent.""" diff --git a/requirements_all.txt b/requirements_all.txt index 308a81e16bb..21ae23ccc3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -295,9 +295,6 @@ fritzhome==1.0.4 # homeassistant.components.media_player.frontier_silicon fsapi==0.0.7 -# homeassistant.components.conversation -fuzzywuzzy==0.16.0 - # homeassistant.components.tts.google gTTS-token==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ecb478a29cc..efb9eb52ce4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -56,9 +56,6 @@ evohomeclient==0.2.5 # homeassistant.components.sensor.geo_rss_events feedparser==5.2.1 -# homeassistant.components.conversation -fuzzywuzzy==0.16.0 - # homeassistant.components.tts.google gTTS-token==1.1.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index dcd201667dd..9c510d8339e 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -46,7 +46,6 @@ TEST_REQUIREMENTS = ( 'ephem', 'evohomeclient', 'feedparser', - 'fuzzywuzzy', 'gTTS-token', 'ha-ffmpeg', 'haversine', diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index fab1e24d8e7..8d629321853 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -6,6 +6,7 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components import conversation +import homeassistant.components as component from homeassistant.helpers import intent from tests.common import async_mock_intent, async_mock_service @@ -16,6 +17,9 @@ def test_calling_intent(hass): """Test calling an intent from a conversation.""" intents = async_mock_intent(hass, 'OrderBeer') + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', { 'conversation': { 'intents': { @@ -145,6 +149,9 @@ def test_http_processing_intent(hass, test_client): @pytest.mark.parametrize('sentence', ('turn on kitchen', 'turn kitchen on')) def test_turn_on_intent(hass, sentence): """Test calling the turn on intent.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result @@ -168,6 +175,9 @@ def test_turn_on_intent(hass, sentence): @pytest.mark.parametrize('sentence', ('turn off kitchen', 'turn kitchen off')) def test_turn_off_intent(hass, sentence): """Test calling the turn on intent.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result @@ -187,9 +197,38 @@ def test_turn_off_intent(hass, sentence): assert call.data == {'entity_id': 'light.kitchen'} +@asyncio.coroutine +@pytest.mark.parametrize('sentence', ('toggle kitchen', 'kitchen toggle')) +def test_toggle_intent(hass, sentence): + """Test calling the turn on intent.""" + result = yield from component.async_setup(hass, {}) + assert result + + result = yield from async_setup_component(hass, 'conversation', {}) + assert result + + hass.states.async_set('light.kitchen', 'on') + calls = async_mock_service(hass, 'homeassistant', 'toggle') + + yield from hass.services.async_call( + 'conversation', 'process', { + conversation.ATTR_TEXT: sentence + }) + yield from hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'homeassistant' + assert call.service == 'toggle' + assert call.data == {'entity_id': 'light.kitchen'} + + @asyncio.coroutine def test_http_api(hass, test_client): """Test the HTTP conversation API.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result @@ -212,6 +251,9 @@ def test_http_api(hass, test_client): @asyncio.coroutine def test_http_api_wrong_data(hass, test_client): """Test the HTTP conversation API.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result diff --git a/tests/components/test_init.py b/tests/components/test_init.py index dde141b6495..fff3b74c831 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -11,6 +11,7 @@ from homeassistant import config from homeassistant.const import ( STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE) import homeassistant.components as comps +import homeassistant.helpers.intent as intent from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity from homeassistant.util.async import run_coroutine_threadsafe @@ -195,3 +196,96 @@ class TestComponentsCore(unittest.TestCase): self.hass.block_till_done() assert mock_check.called assert not mock_stop.called + + +@asyncio.coroutine +def test_turn_on_intent(hass): + """Test HassTurnOn intent.""" + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'off') + calls = async_mock_service(hass, 'light', SERVICE_TURN_ON) + + response = yield from intent.async_handle( + hass, 'test', 'HassTurnOn', {'name': {'value': 'test light'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Turned on test light' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'turn_on' + assert call.data == {'entity_id': ['light.test_light']} + + +@asyncio.coroutine +def test_turn_off_intent(hass): + """Test HassTurnOff intent.""" + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'on') + calls = async_mock_service(hass, 'light', SERVICE_TURN_OFF) + + response = yield from intent.async_handle( + hass, 'test', 'HassTurnOff', {'name': {'value': 'test light'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Turned off test light' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'turn_off' + assert call.data == {'entity_id': ['light.test_light']} + + +@asyncio.coroutine +def test_toggle_intent(hass): + """Test HassToggle intent.""" + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'off') + calls = async_mock_service(hass, 'light', SERVICE_TOGGLE) + + response = yield from intent.async_handle( + hass, 'test', 'HassToggle', {'name': {'value': 'test light'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Toggled test light' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'toggle' + assert call.data == {'entity_id': ['light.test_light']} + + +@asyncio.coroutine +def test_turn_on_multiple_intent(hass): + """Test HassTurnOn intent with multiple similar entities. + + This tests that matching finds the proper entity among similar names. + """ + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'off') + hass.states.async_set('light.test_lights_2', 'off') + hass.states.async_set('light.test_lighter', 'off') + calls = async_mock_service(hass, 'light', SERVICE_TURN_ON) + + response = yield from intent.async_handle( + hass, 'test', 'HassTurnOn', {'name': {'value': 'test lights'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Turned on test lights' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'turn_on' + assert call.data == {'entity_id': ['light.test_lights_2']}