565 lines
19 KiB
Python
565 lines
19 KiB
Python
# Copyright 2019 Mycroft AI Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
|
|
# THE CLASSES IN THIS FILE ARE STILL EXPERIMENTAL, AND ARE SUBJECT TO
|
|
# CHANGES. IT IS PROVIDED NOW AS A PREVIEW, SO SKILL AUTHORS CAN GET
|
|
# AN IDEA OF WHAT IS TO COME. YOU ARE FREE TO BEGIN EXPERIMENTING, BUT
|
|
# BE WARNED THAT THE CLASSES, FUNCTIONS, ETC MAY CHANGE WITHOUT WARNING.
|
|
|
|
from abc import ABC, abstractmethod
|
|
from contextlib import contextmanager
|
|
from enum import Enum, unique
|
|
from functools import total_ordering, wraps
|
|
from itertools import count
|
|
|
|
from .mycroft_skill import MycroftSkill
|
|
from mycroft.messagebus.message import Message
|
|
|
|
|
|
ENTITY = "ENTITY"
|
|
SCENE = "SCENE"
|
|
IOT_REQUEST_ID = "iot_request_id" # TODO make the id a property of the request
|
|
|
|
|
|
_counter = count()
|
|
|
|
|
|
def auto():
|
|
"""
|
|
Indefinitely return the next number in sequence from 0.
|
|
|
|
This can be replaced with enum.auto when we no longer
|
|
need to support python3.4.
|
|
"""
|
|
return next(_counter)
|
|
|
|
|
|
class _BusKeys():
|
|
"""
|
|
This class contains some strings used to identify
|
|
messages on the messagebus. They are used in in
|
|
CommonIoTSkill and the IoTController skill, but
|
|
are not intended to be used elsewhere.
|
|
"""
|
|
BASE = "iot"
|
|
TRIGGER = BASE + ":trigger"
|
|
RESPONSE = TRIGGER + ".response"
|
|
RUN = BASE + ":run." # Will have skill_id appened
|
|
REGISTER = BASE + "register"
|
|
CALL_FOR_REGISTRATION = REGISTER + ".request"
|
|
SPEAK = BASE + ":speak"
|
|
|
|
|
|
####################################################################
|
|
# When adding a new Thing, Attribute, etc, be sure to also add the #
|
|
# corresponding voc files to the skill-iot-control. #
|
|
####################################################################
|
|
|
|
@unique
|
|
class Thing(Enum):
|
|
"""
|
|
This class represents 'Things' which may be controlled
|
|
by IoT Skills. This is intended to be used with the
|
|
IoTRequest class. See that class for more details.
|
|
"""
|
|
LIGHT = auto()
|
|
THERMOSTAT = auto()
|
|
DOOR = auto()
|
|
LOCK = auto()
|
|
PLUG = auto()
|
|
SWITCH = auto()
|
|
TEMPERATURE = auto() # Control desired high and low temperatures
|
|
HEAT = auto() # Control desired low temperature
|
|
AIR_CONDITIONING = auto() # Control desired high temperature
|
|
|
|
|
|
@unique
|
|
class Attribute(Enum):
|
|
"""
|
|
This class represents 'Attributes' of 'Things'.
|
|
"""
|
|
BRIGHTNESS = auto()
|
|
COLOR = auto()
|
|
COLOR_TEMPERATURE = auto()
|
|
TEMPERATURE = auto()
|
|
|
|
|
|
@unique
|
|
class State(Enum):
|
|
"""
|
|
This class represents 'States' of 'Things'.
|
|
|
|
These are generally intended to handle binary
|
|
queries, such as "is the door locked?" or
|
|
"is the heat on?" where 'locked' and 'on'
|
|
are the state values. The special value
|
|
'STATE' can be used for more general queries
|
|
capable of providing more detailed in formation,
|
|
for example, "what is the state of the lamp?"
|
|
could produce state information that includes
|
|
brightness or color.
|
|
"""
|
|
STATE = auto()
|
|
POWERED = auto()
|
|
UNPOWERED = auto()
|
|
LOCKED = auto()
|
|
UNLOCKED = auto()
|
|
OCCUPIED = auto()
|
|
UNOCCUPIED = auto()
|
|
|
|
|
|
@unique
|
|
class Action(Enum):
|
|
"""
|
|
This class represents 'Actions' that can be applied to
|
|
'Things,' e.d. a LIGHT can be turned ON. It is intended
|
|
to be used with the IoTRequest class. See that class
|
|
for more details.
|
|
"""
|
|
ON = auto()
|
|
OFF = auto()
|
|
TOGGLE = auto()
|
|
ADJUST = auto()
|
|
SET = auto()
|
|
INCREASE = auto()
|
|
DECREASE = auto()
|
|
TRIGGER = auto()
|
|
BINARY_QUERY = auto() # yes/no answer
|
|
INFORMATION_QUERY = auto() # detailed answer
|
|
LOCATE = auto()
|
|
LOCK = auto()
|
|
UNLOCK = auto()
|
|
|
|
|
|
@total_ordering
|
|
class IoTRequestVersion(Enum):
|
|
"""
|
|
Enum indicating support IoTRequest fields
|
|
|
|
This class allows us to extend the request without
|
|
requiring that all existing skills are updated to
|
|
handle the new fields. Skills will simply not respond
|
|
to requests that contain fields they are not aware of.
|
|
|
|
CommonIoTSkill subclasses should override
|
|
CommonIoTSkill.supported_request_version to indicate
|
|
their level of support. For backward compatibility,
|
|
the default is V1.
|
|
|
|
Note that this is an attempt to avoid false positive
|
|
matches (i.e. prevent skills from reporting that they
|
|
can handle a request that contains fields they don't
|
|
know anything about). To avoid any possibility of
|
|
false negatives, however, skills should always try to
|
|
support the latest version.
|
|
|
|
Version to supported fields (provided only for reference - always use the
|
|
latest version available, and account for all fields):
|
|
|
|
V1 = {'action', 'thing', 'attribute', 'entity', 'scene'}
|
|
V2 = V1 | {'value'}
|
|
V3 = V2 | {'state'}
|
|
"""
|
|
def __lt__(self, other):
|
|
return self.name < other.name
|
|
|
|
V1 = {'action', 'thing', 'attribute', 'entity', 'scene'}
|
|
V2 = V1 | {'value'}
|
|
V3 = V2 | {'state'}
|
|
|
|
|
|
class IoTRequest():
|
|
"""
|
|
This class represents a request from a user to control
|
|
an IoT device. It contains all of the information an IoT
|
|
skill should need in order to determine if it can handle
|
|
a user's request. The information is supplied as properties
|
|
on the request. At present, those properties are:
|
|
|
|
action (see the Action enum)
|
|
thing (see the Thing enum)
|
|
state (see the State enum)
|
|
attribute (see the Attribute enum)
|
|
value
|
|
entity
|
|
scene
|
|
|
|
The 'action' is mandatory, and will always be not None. The
|
|
other fields may be None.
|
|
|
|
The 'entity' is intended to be used for user-defined values
|
|
specific to a skill. For example, in a skill controlling Lights,
|
|
an 'entity' might represent a group of lights. For a smart-lock
|
|
skill, it might represent a specific lock, e.g. 'front door.'
|
|
|
|
The 'scene' value is also intended to to be used for user-defined
|
|
values. Skills that extend CommonIotSkill are expected to register
|
|
their own scenes. The controller skill will have the ability to
|
|
trigger multiple skills, so common scene names may trigger many
|
|
skills, for a coherent experience.
|
|
|
|
The 'value' property will be a number value. This is intended to
|
|
be used for requests such as "set the heat to 70 degrees" and
|
|
"set the lights to 50% brightness."
|
|
|
|
Skills that extend CommonIotSkill will be expected to register
|
|
their own entities. See the documentation in CommonIotSkill for
|
|
more details.
|
|
"""
|
|
|
|
def __init__(self,
|
|
action: Action,
|
|
thing: Thing = None,
|
|
attribute: Attribute = None,
|
|
entity: str = None,
|
|
scene: str = None,
|
|
value: int = None,
|
|
state: State = None):
|
|
|
|
if not thing and not entity and not scene:
|
|
raise Exception("At least one of thing,"
|
|
" entity, or scene must be present!")
|
|
|
|
self.action = action
|
|
self.thing = thing
|
|
self.attribute = attribute
|
|
self.entity = entity
|
|
self.scene = scene
|
|
self.value = value
|
|
self.state = state
|
|
|
|
def __repr__(self):
|
|
template = ('IoTRequest('
|
|
'action={action},'
|
|
' thing={thing},'
|
|
' attribute={attribute},'
|
|
' entity={entity},'
|
|
' scene={scene},'
|
|
' value={value},'
|
|
' state={state}'
|
|
')')
|
|
entity = '"{}"'.format(self.entity) if self.entity else None
|
|
scene = '"{}"'.format(self.scene) if self.scene else None
|
|
value = '"{}"'.format(self.value) if self.value is not None else None
|
|
return template.format(
|
|
action=self.action,
|
|
thing=self.thing,
|
|
attribute=self.attribute,
|
|
entity=entity,
|
|
scene=scene,
|
|
value=value,
|
|
state=self.state
|
|
)
|
|
|
|
@property
|
|
def version(self):
|
|
if self.state is not None:
|
|
return IoTRequestVersion.V3
|
|
if self.value is not None:
|
|
return IoTRequestVersion.V2
|
|
return IoTRequestVersion.V1
|
|
|
|
def to_dict(self):
|
|
return {
|
|
'action': self.action.name,
|
|
'thing': self.thing.name if self.thing else None,
|
|
'attribute': self.attribute.name if self.attribute else None,
|
|
'entity': self.entity,
|
|
'scene': self.scene,
|
|
'value': self.value,
|
|
'state': self.state.name if self.state else None
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict):
|
|
data = data.copy()
|
|
data['action'] = Action[data['action']]
|
|
if data.get('thing') not in (None, ''):
|
|
data['thing'] = Thing[data['thing']]
|
|
if data.get('attribute') not in (None, ''):
|
|
data['attribute'] = Attribute[data['attribute']]
|
|
if data.get('state') not in (None, ''):
|
|
data['state'] = State[data['state']]
|
|
|
|
return cls(**data)
|
|
|
|
|
|
def _track_request(func):
|
|
"""
|
|
Used within the CommonIoT skill to track IoT requests.
|
|
|
|
The primary purpose of tracking the reqeust is determining
|
|
if the skill is currently handling an IoT request, or is
|
|
running a standard intent. While running IoT requests, certain
|
|
methods defined on MycroftSkill should behave differently than
|
|
under normal circumstances. In particular, speech related methods
|
|
should not actually trigger speech, but instead pass the message
|
|
to the IoT control skill, which will handle deconfliction (in the
|
|
event multiple skills want to respond verbally to the same request).
|
|
|
|
Args:
|
|
func: Callable
|
|
|
|
Returns:
|
|
Callable
|
|
|
|
"""
|
|
@wraps(func)
|
|
def tracking_function(self, message: Message):
|
|
with self._current_request(message.data.get(IOT_REQUEST_ID)):
|
|
func(self, message)
|
|
return tracking_function
|
|
|
|
|
|
class CommonIoTSkill(MycroftSkill, ABC):
|
|
"""
|
|
Skills that want to work with the CommonIoT system should
|
|
extend this class. Subclasses will be expected to implement
|
|
two methods, `can_handle` and `run_request`. See the
|
|
documentation for those functions for more details on how
|
|
they are expected to behave.
|
|
|
|
Subclasses may also register their own entities and scenes.
|
|
See the register_entities and register_scenes methods for
|
|
details.
|
|
|
|
This class works in conjunction with a controller skill.
|
|
The controller registers vocabulary and intents to capture
|
|
IoT related requests. It then emits messages on the messagebus
|
|
that will be picked up by all skills that extend this class.
|
|
Each skill will have the opportunity to declare whether or not
|
|
it can handle the given request. Skills that acknowledge that
|
|
they are capable of handling the request will be considered
|
|
candidates, and after a short timeout, a winner, or winners,
|
|
will be chosen. With this setup, a user can have several IoT
|
|
systems, and control them all without worry that skills will
|
|
step on each other.
|
|
"""
|
|
|
|
@wraps(MycroftSkill.__init__)
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._current_iot_request = None
|
|
|
|
def bind(self, bus):
|
|
"""
|
|
Overrides MycroftSkill.bind.
|
|
|
|
This is called automatically during setup, and
|
|
need not otherwise be used.
|
|
|
|
Subclasses that override this method must call this
|
|
via super in their implementation.
|
|
|
|
Args:
|
|
bus:
|
|
"""
|
|
if bus:
|
|
super().bind(bus)
|
|
self.add_event(_BusKeys.TRIGGER, self._handle_trigger)
|
|
self.add_event(_BusKeys.RUN + self.skill_id, self._run_request)
|
|
self.add_event(_BusKeys.CALL_FOR_REGISTRATION,
|
|
self._handle_call_for_registration)
|
|
|
|
@contextmanager
|
|
def _current_request(self, id: str):
|
|
# Multiple simultaneous requests may interfere with each other as they
|
|
# would overwrite this value, however, this seems unlikely to cause
|
|
# any real world issues and tracking multiple requests seems as
|
|
# likely to cause issues as to solve them.
|
|
self._current_iot_request = id
|
|
yield id
|
|
self._current_iot_request = None
|
|
|
|
@_track_request
|
|
def _handle_trigger(self, message: Message):
|
|
"""
|
|
Given a message, determines if this skill can
|
|
handle the request. If it can, it will emit
|
|
a message on the bus indicating that.
|
|
|
|
Args:
|
|
message: Message
|
|
"""
|
|
data = message.data
|
|
request = IoTRequest.from_dict(data[IoTRequest.__name__])
|
|
|
|
if request.version > self.supported_request_version:
|
|
return
|
|
|
|
can_handle, callback_data = self.can_handle(request)
|
|
if can_handle:
|
|
data.update({"skill_id": self.skill_id,
|
|
"callback_data": callback_data})
|
|
self.bus.emit(message.response(data))
|
|
|
|
@_track_request
|
|
def _run_request(self, message: Message):
|
|
"""
|
|
Given a message, extracts the IoTRequest and
|
|
callback_data and sends them to the run_request
|
|
method.
|
|
|
|
Args:
|
|
message: Message
|
|
"""
|
|
request = IoTRequest.from_dict(message.data[IoTRequest.__name__])
|
|
callback_data = message.data["callback_data"]
|
|
self.run_request(request, callback_data)
|
|
|
|
def speak(self, utterance, *args, **kwargs):
|
|
if self._current_iot_request:
|
|
self.bus.emit(Message(_BusKeys.SPEAK,
|
|
data={"skill_id": self.skill_id,
|
|
IOT_REQUEST_ID:
|
|
self._current_iot_request,
|
|
"speak_args": args,
|
|
"speak_kwargs": kwargs,
|
|
"speak": utterance}))
|
|
else:
|
|
super().speak(utterance, *args, **kwargs)
|
|
|
|
def _handle_call_for_registration(self, _: Message):
|
|
"""
|
|
Register this skill's scenes and entities when requested.
|
|
|
|
Args:
|
|
_: Message. This is ignored.
|
|
"""
|
|
self.register_entities_and_scenes()
|
|
|
|
def _register_words(self, words: [str], word_type: str):
|
|
"""
|
|
Emit a message to the controller skill to register vocab.
|
|
|
|
Emits a message on the bus containing the type and
|
|
the words. The message will be picked up by the
|
|
controller skill, and the vocabulary will be registered
|
|
to that skill.
|
|
|
|
Args:
|
|
words:
|
|
word_type:
|
|
"""
|
|
if words:
|
|
self.bus.emit(Message(_BusKeys.REGISTER,
|
|
data={"skill_id": self.skill_id,
|
|
"type": word_type,
|
|
"words": list(words)}))
|
|
|
|
def register_entities_and_scenes(self):
|
|
"""
|
|
This method will register this skill's scenes and entities.
|
|
|
|
This should be called in the skill's `initialize` method,
|
|
at some point after `get_entities` and `get_scenes` can
|
|
be expected to return correct results.
|
|
|
|
"""
|
|
self._register_words(self.get_entities(), ENTITY)
|
|
self._register_words(self.get_scenes(), SCENE)
|
|
|
|
@property
|
|
def supported_request_version(self) -> IoTRequestVersion:
|
|
"""
|
|
Get the supported IoTRequestVersion
|
|
|
|
By default, this returns IoTRequestVersion.V1. Subclasses
|
|
should override this to indicate higher levels of support.
|
|
|
|
The documentation for IoTRequestVersion provides a reference
|
|
indicating which fields are included in each version. Note
|
|
that you should always take the latest, and account for all
|
|
request fields.
|
|
"""
|
|
return IoTRequestVersion.V1
|
|
|
|
def get_entities(self) -> [str]:
|
|
"""
|
|
Get a list of custom entities.
|
|
|
|
This is intended to be overridden by subclasses, though it
|
|
it not required (the default implementation will return an
|
|
empty list).
|
|
|
|
The strings returned by this function will be registered
|
|
as ENTITY values with the intent parser. Skills should provide
|
|
group names, user aliases for specific devices, or anything
|
|
else that might represent a THING or a set of THINGs, e.g.
|
|
'bedroom', 'lamp', 'front door.' This allows commands that
|
|
don't explicitly include a THING to still be handled, e.g.
|
|
"bedroom off" as opposed to "bedroom lights off."
|
|
"""
|
|
return []
|
|
|
|
def get_scenes(self) -> [str]:
|
|
"""
|
|
Get a list of custom scenes.
|
|
|
|
This method is intended to be overridden by subclasses, though
|
|
it is not required. The strings returned by this function will
|
|
be registered as SCENE values with the intent parser. Skills
|
|
should provide user defined scene names that they are aware of
|
|
and capable of handling, e.g. "relax," "movie time," etc.
|
|
"""
|
|
return []
|
|
|
|
@abstractmethod
|
|
def can_handle(self, request: IoTRequest):
|
|
"""
|
|
Determine if an IoTRequest can be handled by this skill.
|
|
|
|
This method must be implemented by all subclasses.
|
|
|
|
An IoTRequest contains several properties (see the
|
|
documentation for that class). This method should return
|
|
True if and only if this skill can take the appropriate
|
|
'action' when considering _all other properties
|
|
of the request_. In other words, a partial match, one in which
|
|
any piece of the IoTRequest is not known to this skill,
|
|
and is not None, this should return (False, None).
|
|
|
|
Args:
|
|
request: IoTRequest
|
|
|
|
Returns: (boolean, dict)
|
|
True if and only if this skill knows about all the
|
|
properties set on the IoTRequest, and a dict containing
|
|
callback_data. If this skill is chosen to handle the
|
|
request, this dict will be supplied to `run_request`.
|
|
|
|
Note that the dictionary will be sent over the bus, and thus
|
|
must be JSON serializable.
|
|
"""
|
|
return False, None
|
|
|
|
@abstractmethod
|
|
def run_request(self, request: IoTRequest, callback_data: dict):
|
|
"""
|
|
Handle an IoT Request.
|
|
|
|
All subclasses must implement this method.
|
|
|
|
When this skill is chosen as a winner, this function will be called.
|
|
It will be passed an IoTRequest equivalent to the one that was
|
|
supplied to `can_handle`, as well as the `callback_data` returned by
|
|
`can_handle`.
|
|
|
|
Args:
|
|
request: IoTRequest
|
|
callback_data: dict
|
|
"""
|
|
pass
|