Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
eedb3d0
Moved TurnOn/Off Intents to component
todschmidt Feb 3, 2018
bf015c9
Removed unused import
todschmidt Feb 3, 2018
b3d7bf6
Lint fix which my local runs dont catch apparently...
todschmidt Feb 3, 2018
8651566
Moved hass intent code into intent
todschmidt Feb 4, 2018
c842be2
Added test for toggle to conversation.
todschmidt Feb 4, 2018
04b407a
Fixed toggle tests
todschmidt Feb 4, 2018
c479075
Update intent.py
todschmidt Feb 4, 2018
13d0d9a
Added homeassistant.helpers to gen_requirements script.
todschmidt Feb 5, 2018
ac5d2e5
Merge branch 'intent-registry-2' of github.com:tschmidty69/home-assis…
todschmidt Feb 5, 2018
2dcfe18
Update intent.py
todschmidt Feb 5, 2018
861cdc5
Update intent.py
todschmidt Feb 5, 2018
2214096
Changed return value for _match_entity
todschmidt Feb 5, 2018
90280d7
Moved consts and requirements
todschmidt Feb 6, 2018
f287689
Removed unused import
todschmidt Feb 6, 2018
7992a73
Removed http view
todschmidt Feb 6, 2018
5e987e8
Removed http import
todschmidt Feb 6, 2018
5496efd
Removed fuzzywuzzy dependency
todschmidt Feb 7, 2018
cb8ab73
woof
todschmidt Feb 7, 2018
2d9becd
A few cleanups
todschmidt Feb 8, 2018
0d955d3
Added domain filtering to entities
todschmidt Feb 8, 2018
1262ba3
Clarified class doc string
todschmidt Feb 8, 2018
ed1bcf2
Added doc string
todschmidt Feb 8, 2018
6cfce83
Added test in test_init
todschmidt Feb 9, 2018
95d30c6
woof
todschmidt Feb 9, 2018
42273fc
Cleanup entity matching
todschmidt Feb 10, 2018
a43bc92
Update intent.py
todschmidt Feb 10, 2018
b2ad2f9
removed uneeded setup from tests
todschmidt Feb 11, 2018
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
7 changes: 7 additions & 0 deletions homeassistant/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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,
Expand Down Expand Up @@ -154,6 +155,12 @@ def async_handle_turn_service(service):
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):
Expand Down
96 changes: 7 additions & 89 deletions homeassistant/components/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,15 @@
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__)

Expand All @@ -28,9 +24,6 @@
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(''))

Expand All @@ -50,7 +43,7 @@
@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.
Expand All @@ -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')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should add this back where we import fuzzywuzzy. I remember it spamming a lot.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I see you've removed fuzzywuzzy, ok too 👍


config = config.get(DOMAIN, {})
intents = hass.data.get(DOMAIN)

Expand All @@ -102,12 +93,12 @@ def process(service):

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

Expand Down Expand Up @@ -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."""

Expand Down
79 changes: 76 additions & 3 deletions homeassistant/helpers/intent.py
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -117,6 +124,72 @@ def __repr__(self):
return '<{} - {}>'.format(self.__class__.__name__, self.intent_type)


def fuzzymatch(name, entities):
"""Fuzzy matching function."""
matches = []
pattern = '.*?'.join(name)
regex = re.compile(pattern)
for entity in entities:
match = regex.search(entity)
if match:
matches.append((len(match.group()), match.start(), entity))
return [x for _, _, x in sorted(matches)]


class ServiceIntentHandler(IntentHandler):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you specify in both the name and doc of this class that it is only working for services that call entities and that it needs a name slot that will be mapped to entity_id in the service

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated. I think having the slot schema in the class also makes it clearer.

"""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()}
entity_name = name.replace(' ', '_').lower()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment didn't stick, but sine we convert spaces to _ in friendly names this is needed to match and entity names will never contain a space. But this is better done in the matching code.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We match against entity name and not id right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmm, should be both since that code should create a dict of id: name but apparently does not for whatever reason and is just creating a list of entity_ids. Flipped it to match on name.

entity_name = entity_name.replace('the_', '')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please add a comment why this is? Also, it seems very English focused

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

True, and again with the above this may no longer be necessary but then again it might, but this logic should be moved into that function. But a typical utterance is 'Turn on the living room lights' so we get 'the living room lights' for the entity name. Not that we are trying to match everything but it seemed a common enough use. But I can remove it since there is no need to make it too specific and turn on livign room lights is fine.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This sounds like an issue that should be fixed by things launching intents?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, makes sense that it would be done by extending utterances instead.


matches = fuzzymatch(entity_name, entities)
entity_id = next((x for x in matches if self.domain in x), None)

@balloob balloob Feb 9, 2018

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What's with this check? Isn't domain for now always homeassistant and we don't have those entities?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Well my plan is to add things like covers and media players, so an Intent like HassOpenCover declares it's domain we then match against cover.garage_door and not switch.garage_door or sensor.garage_door.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think that it's:

  • out of scope for the current implementation (since it only works with homeassistant
  • Filter should be applied when generating the dictionary of potential entities to match instead of filtering it after matching.
  • A "limit matching to domain" should be an optional boolean passed into the constructor

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Why would we add another passed variable instead of just 'if domain not Homeassistant'? And I don't think it's out of scope since the whole intention of this is to lay some groundwork for adding other intents. Otherwise all of this has just been rearranging things with zero added value beyond cleanup.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you 100% guarantee that you can always derive the filter from the service domain? Because if you can't, an extra variable is necessary.

A cleanup so that you can start building on top is perfectly fine to be in a single pr. If you're adding functionality you need to use it and you need to test it. Neither is happening.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The best filter would be to filter by entites that support the given domain.service

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct. However, one thing is that Home Assistant core should not be aware of the different components. So if we would want something like this, we would have to make it part of service registration, and then the service registry needs to know about which attribute should map to the entity… it's a bit too much. So I think that passing the right filter at the moment we define the intent is great, that way the logic remains inside the components. (light intent will specify light filter, etc)

if not entity_id:
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 (%s)", name,
entity_name)
return response

yield from hass.services.async_call(
self.domain, self.service, {
ATTR_ENTITY_ID: entity_id
}, blocking=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's remove blocking, it has been causing trouble for Alexa/Google Assistant as turning on can sometimes take a long time and we don't actually know if a service is successful.


response.async_set_speech(
self.speech.format(name))
return response


class Intent:
"""Hold the intent."""

Expand Down
3 changes: 0 additions & 3 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,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

Expand Down
3 changes: 0 additions & 3 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion script/gen_requirements_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
'ephem',
'evohomeclient',
'feedparser',
'fuzzywuzzy',
'gTTS-token',
'ha-ffmpeg',
'haversine',
Expand Down
Loading