From e0876d93fbb5f8d1677befad84962ca8fbdc6216 Mon Sep 17 00:00:00 2001 From: Ian Young <ian@duffrecords.com> Date: Mon, 10 Sep 2018 03:18:40 -0700 Subject: [PATCH 01/10] working demo of Gadgets Skill API --- flask_ask/__init__.py | 5 +- flask_ask/core.py | 50 ++++-- flask_ask/models.py | 217 ++++++++++++++++++++++++-- samples/gadget/button_demo/buttons.py | 155 ++++++++++++++++++ 4 files changed, 401 insertions(+), 26 deletions(-) create mode 100644 samples/gadget/button_demo/buttons.py diff --git a/flask_ask/__init__.py b/flask_ask/__init__.py index d878d12..5a29f8f 100644 --- a/flask_ask/__init__.py +++ b/flask_ask/__init__.py @@ -26,5 +26,8 @@ confirm_intent, buy, upsell, - refund + refund, + gadget, + animation, + animation_step ) diff --git a/flask_ask/core.py b/flask_ask/core.py index bf006c1..ed421eb 100644 --- a/flask_ask/core.py +++ b/flask_ask/core.py @@ -135,7 +135,7 @@ def init_app(self, app, path='templates.yaml'): raise TypeError("route is a required argument when app is not None") self.app = app - + app.ask = self app.add_url_rule(self._route, view_func=self._flask_view_func, methods=['POST']) @@ -300,13 +300,13 @@ def wrapper(*args, **kw): def on_purchase_completed(self, mapping={'payload': 'payload','name':'name','status':'status','token':'token'}, convert={}, default={}): """Decorator routes an Connections.Response to the wrapped function. - Request is sent when Alexa completes the purchase flow. - See https://developer.amazon.com/docs/in-skill-purchase/add-isps-to-a-skill.html#handle-results + Request is sent when Alexa completes the purchase flow. + See https://developer.amazon.com/docs/in-skill-purchase/add-isps-to-a-skill.html#handle-results The wrapped view function may accept parameters from the Request. In addition to locale, requestId, timestamp, and type - + @ask.on_purchase_completed( mapping={'payload': 'payload','name':'name','status':'status','token':'token'}) def completed(payload, name, status, token): @@ -314,7 +314,7 @@ def completed(payload, name, status, token): logger.info(name) logger.info(status) logger.info(token) - + """ def decorator(f): self._intent_view_funcs['Connections.Response'] = f @@ -522,6 +522,19 @@ def wrapper(*args, **kwargs): return f return decorator + def on_input_handler_event(self, mapping={}, convert={}, default={}): + def decorator(f): + self._intent_view_funcs['GameEngine.InputHandlerEvent'] = f + self._intent_mappings['GameEngine.InputHandlerEvent'] = mapping + self._intent_converts['GameEngine.InputHandlerEvent'] = convert + self._intent_defaults['GameEngine.InputHandlerEvent'] = default + + @wraps(f) + def wrapper(*args, **kwargs): + self._flask_view_func(*args, **kwargs) + return f + return decorator + @property def request(self): return getattr(_app_ctx_stack.top, '_ask_request', None) @@ -643,9 +656,9 @@ def unicode_to_wsgi(u): body = json.dumps(event) environ['CONTENT_TYPE'] = 'application/json' environ['CONTENT_LENGTH'] = len(body) - + PY3 = sys.version_info[0] == 3 - + if PY3: environ['wsgi.input'] = io.StringIO(body) else: @@ -812,7 +825,8 @@ def _flask_view_func(self, *args, **kwargs): # user can also access state of content.AudioPlayer with current_stream elif 'Connections.Response' in request_type: result = self._map_purchase_request_to_func(self.request.type)() - + elif 'GameEngine' in request_type: + result = self._map_gadget_request_to_func(self.request.type)() if result is not None: if isinstance(result, models._Response): return result.render_response() @@ -833,7 +847,7 @@ def _map_intent_to_view_func(self, intent): argspec = inspect.getfullargspec(view_func) else: argspec = inspect.getargspec(view_func) - + arg_names = argspec.args arg_values = self._map_params_to_view_args(intent.name, arg_names) @@ -852,11 +866,11 @@ def _map_player_request_to_func(self, player_request_type): def _map_purchase_request_to_func(self, purchase_request_type): """Provides appropriate parameters to the on_purchase functions.""" - + if purchase_request_type in self._intent_view_funcs: view_func = self._intent_view_funcs[purchase_request_type] else: - raise NotImplementedError('Request type "{}" not found and no default view specified.'.format(purchase_request_type)) + raise NotImplementedError('Request type "{}" not found and no default view specified.'.format(purchase_request_type)) argspec = inspect.getargspec(view_func) arg_names = argspec.args @@ -865,6 +879,20 @@ def _map_purchase_request_to_func(self, purchase_request_type): print('_map_purchase_request_to_func', arg_names, arg_values, view_func, purchase_request_type) return partial(view_func, *arg_values) + def _map_gadget_request_to_func(self, gadget_request_type): + """Provides appropriate parameters to the on_input_handler_event function.""" + + if gadget_request_type in self._intent_view_funcs: + view_func = self._intent_view_funcs[gadget_request_type] + else: + raise NotImplementedError('Request type "{}" not found and no default view specified.'.format(gadget_request_type)) + + argspec = inspect.getargspec(view_func) + arg_names = argspec.args + arg_values = self._map_params_to_view_args(gadget_request_type, arg_names) + + return partial(view_func, *arg_values) + def _get_slot_value(self, slot_object): slot_name = slot_object.name slot_value = getattr(slot_object, 'value', None) diff --git a/flask_ask/models.py b/flask_ask/models.py index d159f7b..c652359 100644 --- a/flask_ask/models.py +++ b/flask_ask/models.py @@ -1,4 +1,5 @@ import inspect +import json from flask import json from xml.etree import ElementTree import aniso8601 @@ -76,7 +77,7 @@ def standard_card(self, title=None, text=None, small_image_url=None, large_image self._response['card'] = card return self - + def list_display_render(self, template=None, title=None, backButton='HIDDEN', token=None, background_image_url=None, image=None, listItems=None, hintText=None): directive = [ { @@ -89,7 +90,7 @@ def list_display_render(self, template=None, title=None, backButton='HIDDEN', to } } ] - + if background_image_url is not None: directive[0]['template']['backgroundImage'] = { 'sources': [ @@ -121,24 +122,24 @@ def display_render(self, template=None, title=None, backButton='HIDDEN', token=N } } ] - + if background_image_url is not None: directive[0]['template']['backgroundImage'] = { 'sources': [ {'url': background_image_url} ] } - + if image is not None: directive[0]['template']['image'] = { 'sources': [ {'url': image} ] } - + if token is not None: directive[0]['template']['token'] = token - + if hintText is not None: hint = { 'type':'Hint', @@ -171,7 +172,7 @@ def render_response(self): 'response': self._response, 'sessionAttributes': session.attributes } - + kw = {} if hasattr(session, 'attributes_encoder'): json_encoder = session.attributes_encoder @@ -208,13 +209,13 @@ def __init__(self, productId=None): 'shouldEndSession': True, 'directives': [{ 'type': 'Connections.SendRequest', - 'name': 'Buy', + 'name': 'Buy', 'payload': { 'InSkillProduct': { 'productId': productId } }, - 'token': 'correlationToken' + 'token': 'correlationToken' }] } @@ -226,13 +227,13 @@ def __init__(self, productId=None): 'shouldEndSession': True, 'directives': [{ 'type': 'Connections.SendRequest', - 'name': 'Cancel', + 'name': 'Cancel', 'payload': { 'InSkillProduct': { 'productId': productId } }, - 'token': 'correlationToken' + 'token': 'correlationToken' }] } @@ -243,14 +244,14 @@ def __init__(self, productId=None, msg=None): 'shouldEndSession': True, 'directives': [{ 'type': 'Connections.SendRequest', - 'name': 'Upsell', + 'name': 'Upsell', 'payload': { 'InSkillProduct': { 'productId': productId }, 'upsellMessage': msg }, - 'token': 'correlationToken' + 'token': 'correlationToken' }] } @@ -311,7 +312,7 @@ def __init__(self, slot, speech, updated_intent=None): class confirm_intent(_Response): """ Sends a ConfirmIntent directive. - + """ def __init__(self, speech, updated_intent=None): self._response = { @@ -442,6 +443,194 @@ def clear_queue(self, stop=False): return self +class gadget(_Response): + """Returns a response object with one or more GameEngine/GadgetController directives. + + Responses may include outputSpeech in addition to these directives. All timeout + parameters below are in milliseconds. + """ + + def __init__(self, speech=''): + super(gadget, self).__init__(speech) + # if not speech: + # self._response = {} + self._response['directives'] = [] + self._response['shouldEndSession'] = False + + def reprompt(self, reprompt): + reprompt = {'outputSpeech': _output_speech(reprompt)} + self._response['reprompt'] = reprompt + return self + + def _start_input_handler(self, timeout=0, proxies=[], recognizers={}, events={}): + """Returns an Input Handler which will wait for gadget events.""" + directive = {} + directive['type'] = 'GameEngine.StartInputHandler' + directive['timeout'] = timeout + directive['proxies'] = proxies + directive['recognizers'] = recognizers + directive['events'] = events + return directive + + def _stop_input_handler(self, request_id): + """Cancels the current Input Handler.""" + directive = {} + directive['type'] = 'GameEngine.StopInputHandler' + directive['originatingRequestId'] = request_id + return directive + + def roll_call(self, timeout=0, max_buttons=1): + """Waits for all available Echo Buttons to connect to the Echo device.""" + directive = self._start_input_handler(timeout=timeout) + for i in range(1, max_buttons + 1): + button = "btn{}".format(i) + recognizer = 'roll_call_recognizer_{}'.format(button) + event = 'roll_call_event_{}'.format(button) + directive['proxies'].append(button) + directive['recognizers'][recognizer] = { + 'type': 'match', + 'fuzzy': True, + 'anchor': 'end', + 'pattern': [{ + 'gadgetIds': [button], + 'action': 'down' + }] + } + directive['events'][event] = { + 'meets': [recognizer], + 'reports': 'matches', + 'shouldEndInputHandler': i == max_buttons, + 'maximumInvocations': 1 + } + directive['events']['timeout'] = { + 'meets': ['timed out'], + 'reports': 'history', + 'shouldEndInputHandler': True + } + self._response['directives'].append(directive) + return self + + def first_button(self, timeout=0, gadget_ids=[], animations=[]): + """Waits for the first Echo Button to be pressed.""" + directive = self._start_input_handler(timeout=timeout) + directive['recognizers'] = { + 'button_down_recognizer': { + 'type': 'match', + 'fuzzy': False, + 'anchor': 'end', + 'pattern': [{ + 'action': 'down' + }] + } + } + directive['events'] = { + 'timeout': { + 'meets': ['timed out'], + 'reports': 'nothing', + 'shouldEndInputHandler': True + }, + 'button_down_event': { + 'meets': ['button_down_recognizer'], + 'reports': 'matches', + 'shouldEndInputHandler': True + } + } + self._response['directives'].append(directive) + self.set_light(targets=gadget_ids, animations=animations) + return self + + def set_light(self, targets=[], trigger='none', delay=0, animations=[]): + """Sends a command to modify the behavior of connected Echo Buttons.""" + directive = {} + directive['type'] = 'GadgetController.SetLight' + directive['version'] = 1 + directive['targetGadgets'] = targets + if trigger not in ['buttonDown', 'buttonUp', 'none']: + trigger = None + if delay < 0 or delay > 65535: + delay = 0 + directive['parameters'] = { + 'triggerEvent': trigger, + 'triggerEventTimeMs': delay, + 'animations': animations + } + self._response['directives'].append(directive) + return self + + +class animation(dict): + """Returns a dictionary of animation parameters to be passed to the GadgetController.SetLight directive. + + Multiple animation steps can be added in a sequence by calling the class methods below. + """ + + def __init__(self, repeat=1, lights=['1'], sequence=[]): + attributes = {'repeat': repeat, 'targetLights': lights, 'sequence': sequence} + super(animation, self).__init__(attributes) + if not sequence: + print('clearing sequence') + self['sequence'] = [] + + def on(self, duration=1, color='FFFFFF'): + self['sequence'].append(animation_step(duration=duration, color=color)) + return self + + def off(self, duration=1): + self['sequence'].append(animation_step(duration=duration, color='000000')) + return self + + def fade_in(self, duration=3000, color='FFFFFF', repeat=1): + for i in range(repeat): + self['sequence'].append(animation_step(duration=1, color='000000', blend=True)) + self['sequence'].append(animation_step(duration=duration, color=color, blend=True)) + return self + + def fade_out(self, duration=3000, color='FFFFFF', repeat=1): + for i in range(repeat): + self['sequence'].append(animation_step(duration=1, color=color, blend=True)) + self['sequence'].append(animation_step(duration=duration, color='000000', blend=True)) + return self + + def crossfade(self, duration=2000, colors=['0000FF', 'FF0000'], repeat=1): + for i in range(repeat): + for color in colors: + self['sequence'].append(animation_step(duration=duration, color=color, blend=True)) + return self + + def breathe(self, duration=1000, color='FFFFFF', repeat=1): + for i in range(repeat): + self['sequence'].append(animation_step(duration=1, color='000000', blend=True)) + self['sequence'].append(animation_step(duration=duration, color='FFFFFF', blend=True)) + self['sequence'].append(animation_step(duration=int(duration * 0.3), color='000000', blend=True)) + return self + + def blink(self, duration=500, color='FFFFFF', repeat=1): + for i in range(repeat): + self['sequence'].append(animation_step(duration=duration, color=color)) + self['sequence'].append(animation_step(duration=duration, color='000000')) + return self + + def flip(self, duration=500, colors=['0000FF', 'FF0000'], repeat=1): + for i in range(repeat): + for color in colors: + self['sequence'].append(animation_step(duration=duration, color=color)) + return self + + def pulse(self, duration=500, color='FFFFFF', repeat=1): + for i in range(repeat): + self['sequence'].append(animation_step(duration=duration, color=color, blend=True)) + self['sequence'].append(animation_step(duration=duration*2, color='000000', blend=True)) + return self + + +class animation_step(dict): + """Returns a single animation step, which can be chained in a sequence.""" + + def __init__(self, duration=500, color='FFFFFF', blend=False): + attributes = {'durationMs': duration, 'color': color, 'blend': blend} + super(animation_step, self).__init__(attributes) + + def _copyattr(src, dest, attr, convert=None): if attr in src: value = src[attr] diff --git a/samples/gadget/button_demo/buttons.py b/samples/gadget/button_demo/buttons.py new file mode 100644 index 0000000..220af3d --- /dev/null +++ b/samples/gadget/button_demo/buttons.py @@ -0,0 +1,155 @@ +import json +import logging +import os + +from flask import Flask +from flask_ask import Ask, question, statement, gadget, animation, session + +app = Flask(__name__) +ask = Ask(app, "/") +logger = logging.getLogger() +logging.getLogger('flask_ask').setLevel(logging.INFO) + + +@ask.launch +def launch(): + session.attributes['activity'] = '' + session.attributes['players'] = [] + session.attributes['max_players'] = 4 + card_title = 'Gadget Skill Example' + text = 'Welcome to the gadget skill example.' + prompt = 'To register your Echo Buttons, say, "Start the roll call."' + return question(text).reprompt(prompt).simple_card(card_title, text) + + +@ask.intent('RollCallIntent') +def start_roll_call(): + """Identifies all Echo Buttons that are present.""" + speech = 'Players, press your buttons now.' + prompt = "I'm still waiting for you to press your buttons." + session.attributes['players'] = [] + session.attributes['activity'] = 'roll call' + return gadget(speech).reprompt(prompt).roll_call(timeout=10000, max_buttons=session.attributes['max_players']) + + +@ask.intent('StartRound') +def start_round(): + """Prompts all users to press their buttons and responds to the first one.""" + if not session.attributes['players']: + return question("I don't see any players yet. Start the roll call first.") + session.attributes['activity'] = 'new round' + return gadget('Press your button to buzz in.').first_button( + timeout=8000, + gadget_ids=[p['gid'] for p in session.attributes['players']], + animations=[animation(repeat=4).crossfade(duration=200).off()] + ) + + +@ask.intent('SetButtonColor') +def set_color(button='1', color='red'): + colors = { + 'white': 'FFFFFF', + 'red': 'FF0000', + 'orange': 'FF3300', + 'yellow': 'FFD400', + 'green': '00FF00', + 'blue': '0000FF', + 'purple': '4B0098', + 'black': '000000' + } + hex = colors.get(color, 'FFFFFF') + try: + gid = [p['gid'] for p in session.attributes['players'] if p['pid'] == button][0] + except LookupError: + return question("I couldn't find that button.").reprompt("What would you like to do?") + return gadget().set_light(targets=[gid], animations=[animation().on(color=hex)]) + + +@ask.on_input_handler_event() +def event_received(type, requestId, originatingRequestId, events): + """Receives an Input Handler event from Alexa.""" + if len(events) == 1 and events[0].get('name', '') == 'timeout': + if not events[0].get('inputEvents', []): + return question('Timeout received, no events') + else: + if session.attributes['activity'] == 'roll call': + session.attributes['activity'] = 'roll call complete' + return question('I found {} buttons. Ready to start the round?'.format(len(session.attributes['players']))) + elif session.attributes['activity'] == 'new round': + return question('Nobody buzzed in.') + for event in events: + for input_event in event['inputEvents']: + if session.attributes['activity'] == 'roll call': + return register_player(event['name'], input_event) + elif session.attributes['activity'] == 'new round': + return buzz_in(input_event) + + +def register_player(event_name, input_event): + """Adds a player's button to the list of known buttons and makes the button pulse yellow.""" + if input_event['action'] == 'down': + button_number = event_name[-1] + gid = input_event['gadgetId'] + session.attributes['players'].append({'pid': button_number, 'gid': gid}) + speech = "" + if event_name.endswith(str(session.attributes['max_players'])): + session.attributes['activity'] = 'roll call complete' + speech = 'I found {} buttons. Ready to start the round?'.format(session.attributes['max_players']) + return gadget(speech).set_light( + targets=[gid], + animations=[animation().pulse(color='FFFF00', duration=100)] + ) + + +def buzz_in(input_event): + """Acknowledges the first button that was pressed with speech and a 'breathing' animation.""" + gid = input_event['gadgetId'] + try: + pid = [p['pid'] for p in session.attributes['players'] if p['gid'] == gid][0] + except LookupError: + return question("I couldn't find the player associated with that button.") + return gadget("Player {}, you buzzed in first.".format(pid)).set_light( + targets=[gid], + animations=[animation(repeat=3).breathe(duration=500, color='00FF00')] + ) + + +@ask.intent('AMAZON.StopIntent') +def stop(): + return statement('Goodbye') + + +@ask.intent('AMAZON.YesIntent') +def yes(): + if session.attributes['activity'] == 'roll call complete': + return start_round() + else: + return fallback() + + +@ask.intent('AMAZON.NoIntent') +def no(): + return fallback() + + +@ask.intent('AMAZON.FallbackIntent') +def fallback(): + return question('What would you like to do?').reprompt('Are you still there?') + + +@ask.session_ended +def session_ended(): + return "{}", 200 + + +def _infodump(obj, indent=2): + msg = json.dumps(obj, indent=indent) + logger.info(msg) + + +if __name__ == '__main__': + if 'ASK_VERIFY_REQUESTS' in os.environ: + verify = str(os.environ.get('ASK_VERIFY_REQUESTS', '')).lower() + if verify == 'false': + app.config['ASK_VERIFY_REQUESTS'] = False + app.run(debug=True) From bfbd90d3ccafd89c67b6fb477bce2c7a506e6341 Mon Sep 17 00:00:00 2001 From: Ian Young <ian@duffrecords.com> Date: Mon, 10 Sep 2018 03:26:37 -0700 Subject: [PATCH 02/10] code cleanup --- flask_ask/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/flask_ask/models.py b/flask_ask/models.py index c652359..74332e1 100644 --- a/flask_ask/models.py +++ b/flask_ask/models.py @@ -1,5 +1,4 @@ import inspect -import json from flask import json from xml.etree import ElementTree import aniso8601 @@ -452,8 +451,6 @@ class gadget(_Response): def __init__(self, speech=''): super(gadget, self).__init__(speech) - # if not speech: - # self._response = {} self._response['directives'] = [] self._response['shouldEndSession'] = False From 480f9e98208c50934b126f332bd8f832edd7a43d Mon Sep 17 00:00:00 2001 From: Ian Young <ian@duffrecords.com> Date: Mon, 10 Sep 2018 12:39:50 -0700 Subject: [PATCH 03/10] change behavior of input handler methods --- flask_ask/models.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/flask_ask/models.py b/flask_ask/models.py index 74332e1..61ddc77 100644 --- a/flask_ask/models.py +++ b/flask_ask/models.py @@ -464,27 +464,29 @@ def _start_input_handler(self, timeout=0, proxies=[], recognizers={}, events={}) directive = {} directive['type'] = 'GameEngine.StartInputHandler' directive['timeout'] = timeout - directive['proxies'] = proxies - directive['recognizers'] = recognizers - directive['events'] = events - return directive + directive['proxies'] = proxies if proxies else [] + directive['recognizers'] = recognizers if recognizers else {} + directive['events'] = events if events else {} + self._response['directives'] = [directive] + return self def _stop_input_handler(self, request_id): """Cancels the current Input Handler.""" directive = {} directive['type'] = 'GameEngine.StopInputHandler' directive['originatingRequestId'] = request_id - return directive + self._response['directives'] = [directive] + return self def roll_call(self, timeout=0, max_buttons=1): """Waits for all available Echo Buttons to connect to the Echo device.""" - directive = self._start_input_handler(timeout=timeout) + self._start_input_handler(timeout=timeout) for i in range(1, max_buttons + 1): button = "btn{}".format(i) recognizer = 'roll_call_recognizer_{}'.format(button) event = 'roll_call_event_{}'.format(button) - directive['proxies'].append(button) - directive['recognizers'][recognizer] = { + self._response['directives'][-1]['proxies'].append(button) + self._response['directives'][-1]['recognizers'][recognizer] = { 'type': 'match', 'fuzzy': True, 'anchor': 'end', @@ -493,24 +495,23 @@ def roll_call(self, timeout=0, max_buttons=1): 'action': 'down' }] } - directive['events'][event] = { + self._response['directives'][-1]['events'][event] = { 'meets': [recognizer], 'reports': 'matches', 'shouldEndInputHandler': i == max_buttons, 'maximumInvocations': 1 } - directive['events']['timeout'] = { + self._response['directives'][-1]['events']['timeout'] = { 'meets': ['timed out'], 'reports': 'history', 'shouldEndInputHandler': True } - self._response['directives'].append(directive) return self def first_button(self, timeout=0, gadget_ids=[], animations=[]): """Waits for the first Echo Button to be pressed.""" - directive = self._start_input_handler(timeout=timeout) - directive['recognizers'] = { + self._start_input_handler(timeout=timeout) + self._response['directives'][-1]['recognizers'] = { 'button_down_recognizer': { 'type': 'match', 'fuzzy': False, @@ -520,7 +521,7 @@ def first_button(self, timeout=0, gadget_ids=[], animations=[]): }] } } - directive['events'] = { + self._response['directives'][-1]['events'] = { 'timeout': { 'meets': ['timed out'], 'reports': 'nothing', @@ -532,8 +533,8 @@ def first_button(self, timeout=0, gadget_ids=[], animations=[]): 'shouldEndInputHandler': True } } - self._response['directives'].append(directive) - self.set_light(targets=gadget_ids, animations=animations) + if animations: + self.set_light(targets=gadget_ids, animations=animations) return self def set_light(self, targets=[], trigger='none', delay=0, animations=[]): @@ -565,7 +566,6 @@ def __init__(self, repeat=1, lights=['1'], sequence=[]): attributes = {'repeat': repeat, 'targetLights': lights, 'sequence': sequence} super(animation, self).__init__(attributes) if not sequence: - print('clearing sequence') self['sequence'] = [] def on(self, duration=1, color='FFFFFF'): From 08a3597b7ffcaf97af6e95f2c898e6b4fc6f1136 Mon Sep 17 00:00:00 2001 From: Ian Young <ian@duffrecords.com> Date: Mon, 10 Sep 2018 12:40:15 -0700 Subject: [PATCH 04/10] unit tests for Gadgets Skill API --- tests/test_gadget.py | 139 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/test_gadget.py diff --git a/tests/test_gadget.py b/tests/test_gadget.py new file mode 100644 index 0000000..218b758 --- /dev/null +++ b/tests/test_gadget.py @@ -0,0 +1,139 @@ +import unittest +import json +import uuid + +from flask_ask import Ask, gadget, animation, animation_step +from flask import Flask +from mock import patch, MagicMock + + +class GadgetUnitTests(unittest.TestCase): + + def setUp(self): + self.ask_patcher = patch('flask_ask.core.find_ask', return_value=Ask()) + self.ask_patcher.start() + self.context_patcher = patch('flask_ask.models.context', return_value=MagicMock()) + self.context_patcher.start() + + def tearDown(self): + self.ask_patcher.stop() + self.context_patcher.stop() + + def test_animation_step(self): + step = animation_step() + self.assertEqual(step, {'durationMs': 500, 'color': 'FFFFFF', 'blend': False}) + + def test_animation(self): + a = animation() + self.assertEqual(a, {'repeat': 1, 'targetLights': ['1'], 'sequence': []}) + + def test_animation_sequence(self): + a = animation() + a.fade_in(duration=2000, color='0000FF').off(duration=500) + a.crossfade(duration=1000, colors=['FF0000', 'FFFF00']).fade_out(duration=1000) + sequence = [ + {'durationMs': 1, 'color': '000000', 'blend': True}, + {'durationMs': 2000, 'color': '0000FF', 'blend': True}, + {'durationMs': 500, 'color': '000000', 'blend': False}, + {'durationMs': 1000, 'color': 'FF0000', 'blend': True}, + {'durationMs': 1000, 'color': 'FFFF00', 'blend': True}, + {'durationMs': 1, 'color': 'FFFFFF', 'blend': True}, + {'durationMs': 1000, 'color': '000000', 'blend': True} + ] + self.assertEqual(a['sequence'], sequence) + + def test_start_input_handler(self): + g = gadget('foo')._start_input_handler(timeout=5000) + self.assertEqual(g._response['outputSpeech'], {'type': 'PlainText', 'text': 'foo'}) + self.assertEqual(g._response['shouldEndSession'], False) + self.assertEqual(g._response['directives'][0]['type'], 'GameEngine.StartInputHandler') + self.assertEqual(g._response['directives'][0]['timeout'], 5000) + self.assertEqual(g._response['directives'][0]['proxies'], []) + self.assertEqual(g._response['directives'][0]['recognizers'], {}) + self.assertEqual(g._response['directives'][0]['events'], {}) + + def test_stop_input_handler(self): + g = gadget()._stop_input_handler('1234567890') + self.assertEqual(g._response['outputSpeech'], {'type': 'PlainText', 'text': ''}) + self.assertEqual(g._response['shouldEndSession'], False) + self.assertEqual(g._response['directives'][0]['type'], 'GameEngine.StopInputHandler') + self.assertEqual(g._response['directives'][0]['originatingRequestId'], '1234567890') + + def test_roll_call(self): + g = gadget('Starting roll call').roll_call(timeout=10000, max_buttons=2) + self.assertEqual(g._response['outputSpeech']['text'], 'Starting roll call') + self.assertEqual(g._response['shouldEndSession'], False) + directive = g._response['directives'][0] + self.assertEqual(directive['type'], 'GameEngine.StartInputHandler') + self.assertEqual(directive['timeout'], 10000) + self.assertEqual(directive['proxies'], ['btn1', 'btn2']) + recognizers = { + 'roll_call_recognizer_btn1': { + 'type': 'match', + 'fuzzy': True, + 'anchor': 'end', + 'pattern': [{'gadgetIds': ['btn1'], 'action': 'down'}] + }, + 'roll_call_recognizer_btn2': { + 'type': 'match', + 'fuzzy': True, + 'anchor': 'end', + 'pattern': [{'gadgetIds': ['btn2'], 'action': 'down'}] + } + } + self.assertEqual(directive['recognizers'], recognizers) + events = { + 'roll_call_event_btn1': { + 'meets': ['roll_call_recognizer_btn1'], + 'reports': 'matches', + 'shouldEndInputHandler': False, + 'maximumInvocations': 1 + }, + 'roll_call_event_btn2': { + 'meets': ['roll_call_recognizer_btn2'], + 'reports': 'matches', + 'shouldEndInputHandler': True, + 'maximumInvocations': 1 + }, + 'timeout': { + 'meets': ['timed out'], + 'reports': 'history', + 'shouldEndInputHandler': True + } + } + self.assertEqual(directive['events'], events) + + def test_first_button(self): + g = gadget('Press your buttons').first_button(timeout=5000) + self.assertEqual(g._response['outputSpeech']['text'], 'Press your buttons') + self.assertEqual(g._response['shouldEndSession'], False) + directive = g._response['directives'][0] + self.assertEqual(directive['type'], 'GameEngine.StartInputHandler') + self.assertEqual(directive['timeout'], 5000) + self.assertEqual(directive['proxies'], []) + recognizers = { + 'button_down_recognizer': { + 'type': 'match', + 'fuzzy': False, + 'anchor': 'end', + 'pattern': [{'action': 'down'}] + } + } + events = { + 'timeout': { + 'meets': ['timed out'], + 'reports': 'nothing', + 'shouldEndInputHandler': True + }, + 'button_down_event': { + 'meets': ['button_down_recognizer'], + 'reports': 'matches', + 'shouldEndInputHandler': True + } + } + self.assertEqual(directive['recognizers'], recognizers) + self.assertEqual(directive['events'], events) + + +if __name__ == '__main__': + unittest.main() From 978881f3ba1a452e543eac9f7f566ed1499f5ad3 Mon Sep 17 00:00:00 2001 From: Ian Young <ian@duffrecords.com> Date: Mon, 10 Sep 2018 13:43:41 -0700 Subject: [PATCH 05/10] integration tests for Gadgets Skill API --- tests/test_gadget.py | 140 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/tests/test_gadget.py b/tests/test_gadget.py index 218b758..a59c29b 100644 --- a/tests/test_gadget.py +++ b/tests/test_gadget.py @@ -2,7 +2,8 @@ import json import uuid -from flask_ask import Ask, gadget, animation, animation_step +from datetime import datetime +from flask_ask import Ask, gadget, animation, animation_step, question, session from flask import Flask from mock import patch, MagicMock @@ -135,5 +136,142 @@ def test_first_button(self): self.assertEqual(directive['events'], events) +basic_request = { + "version": "1.0", + "session": { + "new": True, + "sessionId": "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000", + "application": { + "applicationId": "fake-application-id" + }, + "attributes": {}, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" + } + }, + "context": { + "System": { + "application": { + "applicationId": "fake-application-id" + }, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" + }, + "device": { + "supportedInterfaces": { + "GadgetController": {}, + "GameEngine": {} + } + } + } + }, + "request": {} +} + + +class GadgetIntegrationTests(unittest.TestCase): + + def setUp(self): + self.app = Flask(__name__) + self.app.config['ASK_VERIFY_REQUESTS'] = False + self.ask = Ask(app=self.app, route='/ask') + self.client = self.app.test_client() + + @self.ask.intent('RollCallIntent') + def start_roll_call(): + speech = 'Players, press your buttons now.' + return gadget(speech).roll_call(timeout=10000, max_buttons=2) + + @self.ask.on_input_handler_event() + def event_received(type, requestId, originatingRequestId, events): + for event in events: + for input_event in event['inputEvents']: + if session.attributes['activity'] == 'roll call': + return register_player(event['name'], input_event) + elif session.attributes['activity'] == 'new round': + return buzz_in(input_event) + + def register_player(event_name, input_event): + """Adds a player's button to the list of known buttons and makes the button pulse yellow.""" + if input_event['action'] == 'down': + button_number = event_name[-1] + gid = input_event['gadgetId'] + session.attributes['players'].append({'pid': button_number, 'gid': gid}) + speech = "" + if event_name.endswith('2'): + session.attributes['activity'] = 'roll call complete' + speech = 'I found {} buttons. Ready to start the round?'.format(2) + return gadget(speech).set_light( + targets=[gid], + animations=[animation().pulse(color='FFFF00', duration=100)] + ) + + def buzz_in(input_event): + """Acknowledges the first button that was pressed with speech and a 'breathing' animation.""" + gid = input_event['gadgetId'] + try: + pid = [p['pid'] for p in session.attributes['players'] if p['gid'] == gid][0] + except LookupError: + return question("I couldn't find the player associated with that button.") + return gadget("Player {}, you buzzed in first.".format(pid)).set_light( + targets=[gid], + animations=[animation(repeat=3).breathe(duration=500, color='00FF00')] + ) + + def tearDown(self): + pass + + def test_response_to_roll_call(self): + for button in range(1, 3): + event = { + 'type': 'GameEngine.InputHandlerEvent', + 'requestId': 'amzn1.echo-api.request.{}'.format(str(uuid.uuid4())), + 'events': [{ + 'name': 'roll_call_event_btn{}'.format(button), + 'inputEvents': [{ + 'gadgetId': 'amzn1.ask.gadget.{}'.format(button), + 'timestamp': datetime.now().isoformat(), + 'color': '000000', + 'feature': 'press', + 'action': 'down' + }] + }] + } + basic_request['request'] = event + basic_request['session']['attributes']['activity'] = 'roll call' + basic_request['session']['attributes']['players'] = [] + response = self.client.post('/ask', data=json.dumps(basic_request)) + self.assertEqual(200, response.status_code) + response_data = json.loads(response.data.decode('utf-8')) + self.assertEqual(response_data['sessionAttributes']['players'][0]['gid'], 'amzn1.ask.gadget.{}'.format(button)) + self.assertEqual(response_data['sessionAttributes']['players'][0]['pid'], str(button)) + + def test_first_button(self): + event = { + 'type': 'GameEngine.InputHandlerEvent', + 'requestId': 'amzn1.echo-api.request.{}'.format(str(uuid.uuid4())), + 'events': [{ + 'name': 'button_down_event', + 'inputEvents': [{ + 'gadgetId': 'amzn1.ask.gadget.1', + 'timestamp': datetime.now().isoformat(), + 'color': '000000', + 'feature': 'press', + 'action': 'down' + }] + }] + } + basic_request['request'] = event + basic_request['session']['attributes']['activity'] = 'new round' + basic_request['session']['attributes']['players'] = [ + {"gid": "amzn1.ask.gadget.1", "pid": "1"}, + {"gid": "amzn1.ask.gadget.2", "pid": "2"} + ] + response = self.client.post('/ask', data=json.dumps(basic_request)) + self.assertEqual(200, response.status_code) + response_data = json.loads(response.data.decode('utf-8')) + self.assertEqual(response_data['response']['outputSpeech']['text'], 'Player 1, you buzzed in first.') + + if __name__ == '__main__': unittest.main() From cee517bfe437f2b33941c770314c5f43a0fa2579 Mon Sep 17 00:00:00 2001 From: Ian Young <ian@duffrecords.com> Date: Mon, 10 Sep 2018 14:00:13 -0700 Subject: [PATCH 06/10] test setting button color --- tests/test_gadget.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/test_gadget.py b/tests/test_gadget.py index a59c29b..a242026 100644 --- a/tests/test_gadget.py +++ b/tests/test_gadget.py @@ -177,10 +177,10 @@ def setUp(self): self.ask = Ask(app=self.app, route='/ask') self.client = self.app.test_client() - @self.ask.intent('RollCallIntent') - def start_roll_call(): - speech = 'Players, press your buttons now.' - return gadget(speech).roll_call(timeout=10000, max_buttons=2) + # @self.ask.intent('RollCallIntent') + # def start_roll_call(): + # speech = 'Players, press your buttons now.' + # return gadget(speech).roll_call(timeout=10000, max_buttons=2) @self.ask.on_input_handler_event() def event_received(type, requestId, originatingRequestId, events): @@ -203,7 +203,7 @@ def register_player(event_name, input_event): speech = 'I found {} buttons. Ready to start the round?'.format(2) return gadget(speech).set_light( targets=[gid], - animations=[animation().pulse(color='FFFF00', duration=100)] + animations=[animation().on(color='FFFF00')] ) def buzz_in(input_event): @@ -245,6 +245,9 @@ def test_response_to_roll_call(self): response_data = json.loads(response.data.decode('utf-8')) self.assertEqual(response_data['sessionAttributes']['players'][0]['gid'], 'amzn1.ask.gadget.{}'.format(button)) self.assertEqual(response_data['sessionAttributes']['players'][0]['pid'], str(button)) + directive = response_data['response']['directives'][0] + sequence = directive['parameters']['animations'][0]['sequence'] + self.assertEqual(sequence[0]['color'], 'FFFF00') def test_first_button(self): event = { From f2827d7ad3fa955befeca2e6904c29ba5c8a3007 Mon Sep 17 00:00:00 2001 From: Ian Young <ian@duffrecords.com> Date: Mon, 10 Sep 2018 14:00:44 -0700 Subject: [PATCH 07/10] speech assets for Gadgets Skill API --- .../speech_assets/IntentSchema.json | 91 +++++++++++++++++++ .../speech_assets/SampleUtterances.txt | 26 ++++++ 2 files changed, 117 insertions(+) create mode 100644 samples/gadget/button_demo/speech_assets/IntentSchema.json create mode 100644 samples/gadget/button_demo/speech_assets/SampleUtterances.txt diff --git a/samples/gadget/button_demo/speech_assets/IntentSchema.json b/samples/gadget/button_demo/speech_assets/IntentSchema.json new file mode 100644 index 0000000..2321717 --- /dev/null +++ b/samples/gadget/button_demo/speech_assets/IntentSchema.json @@ -0,0 +1,91 @@ +{ + "interactionModel": { + "languageModel": { + "invocationName": "gadget test", + "intents": [ + { + "name": "AMAZON.FallbackIntent", + "samples": [] + }, + { + "name": "AMAZON.CancelIntent", + "samples": [] + }, + { + "name": "AMAZON.HelpIntent", + "samples": [] + }, + { + "name": "AMAZON.StopIntent", + "samples": [] + }, + { + "name": "AMAZON.NavigateHomeIntent", + "samples": [] + }, + { + "name": "RollCallIntent", + "slots": [], + "samples": [ + "start the roll call intent", + "start roll call intent", + "test the roll call intent", + "test roll call intent", + "roll call intent", + "test the roll call", + "start the roll call", + "test roll call", + "start roll call", + "roll call" + ] + }, + { + "name": "AMAZON.YesIntent", + "samples": [] + }, + { + "name": "AMAZON.NoIntent", + "samples": [] + }, + { + "name": "StartRound", + "slots": [], + "samples": [ + "test start the round", + "test buzzing in", + "test buzz in", + "test start a round", + "test start round", + "start another round", + "start the round now", + "start a round", + "start the round", + "start round" + ] + }, + { + "name": "SetButtonColor", + "slots": [ + { + "name": "button", + "type": "AMAZON.NUMBER" + }, + { + "name": "color", + "type": "AMAZON.Color" + } + ], + "samples": [ + "make player {button} {color}", + "make button {button} {color}", + "change player {button} to {color}", + "set player {button} to {color}", + "change button {button} to {color}", + "set button {button} to {color}" + ] + } + ], + "types": [] + } + } +} diff --git a/samples/gadget/button_demo/speech_assets/SampleUtterances.txt b/samples/gadget/button_demo/speech_assets/SampleUtterances.txt new file mode 100644 index 0000000..5b89982 --- /dev/null +++ b/samples/gadget/button_demo/speech_assets/SampleUtterances.txt @@ -0,0 +1,26 @@ +RollCallIntent start the roll call intent +RollCallIntent start roll call intent +RollCallIntent test the roll call intent +RollCallIntent test roll call intent +RollCallIntent roll call intent +RollCallIntent test the roll call +RollCallIntent start the roll call +RollCallIntent test roll call +RollCallIntent start roll call +RollCallIntent roll call +StartRound test start the round +StartRound test buzzing in +StartRound test buzz in +StartRound test start a round +StartRound test start round +StartRound start another round +StartRound start the round now +StartRound start a round +StartRound start the round +StartRound start round +SetButtonColor make player {button} {color} +SetButtonColor make button {button} {color} +SetButtonColor change player {button} to {color} +SetButtonColor set player {button} to {color} +SetButtonColor change button {button} to {color} +SetButtonColor set button {button} to {color} From 96947a97077cad98ef1d02959f262e4659578b06 Mon Sep 17 00:00:00 2001 From: Ian Young <ian@duffrecords.com> Date: Wed, 12 Sep 2018 11:25:31 -0700 Subject: [PATCH 08/10] set light color to parameter, not hard-coded value --- flask_ask/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_ask/models.py b/flask_ask/models.py index 61ddc77..2069c43 100644 --- a/flask_ask/models.py +++ b/flask_ask/models.py @@ -597,7 +597,7 @@ def crossfade(self, duration=2000, colors=['0000FF', 'FF0000'], repeat=1): def breathe(self, duration=1000, color='FFFFFF', repeat=1): for i in range(repeat): self['sequence'].append(animation_step(duration=1, color='000000', blend=True)) - self['sequence'].append(animation_step(duration=duration, color='FFFFFF', blend=True)) + self['sequence'].append(animation_step(duration=duration, color=color, blend=True)) self['sequence'].append(animation_step(duration=int(duration * 0.3), color='000000', blend=True)) return self From 23d07ee28972699c4e15389057172f3b2b4443ab Mon Sep 17 00:00:00 2001 From: Ian Young <ian@duffrecords.com> Date: Thu, 13 Sep 2018 01:35:47 -0700 Subject: [PATCH 09/10] ensure only one StartInputHandler directive per request, don't overwrite GadgetController directives --- flask_ask/models.py | 46 +++++++++++++++++++++++++------------------- tests/test_gadget.py | 5 ++++- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/flask_ask/models.py b/flask_ask/models.py index 2069c43..8654300 100644 --- a/flask_ask/models.py +++ b/flask_ask/models.py @@ -453,40 +453,44 @@ def __init__(self, speech=''): super(gadget, self).__init__(speech) self._response['directives'] = [] self._response['shouldEndSession'] = False + self._input_handler = None def reprompt(self, reprompt): reprompt = {'outputSpeech': _output_speech(reprompt)} self._response['reprompt'] = reprompt return self - def _start_input_handler(self, timeout=0, proxies=[], recognizers={}, events={}): + def _start_input_handler(self, timeout=0): """Returns an Input Handler which will wait for gadget events.""" directive = {} directive['type'] = 'GameEngine.StartInputHandler' directive['timeout'] = timeout - directive['proxies'] = proxies if proxies else [] - directive['recognizers'] = recognizers if recognizers else {} - directive['events'] = events if events else {} - self._response['directives'] = [directive] + directive['proxies'] = [] + directive['recognizers'] = {} + directive['events'] = {} + self._input_handler = len(self._response['directives']) + self._response['directives'].append(directive) return self - def _stop_input_handler(self, request_id): + def stop(self, request_id): """Cancels the current Input Handler.""" directive = {} directive['type'] = 'GameEngine.StopInputHandler' directive['originatingRequestId'] = request_id - self._response['directives'] = [directive] + self._response['directives'].append(directive) return self def roll_call(self, timeout=0, max_buttons=1): """Waits for all available Echo Buttons to connect to the Echo device.""" - self._start_input_handler(timeout=timeout) - for i in range(1, max_buttons + 1): - button = "btn{}".format(i) + if not self._input_handler: + self._start_input_handler(timeout=timeout) + ih = self._input_handler + for btn in range(1, max_buttons + 1): + button = "btn{}".format(btn) recognizer = 'roll_call_recognizer_{}'.format(button) event = 'roll_call_event_{}'.format(button) - self._response['directives'][-1]['proxies'].append(button) - self._response['directives'][-1]['recognizers'][recognizer] = { + self._response['directives'][ih]['proxies'].append(button) + self._response['directives'][ih]['recognizers'][recognizer] = { 'type': 'match', 'fuzzy': True, 'anchor': 'end', @@ -495,23 +499,25 @@ def roll_call(self, timeout=0, max_buttons=1): 'action': 'down' }] } - self._response['directives'][-1]['events'][event] = { + self._response['directives'][ih]['events'][event] = { 'meets': [recognizer], 'reports': 'matches', - 'shouldEndInputHandler': i == max_buttons, + 'shouldEndInputHandler': btn == max_buttons, 'maximumInvocations': 1 } - self._response['directives'][-1]['events']['timeout'] = { + self._response['directives'][ih]['events']['timeout'] = { 'meets': ['timed out'], 'reports': 'history', 'shouldEndInputHandler': True } return self - def first_button(self, timeout=0, gadget_ids=[], animations=[]): + def first_button(self, timeout=0, targets=[], animations=[]): """Waits for the first Echo Button to be pressed.""" - self._start_input_handler(timeout=timeout) - self._response['directives'][-1]['recognizers'] = { + if not self._input_handler: + self._start_input_handler(timeout=timeout) + ih = self._input_handler + self._response['directives'][ih]['recognizers'] = { 'button_down_recognizer': { 'type': 'match', 'fuzzy': False, @@ -521,7 +527,7 @@ def first_button(self, timeout=0, gadget_ids=[], animations=[]): }] } } - self._response['directives'][-1]['events'] = { + self._response['directives'][ih]['events'] = { 'timeout': { 'meets': ['timed out'], 'reports': 'nothing', @@ -534,7 +540,7 @@ def first_button(self, timeout=0, gadget_ids=[], animations=[]): } } if animations: - self.set_light(targets=gadget_ids, animations=animations) + self.set_light(targets=targets, animations=animations) return self def set_light(self, targets=[], trigger='none', delay=0, animations=[]): diff --git a/tests/test_gadget.py b/tests/test_gadget.py index a242026..4ad6a62 100644 --- a/tests/test_gadget.py +++ b/tests/test_gadget.py @@ -54,7 +54,7 @@ def test_start_input_handler(self): self.assertEqual(g._response['directives'][0]['events'], {}) def test_stop_input_handler(self): - g = gadget()._stop_input_handler('1234567890') + g = gadget().stop('1234567890') self.assertEqual(g._response['outputSpeech'], {'type': 'PlainText', 'text': ''}) self.assertEqual(g._response['shouldEndSession'], False) self.assertEqual(g._response['directives'][0]['type'], 'GameEngine.StopInputHandler') @@ -64,6 +64,7 @@ def test_roll_call(self): g = gadget('Starting roll call').roll_call(timeout=10000, max_buttons=2) self.assertEqual(g._response['outputSpeech']['text'], 'Starting roll call') self.assertEqual(g._response['shouldEndSession'], False) + self.assertEqual(len(g._response['directives']), 1) directive = g._response['directives'][0] self.assertEqual(directive['type'], 'GameEngine.StartInputHandler') self.assertEqual(directive['timeout'], 10000) @@ -108,6 +109,7 @@ def test_first_button(self): g = gadget('Press your buttons').first_button(timeout=5000) self.assertEqual(g._response['outputSpeech']['text'], 'Press your buttons') self.assertEqual(g._response['shouldEndSession'], False) + self.assertEqual(len(g._response['directives']), 1) directive = g._response['directives'][0] self.assertEqual(directive['type'], 'GameEngine.StartInputHandler') self.assertEqual(directive['timeout'], 5000) @@ -245,6 +247,7 @@ def test_response_to_roll_call(self): response_data = json.loads(response.data.decode('utf-8')) self.assertEqual(response_data['sessionAttributes']['players'][0]['gid'], 'amzn1.ask.gadget.{}'.format(button)) self.assertEqual(response_data['sessionAttributes']['players'][0]['pid'], str(button)) + self.assertEqual(len(response_data['response']['directives']), 1) directive = response_data['response']['directives'][0] sequence = directive['parameters']['animations'][0]['sequence'] self.assertEqual(sequence[0]['color'], 'FFFF00') From 43c897d646d0cf3bc2fc220b3d7aad85319637c1 Mon Sep 17 00:00:00 2001 From: Ian Young <ian@duffrecords.com> Date: Thu, 13 Sep 2018 02:24:15 -0700 Subject: [PATCH 10/10] allow whitelisting gadget IDs in first_button() --- flask_ask/models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/flask_ask/models.py b/flask_ask/models.py index 8654300..75fe1e4 100644 --- a/flask_ask/models.py +++ b/flask_ask/models.py @@ -517,14 +517,15 @@ def first_button(self, timeout=0, targets=[], animations=[]): if not self._input_handler: self._start_input_handler(timeout=timeout) ih = self._input_handler + pattern = {'action': 'down'} + if targets: + pattern['gadgetIds'] = targets self._response['directives'][ih]['recognizers'] = { 'button_down_recognizer': { 'type': 'match', 'fuzzy': False, 'anchor': 'end', - 'pattern': [{ - 'action': 'down' - }] + 'pattern': [pattern] } } self._response['directives'][ih]['events'] = {