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..75fe1e4 100644 --- a/flask_ask/models.py +++ b/flask_ask/models.py @@ -76,7 +76,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 +89,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 +121,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 +171,7 @@ def render_response(self): 'response': self._response, 'sessionAttributes': session.attributes } - + kw = {} if hasattr(session, 'attributes_encoder'): json_encoder = session.attributes_encoder @@ -208,13 +208,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 +226,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 +243,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 +311,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 +442,199 @@ 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) + 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): + """Returns an Input Handler which will wait for gadget events.""" + directive = {} + directive['type'] = 'GameEngine.StartInputHandler' + directive['timeout'] = timeout + directive['proxies'] = [] + directive['recognizers'] = {} + directive['events'] = {} + self._input_handler = len(self._response['directives']) + self._response['directives'].append(directive) + return self + + def stop(self, request_id): + """Cancels the current Input Handler.""" + directive = {} + directive['type'] = 'GameEngine.StopInputHandler' + directive['originatingRequestId'] = request_id + 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.""" + 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'][ih]['proxies'].append(button) + self._response['directives'][ih]['recognizers'][recognizer] = { + 'type': 'match', + 'fuzzy': True, + 'anchor': 'end', + 'pattern': [{ + 'gadgetIds': [button], + 'action': 'down' + }] + } + self._response['directives'][ih]['events'][event] = { + 'meets': [recognizer], + 'reports': 'matches', + 'shouldEndInputHandler': btn == max_buttons, + 'maximumInvocations': 1 + } + self._response['directives'][ih]['events']['timeout'] = { + 'meets': ['timed out'], + 'reports': 'history', + 'shouldEndInputHandler': True + } + return self + + def first_button(self, timeout=0, targets=[], animations=[]): + """Waits for the first Echo Button to be pressed.""" + 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': [pattern] + } + } + self._response['directives'][ih]['events'] = { + 'timeout': { + 'meets': ['timed out'], + 'reports': 'nothing', + 'shouldEndInputHandler': True + }, + 'button_down_event': { + 'meets': ['button_down_recognizer'], + 'reports': 'matches', + 'shouldEndInputHandler': True + } + } + if animations: + self.set_light(targets=targets, 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: + 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=color, 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) 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} diff --git a/tests/test_gadget.py b/tests/test_gadget.py new file mode 100644 index 0000000..4ad6a62 --- /dev/null +++ b/tests/test_gadget.py @@ -0,0 +1,283 @@ +import unittest +import json +import uuid + +from datetime import datetime +from flask_ask import Ask, gadget, animation, animation_step, question, session +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('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) + self.assertEqual(len(g._response['directives']), 1) + 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) + self.assertEqual(len(g._response['directives']), 1) + 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) + + +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().on(color='FFFF00')] + ) + + 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)) + 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') + + 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()