2017-07-22 04:38:53 +00:00
|
|
|
"""Module to coordinate user intentions."""
|
|
|
|
import logging
|
2018-02-11 17:33:19 +00:00
|
|
|
import re
|
2018-10-28 19:12:52 +00:00
|
|
|
from typing import Any, Callable, Dict, Iterable, Optional
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
import voluptuous as vol
|
|
|
|
|
2018-02-28 02:02:21 +00:00
|
|
|
from homeassistant.const import ATTR_SUPPORTED_FEATURES
|
2018-10-28 19:12:52 +00:00
|
|
|
from homeassistant.core import callback, State, T
|
2017-07-22 04:38:53 +00:00
|
|
|
from homeassistant.exceptions import HomeAssistantError
|
2018-02-11 17:33:19 +00:00
|
|
|
from homeassistant.helpers import config_validation as cv
|
2018-10-28 19:12:52 +00:00
|
|
|
from homeassistant.helpers.typing import HomeAssistantType
|
2017-10-08 15:17:54 +00:00
|
|
|
from homeassistant.loader import bind_hass
|
2018-02-11 17:33:19 +00:00
|
|
|
from homeassistant.const import ATTR_ENTITY_ID
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
2018-10-28 19:12:52 +00:00
|
|
|
_SlotsType = Dict[str, Any]
|
2017-07-22 04:38:53 +00:00
|
|
|
|
2018-02-11 17:33:19 +00:00
|
|
|
INTENT_TURN_OFF = 'HassTurnOff'
|
|
|
|
INTENT_TURN_ON = 'HassTurnOn'
|
|
|
|
INTENT_TOGGLE = 'HassToggle'
|
|
|
|
|
2017-07-22 04:38:53 +00:00
|
|
|
SLOT_SCHEMA = vol.Schema({
|
|
|
|
}, extra=vol.ALLOW_EXTRA)
|
|
|
|
|
2018-02-11 17:33:19 +00:00
|
|
|
DATA_KEY = 'intent'
|
|
|
|
|
2017-07-22 04:38:53 +00:00
|
|
|
SPEECH_TYPE_PLAIN = 'plain'
|
|
|
|
SPEECH_TYPE_SSML = 'ssml'
|
|
|
|
|
|
|
|
|
|
|
|
@callback
|
2017-10-08 15:17:54 +00:00
|
|
|
@bind_hass
|
2018-10-28 19:12:52 +00:00
|
|
|
def async_register(hass: HomeAssistantType, handler: 'IntentHandler') -> None:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Register an intent with Home Assistant."""
|
|
|
|
intents = hass.data.get(DATA_KEY)
|
|
|
|
if intents is None:
|
|
|
|
intents = hass.data[DATA_KEY] = {}
|
|
|
|
|
2018-02-28 02:02:21 +00:00
|
|
|
assert handler.intent_type is not None, 'intent_type cannot be None'
|
|
|
|
|
2017-07-22 04:38:53 +00:00
|
|
|
if handler.intent_type in intents:
|
|
|
|
_LOGGER.warning('Intent %s is being overwritten by %s.',
|
|
|
|
handler.intent_type, handler)
|
|
|
|
|
|
|
|
intents[handler.intent_type] = handler
|
|
|
|
|
|
|
|
|
2017-10-08 15:17:54 +00:00
|
|
|
@bind_hass
|
2018-10-28 19:12:52 +00:00
|
|
|
async def async_handle(hass: HomeAssistantType, platform: str,
|
|
|
|
intent_type: str, slots: Optional[_SlotsType] = None,
|
|
|
|
text_input: Optional[str] = None) -> 'IntentResponse':
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Handle an intent."""
|
2018-10-28 19:12:52 +00:00
|
|
|
handler = \
|
|
|
|
hass.data.get(DATA_KEY, {}).get(intent_type) # type: IntentHandler
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
if handler is None:
|
2018-01-07 00:39:32 +00:00
|
|
|
raise UnknownIntent('Unknown intent {}'.format(intent_type))
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
intent = Intent(hass, platform, intent_type, slots or {}, text_input)
|
|
|
|
|
|
|
|
try:
|
|
|
|
_LOGGER.info("Triggering intent handler %s", handler)
|
2018-02-25 11:38:46 +00:00
|
|
|
result = await handler.async_handle(intent)
|
2017-07-22 04:38:53 +00:00
|
|
|
return result
|
|
|
|
except vol.Invalid as err:
|
2018-02-28 02:02:21 +00:00
|
|
|
_LOGGER.warning('Received invalid slot info for %s: %s',
|
|
|
|
intent_type, err)
|
2018-01-07 00:39:32 +00:00
|
|
|
raise InvalidSlotInfo(
|
|
|
|
'Received invalid slot info for {}'.format(intent_type)) from err
|
2018-07-26 06:55:42 +00:00
|
|
|
# https://github.com/PyCQA/pylint/issues/2284
|
|
|
|
except IntentHandleError: # pylint: disable=try-except-raise
|
2018-02-28 02:02:21 +00:00
|
|
|
raise
|
2017-07-22 04:38:53 +00:00
|
|
|
except Exception as err:
|
2018-02-28 02:02:21 +00:00
|
|
|
raise IntentUnexpectedError(
|
2018-01-07 00:39:32 +00:00
|
|
|
'Error handling {}'.format(intent_type)) from err
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
|
|
|
|
class IntentError(HomeAssistantError):
|
|
|
|
"""Base class for intent related errors."""
|
|
|
|
|
|
|
|
|
|
|
|
class UnknownIntent(IntentError):
|
|
|
|
"""When the intent is not registered."""
|
|
|
|
|
|
|
|
|
|
|
|
class InvalidSlotInfo(IntentError):
|
|
|
|
"""When the slot data is invalid."""
|
|
|
|
|
|
|
|
|
|
|
|
class IntentHandleError(IntentError):
|
|
|
|
"""Error while handling intent."""
|
|
|
|
|
2018-02-28 02:02:21 +00:00
|
|
|
|
|
|
|
class IntentUnexpectedError(IntentError):
|
|
|
|
"""Unexpected error while handling intent."""
|
|
|
|
|
|
|
|
|
|
|
|
@callback
|
|
|
|
@bind_hass
|
2018-10-28 19:12:52 +00:00
|
|
|
def async_match_state(hass: HomeAssistantType, name: str,
|
|
|
|
states: Optional[Iterable[State]] = None) -> State:
|
2018-02-28 02:02:21 +00:00
|
|
|
"""Find a state that matches the name."""
|
|
|
|
if states is None:
|
|
|
|
states = hass.states.async_all()
|
|
|
|
|
2018-02-28 21:39:01 +00:00
|
|
|
state = _fuzzymatch(name, states, lambda state: state.name)
|
2018-02-28 02:02:21 +00:00
|
|
|
|
2018-02-28 21:39:01 +00:00
|
|
|
if state is None:
|
2018-03-01 15:35:12 +00:00
|
|
|
raise IntentHandleError(
|
|
|
|
'Unable to find an entity called {}'.format(name))
|
2018-02-28 02:02:21 +00:00
|
|
|
|
2018-02-28 21:39:01 +00:00
|
|
|
return state
|
2018-02-28 02:02:21 +00:00
|
|
|
|
|
|
|
|
|
|
|
@callback
|
2018-10-28 19:12:52 +00:00
|
|
|
def async_test_feature(state: State, feature: int, feature_name: str) -> None:
|
2018-02-28 02:02:21 +00:00
|
|
|
"""Test is state supports a feature."""
|
|
|
|
if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0:
|
|
|
|
raise IntentHandleError(
|
|
|
|
'Entity {} does not support {}'.format(
|
|
|
|
state.name, feature_name))
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
|
|
|
|
class IntentHandler:
|
|
|
|
"""Intent handler registration."""
|
|
|
|
|
2018-10-28 19:12:52 +00:00
|
|
|
intent_type = None # type: Optional[str]
|
|
|
|
slot_schema = None # type: Optional[vol.Schema]
|
2017-07-22 04:38:53 +00:00
|
|
|
_slot_schema = None
|
2018-10-28 19:12:52 +00:00
|
|
|
platforms = [] # type: Optional[Iterable[str]]
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
@callback
|
2018-10-28 19:12:52 +00:00
|
|
|
def async_can_handle(self, intent_obj: 'Intent') -> bool:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Test if an intent can be handled."""
|
|
|
|
return self.platforms is None or intent_obj.platform in self.platforms
|
|
|
|
|
|
|
|
@callback
|
2018-10-28 19:12:52 +00:00
|
|
|
def async_validate_slots(self, slots: _SlotsType) -> _SlotsType:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Validate slot information."""
|
|
|
|
if self.slot_schema is None:
|
|
|
|
return slots
|
|
|
|
|
|
|
|
if self._slot_schema is None:
|
|
|
|
self._slot_schema = vol.Schema({
|
|
|
|
key: SLOT_SCHEMA.extend({'value': validator})
|
2018-07-01 15:51:40 +00:00
|
|
|
for key, validator in self.slot_schema.items()},
|
|
|
|
extra=vol.ALLOW_EXTRA)
|
2017-07-22 04:38:53 +00:00
|
|
|
|
2018-10-28 19:12:52 +00:00
|
|
|
return self._slot_schema(slots) # type: ignore
|
2017-07-22 04:38:53 +00:00
|
|
|
|
2018-10-28 19:12:52 +00:00
|
|
|
async def async_handle(self, intent_obj: 'Intent') -> 'IntentResponse':
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Handle the intent."""
|
|
|
|
raise NotImplementedError()
|
|
|
|
|
2018-10-28 19:12:52 +00:00
|
|
|
def __repr__(self) -> str:
|
2018-01-21 06:35:38 +00:00
|
|
|
"""Represent a string of an intent handler."""
|
2017-07-22 04:38:53 +00:00
|
|
|
return '<{} - {}>'.format(self.__class__.__name__, self.intent_type)
|
|
|
|
|
|
|
|
|
2018-10-28 19:12:52 +00:00
|
|
|
def _fuzzymatch(name: str, items: Iterable[T], key: Callable[[T], str]) \
|
|
|
|
-> Optional[T]:
|
2018-02-11 17:33:19 +00:00
|
|
|
"""Fuzzy matching function."""
|
|
|
|
matches = []
|
|
|
|
pattern = '.*?'.join(name)
|
|
|
|
regex = re.compile(pattern, re.IGNORECASE)
|
2018-02-28 21:39:01 +00:00
|
|
|
for idx, item in enumerate(items):
|
2018-02-28 02:02:21 +00:00
|
|
|
match = regex.search(key(item))
|
2018-02-11 17:33:19 +00:00
|
|
|
if match:
|
2018-02-28 21:39:01 +00:00
|
|
|
# Add index so we pick first match in case same group and start
|
|
|
|
matches.append((len(match.group()), match.start(), idx, item))
|
2018-02-28 02:02:21 +00:00
|
|
|
|
2018-02-28 21:39:01 +00:00
|
|
|
return sorted(matches)[0][3] if matches else None
|
2018-02-11 17:33:19 +00:00
|
|
|
|
|
|
|
|
|
|
|
class ServiceIntentHandler(IntentHandler):
|
|
|
|
"""Service Intent handler registration.
|
|
|
|
|
|
|
|
Service specific intent handler that calls a service by name/entity_id.
|
|
|
|
"""
|
|
|
|
|
|
|
|
slot_schema = {
|
2018-02-28 02:02:21 +00:00
|
|
|
vol.Required('name'): cv.string,
|
2018-02-11 17:33:19 +00:00
|
|
|
}
|
|
|
|
|
2018-10-28 19:12:52 +00:00
|
|
|
def __init__(self, intent_type: str, domain: str, service: str,
|
|
|
|
speech: str) -> None:
|
2018-02-11 17:33:19 +00:00
|
|
|
"""Create Service Intent Handler."""
|
|
|
|
self.intent_type = intent_type
|
|
|
|
self.domain = domain
|
|
|
|
self.service = service
|
|
|
|
self.speech = speech
|
|
|
|
|
2018-10-28 19:12:52 +00:00
|
|
|
async def async_handle(self, intent_obj: 'Intent') -> 'IntentResponse':
|
2018-02-11 17:33:19 +00:00
|
|
|
"""Handle the hass intent."""
|
|
|
|
hass = intent_obj.hass
|
|
|
|
slots = self.async_validate_slots(intent_obj.slots)
|
2018-02-28 02:02:21 +00:00
|
|
|
state = async_match_state(hass, slots['name']['value'])
|
2018-02-11 17:33:19 +00:00
|
|
|
|
2018-02-28 02:02:21 +00:00
|
|
|
await hass.services.async_call(self.domain, self.service, {
|
|
|
|
ATTR_ENTITY_ID: state.entity_id
|
|
|
|
})
|
2018-02-11 17:33:19 +00:00
|
|
|
|
|
|
|
response = intent_obj.create_response()
|
2018-02-28 02:02:21 +00:00
|
|
|
response.async_set_speech(self.speech.format(state.name))
|
2018-02-11 17:33:19 +00:00
|
|
|
return response
|
|
|
|
|
|
|
|
|
2017-07-22 04:38:53 +00:00
|
|
|
class Intent:
|
|
|
|
"""Hold the intent."""
|
|
|
|
|
|
|
|
__slots__ = ['hass', 'platform', 'intent_type', 'slots', 'text_input']
|
|
|
|
|
2018-10-28 19:12:52 +00:00
|
|
|
def __init__(self, hass: HomeAssistantType, platform: str,
|
|
|
|
intent_type: str, slots: _SlotsType,
|
|
|
|
text_input: Optional[str]) -> None:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Initialize an intent."""
|
|
|
|
self.hass = hass
|
|
|
|
self.platform = platform
|
|
|
|
self.intent_type = intent_type
|
|
|
|
self.slots = slots
|
|
|
|
self.text_input = text_input
|
|
|
|
|
|
|
|
@callback
|
2018-10-28 19:12:52 +00:00
|
|
|
def create_response(self) -> 'IntentResponse':
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Create a response."""
|
|
|
|
return IntentResponse(self)
|
|
|
|
|
|
|
|
|
|
|
|
class IntentResponse:
|
|
|
|
"""Response to an intent."""
|
|
|
|
|
2018-10-28 19:12:52 +00:00
|
|
|
def __init__(self, intent: Optional[Intent] = None) -> None:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Initialize an IntentResponse."""
|
|
|
|
self.intent = intent
|
2018-10-28 19:12:52 +00:00
|
|
|
self.speech = {} # type: Dict[str, Dict[str, Any]]
|
|
|
|
self.card = {} # type: Dict[str, Dict[str, str]]
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
@callback
|
2018-10-28 19:12:52 +00:00
|
|
|
def async_set_speech(self, speech: str, speech_type: str = 'plain',
|
|
|
|
extra_data: Optional[Any] = None) -> None:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Set speech response."""
|
|
|
|
self.speech[speech_type] = {
|
|
|
|
'speech': speech,
|
|
|
|
'extra_data': extra_data,
|
|
|
|
}
|
|
|
|
|
|
|
|
@callback
|
2018-10-28 19:12:52 +00:00
|
|
|
def async_set_card(self, title: str, content: str,
|
|
|
|
card_type: str = 'simple') -> None:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Set speech response."""
|
|
|
|
self.card[card_type] = {
|
|
|
|
'title': title,
|
|
|
|
'content': content,
|
|
|
|
}
|
|
|
|
|
|
|
|
@callback
|
2018-10-28 19:12:52 +00:00
|
|
|
def as_dict(self) -> Dict[str, Dict[str, Dict[str, Any]]]:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Return a dictionary representation of an intent response."""
|
|
|
|
return {
|
|
|
|
'speech': self.speech,
|
|
|
|
'card': self.card,
|
|
|
|
}
|