mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 05:37:44 +00:00
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:
parent
678f284015
commit
26209de2f2
@ -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):
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -46,7 +46,6 @@ TEST_REQUIREMENTS = (
|
||||
'ephem',
|
||||
'evohomeclient',
|
||||
'feedparser',
|
||||
'fuzzywuzzy',
|
||||
'gTTS-token',
|
||||
'ha-ffmpeg',
|
||||
'haversine',
|
||||
|
@ -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
|
||||
|
||||
|
@ -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']}
|
||||
|
Loading…
x
Reference in New Issue
Block a user