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

View File

@ -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<name>(?: |\w)+) (?P<command>\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."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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