diff --git a/mycroft/skills/common_iot_skill.py b/mycroft/skills/common_iot_skill.py index 25c923c0f4..96cbf8c8ca 100644 --- a/mycroft/skills/common_iot_skill.py +++ b/mycroft/skills/common_iot_skill.py @@ -19,8 +19,9 @@ # 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 +from functools import total_ordering, wraps from itertools import count from mycroft import MycroftSkill @@ -29,6 +30,7 @@ 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() @@ -57,8 +59,14 @@ class _BusKeys(): 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): """ @@ -81,13 +89,35 @@ class Thing(Enum): class Attribute(Enum): """ This class represents 'Attributes' of 'Things'. - - This may also grow to encompass states, e.g. - 'locked' or 'unlocked'. """ 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 @@ -106,6 +136,11 @@ class Action(Enum): 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 @@ -135,12 +170,14 @@ class IoTRequestVersion(Enum): 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(): @@ -151,11 +188,13 @@ class IoTRequest(): a user's request. The information is supplied as properties on the request. At present, those properties are: - action (see the Action enum above) - thing (see the Thing enum above) + action (see the Action enum) + thing (see the Thing enum) + state (see the State enum) + attribute (see the Attribute enum) + value entity scene - value The 'action' is mandatory, and will always be not None. The other fields may be None. @@ -186,7 +225,8 @@ class IoTRequest(): attribute: Attribute = None, entity: str = None, scene: str = None, - value: int = None): + value: int = None, + state: State = None): if not thing and not entity and not scene: raise Exception("At least one of thing," @@ -198,6 +238,7 @@ class IoTRequest(): self.entity = entity self.scene = scene self.value = value + self.state = state def __repr__(self): template = ('IoTRequest(' @@ -206,19 +247,26 @@ class IoTRequest(): ' attribute={attribute},' ' entity={entity},' ' scene={scene},' - ' value={value}' + ' 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='"{}"'.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 + 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 @@ -230,7 +278,8 @@ class IoTRequest(): 'attribute': self.attribute.name if self.attribute else None, 'entity': self.entity, 'scene': self.scene, - 'value': self.value + 'value': self.value, + 'state': self.state.name if self.state else None } @classmethod @@ -241,10 +290,39 @@ class IoTRequest(): 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 @@ -270,6 +348,11 @@ class CommonIoTSkill(MycroftSkill, ABC): 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. @@ -277,6 +360,9 @@ class CommonIoTSkill(MycroftSkill, ABC): 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: """ @@ -287,6 +373,17 @@ class CommonIoTSkill(MycroftSkill, ABC): 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 @@ -308,6 +405,7 @@ class CommonIoTSkill(MycroftSkill, ABC): "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 @@ -321,6 +419,18 @@ class CommonIoTSkill(MycroftSkill, ABC): 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.