Move HassIntent handler code into helpers/intent (#12181)

* Moved TurnOn/Off Intents to component

* Removed unused import

* Lint fix which my local runs dont catch apparently...

* Moved hass intent code into intent

* Added test for toggle to conversation.

* Fixed toggle tests

* Update intent.py

* Added homeassistant.helpers to gen_requirements script.

* Update intent.py

* Update intent.py

* Changed return value for _match_entity

* Moved consts and requirements

* Removed unused import

* Removed http view

* Removed http import

* Removed fuzzywuzzy dependency

* woof

* A few cleanups

* Added domain filtering to entities

* Clarified class doc string

* Added doc string

* Added test in test_init

* woof

* Cleanup entity matching

* Update intent.py

* removed uneeded setup from tests
This commit is contained in:
Tod Schmidt 2018-02-11 12:33:19 -05:00 committed by Paulus Schoutsen
parent 678f284015
commit 26209de2f2
8 changed files with 221 additions and 99 deletions

View File

@ -15,6 +15,7 @@ import homeassistant.core as ha
import homeassistant.config as conf_util import homeassistant.config as conf_util
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.service import extract_entity_ids from homeassistant.helpers.service import extract_entity_ids
from homeassistant.helpers import intent
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE,
SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART,
@ -154,6 +155,12 @@ def async_setup(hass, config):
ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service)
hass.services.async_register( hass.services.async_register(
ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) 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 @asyncio.coroutine
def async_handle_core_service(call): def async_handle_core_service(call):

View File

@ -7,19 +7,15 @@ https://home-assistant.io/components/conversation/
import asyncio import asyncio
import logging import logging
import re import re
import warnings
import voluptuous as vol import voluptuous as vol
from homeassistant import core from homeassistant import core
from homeassistant.components import http 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 config_validation as cv
from homeassistant.helpers import intent 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__) _LOGGER = logging.getLogger(__name__)
@ -28,9 +24,6 @@ ATTR_TEXT = 'text'
DEPENDENCIES = ['http'] DEPENDENCIES = ['http']
DOMAIN = 'conversation' DOMAIN = 'conversation'
INTENT_TURN_OFF = 'HassTurnOff'
INTENT_TURN_ON = 'HassTurnOn'
REGEX_TURN_COMMAND = re.compile(r'turn (?P<name>(?: |\w)+) (?P<command>\w+)') REGEX_TURN_COMMAND = re.compile(r'turn (?P<name>(?: |\w)+) (?P<command>\w+)')
REGEX_TYPE = type(re.compile('')) REGEX_TYPE = type(re.compile(''))
@ -50,7 +43,7 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({
@core.callback @core.callback
@bind_hass @bind_hass
def async_register(hass, intent_type, utterances): 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 Registrations don't require conversations to be loaded. They will become
active once the conversation component is loaded. active once the conversation component is loaded.
@ -75,8 +68,6 @@ def async_register(hass, intent_type, utterances):
@asyncio.coroutine @asyncio.coroutine
def async_setup(hass, config): def async_setup(hass, config):
"""Register the process service.""" """Register the process service."""
warnings.filterwarnings('ignore', module='fuzzywuzzy')
config = config.get(DOMAIN, {}) config = config.get(DOMAIN, {})
intents = hass.data.get(DOMAIN) intents = hass.data.get(DOMAIN)
@ -102,12 +93,12 @@ def async_setup(hass, config):
hass.http.register_view(ConversationProcessView) hass.http.register_view(ConversationProcessView)
hass.helpers.intent.async_register(TurnOnIntent()) async_register(hass, intent.INTENT_TURN_ON,
hass.helpers.intent.async_register(TurnOffIntent())
async_register(hass, INTENT_TURN_ON,
['Turn {name} on', 'Turn on {name}']) ['Turn {name} on', 'Turn on {name}'])
async_register(hass, INTENT_TURN_OFF, [ async_register(hass, intent.INTENT_TURN_OFF,
'Turn {name} off', 'Turn off {name}']) ['Turn {name} off', 'Turn off {name}'])
async_register(hass, intent.INTENT_TOGGLE,
['Toggle {name}', '{name} toggle'])
return True return True
@ -151,79 +142,6 @@ def _process(hass, text):
return response 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): class ConversationProcessView(http.HomeAssistantView):
"""View to retrieve shopping list content.""" """View to retrieve shopping list content."""

View File

@ -1,20 +1,27 @@
"""Module to coordinate user intentions.""" """Module to coordinate user intentions."""
import asyncio import asyncio
import logging import logging
import re
import voluptuous as vol import voluptuous as vol
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from homeassistant.const import ATTR_ENTITY_ID
DATA_KEY = 'intent'
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
INTENT_TURN_OFF = 'HassTurnOff'
INTENT_TURN_ON = 'HassTurnOn'
INTENT_TOGGLE = 'HassToggle'
SLOT_SCHEMA = vol.Schema({ SLOT_SCHEMA = vol.Schema({
}, extra=vol.ALLOW_EXTRA) }, extra=vol.ALLOW_EXTRA)
DATA_KEY = 'intent'
SPEECH_TYPE_PLAIN = 'plain' SPEECH_TYPE_PLAIN = 'plain'
SPEECH_TYPE_SSML = 'ssml' SPEECH_TYPE_SSML = 'ssml'
@ -87,7 +94,7 @@ class IntentHandler:
intent_type = None intent_type = None
slot_schema = None slot_schema = None
_slot_schema = None _slot_schema = None
platforms = None platforms = []
@callback @callback
def async_can_handle(self, intent_obj): def async_can_handle(self, intent_obj):
@ -117,6 +124,67 @@ class IntentHandler:
return '<{} - {}>'.format(self.__class__.__name__, self.intent_type) 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: class Intent:
"""Hold the intent.""" """Hold the intent."""

View File

@ -295,9 +295,6 @@ fritzhome==1.0.4
# homeassistant.components.media_player.frontier_silicon # homeassistant.components.media_player.frontier_silicon
fsapi==0.0.7 fsapi==0.0.7
# homeassistant.components.conversation
fuzzywuzzy==0.16.0
# homeassistant.components.tts.google # homeassistant.components.tts.google
gTTS-token==1.1.1 gTTS-token==1.1.1

View File

@ -56,9 +56,6 @@ evohomeclient==0.2.5
# homeassistant.components.sensor.geo_rss_events # homeassistant.components.sensor.geo_rss_events
feedparser==5.2.1 feedparser==5.2.1
# homeassistant.components.conversation
fuzzywuzzy==0.16.0
# homeassistant.components.tts.google # homeassistant.components.tts.google
gTTS-token==1.1.1 gTTS-token==1.1.1

View File

@ -46,7 +46,6 @@ TEST_REQUIREMENTS = (
'ephem', 'ephem',
'evohomeclient', 'evohomeclient',
'feedparser', 'feedparser',
'fuzzywuzzy',
'gTTS-token', 'gTTS-token',
'ha-ffmpeg', 'ha-ffmpeg',
'haversine', 'haversine',

View File

@ -6,6 +6,7 @@ import pytest
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.components import conversation from homeassistant.components import conversation
import homeassistant.components as component
from homeassistant.helpers import intent from homeassistant.helpers import intent
from tests.common import async_mock_intent, async_mock_service 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.""" """Test calling an intent from a conversation."""
intents = async_mock_intent(hass, 'OrderBeer') intents = async_mock_intent(hass, 'OrderBeer')
result = yield from component.async_setup(hass, {})
assert result
result = yield from async_setup_component(hass, 'conversation', { result = yield from async_setup_component(hass, 'conversation', {
'conversation': { 'conversation': {
'intents': { 'intents': {
@ -145,6 +149,9 @@ def test_http_processing_intent(hass, test_client):
@pytest.mark.parametrize('sentence', ('turn on kitchen', 'turn kitchen on')) @pytest.mark.parametrize('sentence', ('turn on kitchen', 'turn kitchen on'))
def test_turn_on_intent(hass, sentence): def test_turn_on_intent(hass, sentence):
"""Test calling the turn on intent.""" """Test calling the turn on intent."""
result = yield from component.async_setup(hass, {})
assert result
result = yield from async_setup_component(hass, 'conversation', {}) result = yield from async_setup_component(hass, 'conversation', {})
assert result assert result
@ -168,6 +175,9 @@ def test_turn_on_intent(hass, sentence):
@pytest.mark.parametrize('sentence', ('turn off kitchen', 'turn kitchen off')) @pytest.mark.parametrize('sentence', ('turn off kitchen', 'turn kitchen off'))
def test_turn_off_intent(hass, sentence): def test_turn_off_intent(hass, sentence):
"""Test calling the turn on intent.""" """Test calling the turn on intent."""
result = yield from component.async_setup(hass, {})
assert result
result = yield from async_setup_component(hass, 'conversation', {}) result = yield from async_setup_component(hass, 'conversation', {})
assert result assert result
@ -187,9 +197,38 @@ def test_turn_off_intent(hass, sentence):
assert call.data == {'entity_id': 'light.kitchen'} 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 @asyncio.coroutine
def test_http_api(hass, test_client): def test_http_api(hass, test_client):
"""Test the HTTP conversation API.""" """Test the HTTP conversation API."""
result = yield from component.async_setup(hass, {})
assert result
result = yield from async_setup_component(hass, 'conversation', {}) result = yield from async_setup_component(hass, 'conversation', {})
assert result assert result
@ -212,6 +251,9 @@ def test_http_api(hass, test_client):
@asyncio.coroutine @asyncio.coroutine
def test_http_api_wrong_data(hass, test_client): def test_http_api_wrong_data(hass, test_client):
"""Test the HTTP conversation API.""" """Test the HTTP conversation API."""
result = yield from component.async_setup(hass, {})
assert result
result = yield from async_setup_component(hass, 'conversation', {}) result = yield from async_setup_component(hass, 'conversation', {})
assert result assert result

View File

@ -11,6 +11,7 @@ from homeassistant import config
from homeassistant.const import ( from homeassistant.const import (
STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE) STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE)
import homeassistant.components as comps import homeassistant.components as comps
import homeassistant.helpers.intent as intent
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity from homeassistant.helpers import entity
from homeassistant.util.async import run_coroutine_threadsafe from homeassistant.util.async import run_coroutine_threadsafe
@ -195,3 +196,96 @@ class TestComponentsCore(unittest.TestCase):
self.hass.block_till_done() self.hass.block_till_done()
assert mock_check.called assert mock_check.called
assert not mock_stop.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']}