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'] = {