# 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, dig_for_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: message = dig_for_message() self.bus.emit(message.forward(_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