Hackster is hosting Hackster Holidays, Ep. 4: Livestream & Giveaway Drawing. Start streaming on Wednesday!Stream Hackster Holidays, Ep. 4 on Wednesday!
bondington
Created December 23, 2019

Alexa's Little Helper

Alexa's little helper is a Lego Mindstorms Echo Show transport system. Never lift a finger to move your Echo Show again!

IntermediateShowcase (no instructions)27
Alexa's Little Helper

Things used in this project

Story

Read more

Schematics

EV3 And NXT Port Wiring

Details which motors and sensors are connected to which ports (also detailed in the EV3 Python source code)

Code

EV3 Application

Python
The Python application which runs on the EV3 and interacts with Alexa
#!/usr/bin/env python3

"""
ALEXA'S LITTLE HELPER (Skill invocation "your little helper")

Creator
-------
Keith Rosenheck 2019

Credits
-------
I learnt from a lot of good work by a lot of good people to help me get this project done. This included copying sample code and modifying it to suit this project.
My main sources of information and examples were:

- The tutorial for the Lego MindStorms Voice Challenge:
    - https://www.hackster.io/alexagadgets/lego-mindstorms-voice-challenge-setup-17300f

- Amazon's Gadget Toolkit and Rasberry PI Examples:
    - https://developer.amazon.com/en-US/docs/alexa/alexa-gadgets-toolkit/features.html
    - https://github.com/alexa/Alexa-Gadgets-Raspberry-Pi-Samples

- The EV3DEV project:
    - https://www.ev3dev.org/docs/getting-started/

- The EV3 Python site:
    - https://sites.google.com/site/ev3devpython/learn_ev3_python

- The NXT-PYTHON project:
    - https://github.com/eelviny/nxt-python

- Other resources helped out too:
    - https://github.com/ev3dev/ev3dev-lang-python
    - https://cheatsheet.c4f.wtf/python-ev3dev
    - https://medium.com/@yzhong.cs/serialize-and-deserialize-complex-json-in-python-205ecc636caa
    - https://medium.com/python-pandemonium/json-the-python-way-91aac95d4041
    - The LEGO MINDSTORMS EV3 Idea Book: 181 Simple Machines and Clever Contraptions - By Yoshihito Isogawa


EV3 PORTS
---------
A: NXT Notification Flag Motor
B: Left EV3 Large Motor - Tank
C: Right EV3 Large Motor - Tank
D: Medium EV3 Tilt Motor
1: Front Left NXT Ultrasonic Sensor
2: Front Right NXT Ultrasonic Sensor
3: Rear Feelers EV3 Color Sensor
4: Front Object Detection Infrared Sensor

NXT PORTS
---------
A:
B:
C: NXT Spinner Motor
1: Tilt State NXT Touch Button
2: Notification Flag State NXT Touch Button
3:
4:
"""
import sys
import os
import time
import logging
import threading
import subprocess
import dateutil.parser
from enum import Enum
from datetime import datetime

from agt import AlexaGadget

from ev3dev2.led import Leds
from ev3dev2.sound import Sound
from ev3dev2.motor import (
    OUTPUT_A,
    OUTPUT_B,
    OUTPUT_C,
    OUTPUT_D,
    MoveTank,
    SpeedPercent,
    MediumMotor,
    LargeMotor,
)
from ev3dev2.sensor.lego import UltrasonicSensor
from ev3dev2.sensor.lego import ColorSensor
from ev3dev2.sensor.lego import InfraredSensor
from ev3dev2.sensor import INPUT_1, INPUT_2, INPUT_3, INPUT_4
from ev3dev2.button import Button
from ev3dev2.power import PowerSupply

import nxt as nxt_brick
from nxt.locator import find_one_brick
from nxt.motor import Motor, PORT_A, PORT_B, PORT_C
from nxt.sensor import Light, Touch, Ultrasonic
from nxt.sensor import PORT_1, PORT_2, PORT_3, PORT_4

from typing import List
import json

import os.path
from os import path

# declare constants
SPEED = 20
TILT_DEGREES = 1135
SURFACE_DISTANCE_MIN = 7
PROXIMITY_DISTANCE_MIN = 15
REFELECTED_LIGHT_MIN = 18
LOCATION_HOME = "home"
LOCATION_UNKNOWN = "unknown"

# Set the logging level to INFO to see messages from AlexaGadget
logging.basicConfig(level=logging.INFO)


class Direction(Enum):
    """
    The list of directional commands and their variations.
    These variations correspond to the skill slot values.
    """

    FORWARD = ["forward", "forwards", "go forward"]
    BACKWARD = ["back", "backward", "backwards", "go backward"]
    LEFT = ["left", "go left"]
    RIGHT = ["right", "go right"]
    STOP = ["stop", "brake", "halt"]


class Command(Enum):
    """
    The list of preset commands and their invocation variation.
    These variations correspond to the skill slot values.
    """

    TILT = ["tilt"]
    PRINT_DATA = ["print"]
    BATTERY = ["battery", "battery level"]
    POWER_DOWN = ["power_down"]
    LOCATION_SET_CURRENT = ["set", "set current", "set position", "set location", "set current location", "set current position"]
    LOCATION_SAVE = ["save", "save location", "save position"]
    LOCATION_GOTO = ["go to", "goto"]
    LOCATION_REMOVE = ["remove", "remove location", "remove position", "delete", "delete location", "delete position"]


class Move(object):
    """
    Capture move data for later replay if required
    """
    def __init__(self, direction: str, distance: int, speed: int):
        self.direction = direction
        self.distance = distance
        self.speed = speed

    @classmethod
    def from_json(cls, data):
        return cls(**data)


class Location(object):
    """
    Capture a location as a collection of moves
    """
    def __init__(self, moves: List[Move]):
        self.moves = moves

    @classmethod
    def from_json(cls, data):
        moves = list(map(Move.from_json, data["moves"]))
        return cls(moves)


class MindstormsGadget(AlexaGadget):
    """
    A Mindstorms gadget that can perform bi-directional interaction with an Alexa skill.
    """

    def __init__(self):
        """
        Performs Alexa Gadget initialization routines and ev3dev resource allocation.
        """
        super().__init__()

        # find the NXT brick
        self.outputToConsole("About to look for NXT brick")
        self.nxtBrick = nxt_brick.locator.find_one_brick()
        self.outputToConsole("Found NXT brick")

        # setup the tank drive and use one of its motors for data capture
        self.tankDrive = MoveTank(OUTPUT_B, OUTPUT_C)
        self.largeMotorB = LargeMotor(OUTPUT_B)

        # setup the tilt motor
        self.mediumTiltMotor = MediumMotor(OUTPUT_D)
        self.mediumTiltMotor.reset()

        # setup the timer and notification motors
        self.nxtNotificationMotor = LargeMotor(OUTPUT_A)
        self.nxtTimerMotor = Motor(self.nxtBrick, PORT_C)

        # setup sensors
        self.frontLeftNxtUltrasonic = UltrasonicSensor(INPUT_1)
        self.frontRightNxtUltrasonic = UltrasonicSensor(INPUT_2)
        self.rearEv3Colour = ColorSensor(INPUT_3)
        self.frontEv3InfraRed = InfraredSensor(INPUT_4)
        self.nxtTiltButton = Touch(self.nxtBrick, PORT_1)
        self.nxtFlagButton = Touch(self.nxtBrick, PORT_2)

        # setup extra info and feedback objects
        self.powerSupply = PowerSupply()
        self.sound = Sound()
        self.leds = Leds()

        # init sensor reading variables
        self.frontLeftNxtUltrasonicDistance = 0
        self.frontRightNxtUltrasonicDistance = 0
        self.frontEv3InfraRedProximity = 0
        self.rearEv3ColourReflectedLight = 0
        self.nxtTiltButtonPressed = False
        self.nxtFlagButtonPressed = False

        # init other variables
        self.forwardStop = True
        self.backwardStop = True
        self.powerDown = False
        self.currentLocationName = LOCATION_HOME
        self.movesList = []
        self.recordMoves = True
        self.movedSomewhere = False
        self.setTheTimeRecently = False

        # init timer response variables
        self.timer_thread = None
        self.timer_token = None
        self.timer_end_time = None

        # fire up the detection thread looking for edges and objects to avoid
        threading.Thread(target=self.detection, daemon=True).start()

    def on_connected(self, device_addr):
        """
        Gadget connected to the paired Echo device.
        """
        print("{} connected to Echo device".format(self.friendly_name))

        self.sound.play_song((("C4", "e"), ("D4", "e"), ("E5", "q")))

    def on_disconnected(self, device_addr):
        """
        Gadget disconnected from the paired Echo device.
        """
        print("{} disconnected from Echo device".format(self.friendly_name))

    def on_alexa_gadget_statelistener_stateupdate(self, directive):
        """
        Handles the Alexa.Gadget.StateListener state updates
        """
        # do we have a state update to do something with
        if len(directive.payload.states) > 0:
            statusUpdate = directive.payload.states[0]

            if statusUpdate.name == "timeinfo":
                # getting two time status updates at the start and as they're both he same time, the second setting of it actually makes the time a second or two out
                if not self.setTheTimeRecently:
                    os.system("sudo date -s '{}'".format(statusUpdate.value))
                    self.outputToConsole("Set the time to '{0}' and the current time is '{1}'".format(statusUpdate.value, datetime.now()))
                    self.setTheTimeRecently = True
                else:
                    self.outputToConsole("Ignoring timeinfo update as it hasn't been long enough since we last set the time")

    def on_alerts_setalert(self, directive):
        """
        Handles Alerts.SetAlert directive sent from Echo Device
        """
        # check that this is a timer. if it is something else (alarm, reminder), just ignore
        if directive.payload.type != 'TIMER':
            return

        # parse the scheduledTime in the directive. if is already expired, ignore
        t = dateutil.parser.parse(directive.payload.scheduledTime).timestamp()
        if t <= 0:
            self.outputToConsole("Received SetAlert directive but scheduledTime has already passed. Ignoring")
            return

        # check if this is an update to an already running timer (e.g. users asks alexa to add 30s), if it is, just adjust the end time
        if self.timer_token == directive.payload.token:
            self.outputToConsole("Received SetAlert directive to update to currently running timer. Adjusting")
            self.timer_end_time = t
            return

        # check if another timer is already running. if it is, just ignore this one
        if self.timer_thread is not None and self.timer_thread.isAlive():
            self.outputToConsole("Received SetAlert directive but another timer is already running. Ignoring")
            return

        # start a thread to countdown till the timer goes off
        self.outputToConsole("Received SetAlert directive ({0}). Starting a timer. {1}".format(directive.payload.scheduledTime, str(int(t - time.time())) + " seconds left.."))
        self.timer_end_time = t
        self.timer_token = directive.payload.token

        # run timer in it's own thread to prevent blocking future directives during count down
        self.timer_thread = threading.Thread(target=self.timer_countdown)
        self.timer_thread.start()

    def on_alerts_deletealert(self, directive):
        """
        Handles Alerts.DeleteAlert directive sent from Echo Device
        """
        # check if this is for the currently running timer. if not, just ignore
        if self.timer_token != directive.payload.token:
            self.outputToConsole("Received DeleteAlert directive but not for the currently active timer. Ignoring")
            return

        # delete the timer, and stop the currently running timer thread
        self.outputToConsole("Received DeleteAlert directive. Cancelling the timer")
        self.timer_token = None

        # stop the spinner
        self.runTimerSpinner(0)

    def timer_countdown(self):
        """
        Handles making the stand run a spinner when an Alexa timer goes off
        """
        time_remaining = max(0, self.timer_end_time - time.time())
        while self.timer_token and time_remaining > 1.5:
            # update the time remaining
            time_remaining = max(0, self.timer_end_time - time.time())

            time.sleep(0.2)

        # if we still have a valid token then the timer expired so lets dance
        if self.timer_token:
            self.runTimerSpinner(90)

    def on_notifications_setindicator(self, directive):
        """
        Handles raising the flag if a notifaction is received
        """
        self.outputToConsole("Received notification")
        self.setNotificationFlag(True)

    def on_notifications_clearindicator(self, directive):
        """
        Handles lowering the flag when notifications aere cleared
        """
        self.outputToConsole("Cleared notification")
        self.setNotificationFlag(False)

    def on_custom_mindstorms_gadget_control(self, directive):
        """
        Handles the Custom.Mindstorms.Gadget control directive.
        :param directive: the custom directive with the matching namespace and name
        """
        try:
            inputRecognised = False

            # get the control type from the directive payload so we can act on it
            payload = json.loads(directive.payload.decode("utf-8"))
            print("Control payload: {}".format(payload))
            control_type = payload["type"]

            if control_type == "move":
                self.outputToConsole("We have a move request")

                # have we been asked to stop or move?
                if payload["direction"] in Direction.STOP.value:
                    self.tankDrive.off()
                    inputRecognised = True
                else:
                    self.move(payload["direction"], float(payload["distance"]), SPEED)
                    inputRecognised = True

            if control_type == "command":
                self.outputToConsole("We have a command to action")

                # have we been asked to tilt?
                if payload["command"] in Command.TILT.value:
                    self.tilt()
                    inputRecognised = True

                # have we been asked to print data?
                if payload["command"] in Command.PRINT_DATA.value:
                    self.printData()
                    inputRecognised = True

                # have we been asked to provide the battery level?
                if payload["command"] in Command.BATTERY.value:
                    self.getBatteryInfo()
                    inputRecognised = True

                # have we been asked to set the current location?
                if payload["command"] in Command.LOCATION_SET_CURRENT.value:
                    self.setCurrentLocation(payload["location"])
                    inputRecognised = True

                # have we been asked to save a location?
                if payload["command"] in Command.LOCATION_SAVE.value:
                    self.saveMovesAsLocation(payload["location"])
                    inputRecognised = True

                # have we been asked to go to a location?
                if payload["command"] in Command.LOCATION_GOTO.value:
                    self.moveFromAtoB(payload["location"])
                    inputRecognised = True

                # have we been asked to remove a location?
                if payload["command"] in Command.LOCATION_REMOVE.value:
                    self.removeLocation(payload["location"])
                    inputRecognised = True

                # have we been asked to power down?
                if payload["command"] in Command.POWER_DOWN.value:
                    self.powerDown = True
                    inputRecognised = True
                    subprocess.call(["sudo", "poweroff"])  # the NXT brick is on a 2 minute timeout without a keep alive so will power down a few minutes after this EV3 brick does

            # if we've moved somewhere and we've not detected something in the way then let the user know we're done
            if self.movedSomewhere and not self.forwardStop and not self.backwardStop:
                self.makeAlexaSpeak("Done")

            # if we didn't recognise the input then tell the user
            if not inputRecognised:
                self.makeAlexaSpeak("Sorry, I didn't understand that command")

        except KeyError:
            print("Missing expected parameters: {}".format(directive))

    def outputToConsole(self, outputData):
        """
        Output stuff to the console
        """
        print("\nLog: " + outputData + "\n")

    def makeAlexaSpeak(self, speech):
        """
        Send a custom event to the skill making Alexa say the given speech
        """
        print("\nAlexa: " + speech + "\n")
        self.send_custom_event("Custom.Mindstorms.Gadget", "Speech", {"speechOut": speech})

    def detection(self):
        """
        Detect edges and objects using the sensors
        """
        startTime = time.time()

        toldUserAboutForwardStop = False
        toldUserAboutBackwardStop = False

        while not self.powerDown:
            # keep the NXT brick on with a keep alive every 30 seconds (apparently getting a sample won't do, it needs a specific keep alive)
            if time.time() - startTime > 30:
                self.outputToConsole("Keeping the NXT brick alive ({})".format(datetime.now()))
                self.setTheTimeRecently = False
                self.nxtBrick.keep_alive()
                startTime = time.time()

            # get sensor data
            self.frontLeftNxtUltrasonicDistance = self.frontLeftNxtUltrasonic.distance_centimeters
            self.frontRightNxtUltrasonicDistance = self.frontRightNxtUltrasonic.distance_centimeters
            self.frontEv3InfraRedProximity = self.frontEv3InfraRed.proximity
            self.rearEv3ColourReflectedLight = self.rearEv3Colour.reflected_light_intensity
            self.nxtTiltButtonPressed = self.nxtTiltButton.get_sample()
            self.nxtFlagButtonPressed = self.nxtFlagButton.get_sample()

            # decide if forward movement is allowed
            if self.frontLeftNxtUltrasonicDistance > SURFACE_DISTANCE_MIN or self.frontRightNxtUltrasonicDistance > SURFACE_DISTANCE_MIN or self.frontEv3InfraRedProximity < PROXIMITY_DISTANCE_MIN:
                if not self.forwardStop:
                    self.printData()

                self.forwardStop = True
            else:
                self.forwardStop = False
                toldUserAboutForwardStop = False

            # decide if backward movement is allowed
            if self.rearEv3ColourReflectedLight < REFELECTED_LIGHT_MIN:
                if not self.backwardStop:
                    self.printData()

                self.backwardStop = True
            else:
                self.backwardStop = False
                toldUserAboutBackwardStop = False

            if self.forwardStop and not toldUserAboutForwardStop:
                if self.frontEv3InfraRedProximity < PROXIMITY_DISTANCE_MIN:
                    self.makeAlexaSpeak("Woah, there's something in the way")
                else:
                    self.makeAlexaSpeak("That was close!")
                toldUserAboutForwardStop = True

            if self.backwardStop and not toldUserAboutBackwardStop:
                self.makeAlexaSpeak("That was a near miss")
                toldUserAboutBackwardStop = True

            time.sleep(0.1)

        self.outputToConsole("Exiting edge and object detection loop")

    def move(self, direction, distance, speed):
        """
        Move the robot in the specified direction
        """
        DEGREES_OF_DRIVE_PER_CM = 35  # 350 degrees of drive is approx. 10cm so 35 degrees is approx. 1cm
        DEGREES_OF_DRIVE_TO_TURN_ONE_DEGREE = 5.22  # 470 degrees of drive on one motor gives a 90 degree turn so 5.22 degrees would give a 1 degree turn
        DEGREES_OF_DRIVE_TO_TURN_NINETY_DEGREES = 470

        self.outputToConsole("Move - Direction: {0}, Distance: {1}, Speed: {2}".format(direction, distance, speed))

        self.movedSomewhere = False

        # do we recognise the move?
        if (
            (direction not in Direction.FORWARD.value) and (direction not in Direction.BACKWARD.value) and (direction not in Direction.LEFT.value)
            and (direction not in Direction.RIGHT.value) and (direction not in Direction.STOP.value)
        ):
            self.makeAlexaSpeak("Direction not recognised")
            return

        # no negative distances allowed
        if float(distance) < 0:
            self.makeAlexaSpeak("Distance cannot be negative, the requested move will not be made")
            return

        # no negative speeds allowed
        if int(speed) < 0:
            self.makeAlexaSpeak("Speed cannot be negative, the requested move will not be made")
            return

        # record the starting degrees so that we can eventually record distance moved if required
        motorStartDegrees = self.largeMotorB.degrees

        # no moving while tilted so level off before moving if required
        if not self.nxtTiltButtonPressed:
            self.tilt()

        # stop if we've been asked to
        if direction in Direction.STOP.value and self.tankDrive.is_running:
            self.tankDrive.off()

        # move forward either the specified distance or while we can
        if direction in Direction.FORWARD.value and not self.forwardStop:
            self.currentLocationName = LOCATION_UNKNOWN
            self.movedSomewhere = True

            if distance != 0:
                degreesOfDrive = float(distance) * DEGREES_OF_DRIVE_PER_CM  # convert centimetres to degrees of drive
                self.tankDrive.on_for_degrees(speed, speed, degreesOfDrive, True, False)
            else:
                self.tankDrive.on(speed, speed)

            # wait till we're done moving
            while not self.forwardStop and not self.powerDown and self.tankDrive.is_running:
                time.sleep(0.1)

        # move backward either the specified distance or while we can
        if direction in Direction.BACKWARD.value and not self.backwardStop:
            self.currentLocationName = LOCATION_UNKNOWN
            self.movedSomewhere = True

            if distance != 0:
                degreesOfDrive = float(distance) * DEGREES_OF_DRIVE_PER_CM  # convert centimetres to degrees of drive
                self.tankDrive.on_for_degrees(-speed, -speed, degreesOfDrive, True, False)
            else:
                self.tankDrive.on(-speed, -speed)

            # wait till we're done moving
            while not self.backwardStop and not self.powerDown and self.tankDrive.is_running:
                time.sleep(0.1)

        # move left the specified degrees or 90 degrees
        if direction in Direction.LEFT.value and not self.forwardStop and not self.backwardStop:
            self.currentLocationName = LOCATION_UNKNOWN
            self.movedSomewhere = True

            if distance != 0:
                degreesOfDrive = float(distance) * DEGREES_OF_DRIVE_TO_TURN_ONE_DEGREE  # convert degrees to degrees of drive
                self.tankDrive.on_for_degrees(speed, -speed, degreesOfDrive, True, False)
            else:
                self.tankDrive.on_for_degrees(speed, -speed, DEGREES_OF_DRIVE_TO_TURN_NINETY_DEGREES, True, False)

            # wait till we're done moving
            while not self.forwardStop and not self.backwardStop and not self.powerDown and self.tankDrive.is_running:
                time.sleep(0.1)

        # move right the specified degrees or 90 degrees
        if direction in Direction.RIGHT.value and not self.forwardStop and not self.backwardStop:
            self.currentLocationName = LOCATION_UNKNOWN
            self.movedSomewhere = True

            if distance != 0:
                degreesOfDrive = float(distance) * DEGREES_OF_DRIVE_TO_TURN_ONE_DEGREE  # convert degrees to degrees of drive
                self.tankDrive.on_for_degrees(-speed, speed, degreesOfDrive, True, False)
            else:
                self.tankDrive.on_for_degrees(-speed, speed, DEGREES_OF_DRIVE_TO_TURN_NINETY_DEGREES, True, False)

            # wait till we're done moving
            while not self.forwardStop and not self.backwardStop and not self.powerDown and self.tankDrive.is_running:
                time.sleep(0.1)

        # and we're done moving
        if self.tankDrive.is_running:
            self.tankDrive.off()

        # add the move to the moves list
        if self.recordMoves:
            if self.forwardStop or self.backwardStop:
                if direction in Direction.FORWARD.value or direction in Direction.BACKWARD.value:
                    calculatedDistance = round((abs(self.largeMotorB.degrees - motorStartDegrees) / DEGREES_OF_DRIVE_PER_CM), 2)
                else:
                    calculatedDistance = round((abs(self.largeMotorB.degrees - motorStartDegrees) / DEGREES_OF_DRIVE_TO_TURN_ONE_DEGREE), 2)

                self.movesList.append(Move(direction=direction, distance=calculatedDistance, speed=speed))
            else:
                self.movesList.append(Move(direction=direction, distance=distance, speed=speed))

    def dance(self):
        """
        Make the stand do a short side to side dance
        """
        SMALL_MOVE = 6
        BIG_MOVE = 12
        MOVE_SPEED = 40

        # we don't want to record dance moves
        self.recordMoves = False

        # shimmy a bit to the left to the left
        self.move("left", SMALL_MOVE, MOVE_SPEED)

        # swing right and left
        moveCount = 0
        while moveCount < 4:
            self.move("right", BIG_MOVE, MOVE_SPEED)
            self.move("left", BIG_MOVE, MOVE_SPEED)
            moveCount += 1

        # shimmy a little left one last time to be back where we were (in theory ...)
        self.move("right", BIG_MOVE, MOVE_SPEED)
        self.move("left", SMALL_MOVE, MOVE_SPEED)

        # done dancing so let's record moves again
        self.recordMoves = True

    def setNotificationFlag(self, raiseFlag):
        """
        Raise or lower the notification flag
        """
        if raiseFlag and self.nxtFlagButtonPressed:
            self.nxtNotificationMotor.on_for_degrees(-10, 95)
            self.outputToConsole("Raising the flag")
        else:
            if not raiseFlag and not self.nxtFlagButtonPressed:
                self.nxtNotificationMotor.on_for_degrees(10, 95)
                self.outputToConsole("Lowering the flag")

    def runTimerSpinner(self, power):
        """
        Spin or stop the end of timer spinner
        """
        if power > 0:
            self.nxtTimerMotor.run(-power)
        else:
            self.nxtTimerMotor.idle()

    def tilt(self):
        """
        Raise or lower the Echo Show
        """
        # add the move to the moves list
        if self.recordMoves:
            self.movesList.append(Move(direction="tilt", distance=0, speed=0))

        # no tilting and driving at the same time
        self.tankDrive.off()

        # tilt the opposite way to the current tilt
        if self.nxtTiltButtonPressed:
            tiltDegrees = TILT_DEGREES
            self.tilted = True
            self.outputToConsole("Tilting up ...")
        else:
            tiltDegrees = -TILT_DEGREES
            self.tilted = False
            self.outputToConsole("Tilting down ...")

        # do the tilt
        self.mediumTiltMotor.on_for_degrees(20, tiltDegrees)
        self.mediumTiltMotor.off()

    def moveFromAtoB(self, locationName):
        """
        Handles getting the stand from A to B, including going home in between
        """

        self.movedSomewhere = False

        # can't go somewhere we don't recognise
        if locationName != LOCATION_HOME and not path.exists(locationName):
            self.makeAlexaSpeak("Hmm, I don't recognise a location called {}".format(locationName))
            return

        # can't go home if we're already home
        if self.currentLocationName == LOCATION_HOME and locationName == LOCATION_HOME:
            self.makeAlexaSpeak("We're already home")
            return

        # if we're home then go to the location, otherwise go home first
        if self.currentLocationName == LOCATION_HOME:
            self.gotoLocation(locationName)
        else:
            self.gotoLocation(LOCATION_HOME)
            self.gotoLocation(locationName)

    def gotoLocation(self, locationName):
        """
        Load the moves for the location and then do the moves
        """
        failedToExecuteMove = False
        moves = []
        haveValidMoves = False

        # we don't want to record moves while moving to a location as we already have the moves in the list
        self.recordMoves = False

        # if we're going home then reverse the moves list and swap forwards and backwards and left and right, otherwise load a moves list
        if locationName == LOCATION_HOME:
            haveValidMoves = True

            for move in reversed(self.movesList):

                if move.direction in Direction.FORWARD.value:
                    moves.append(Move(direction="backward", distance=move.distance, speed=move.speed))

                if move.direction in Direction.BACKWARD.value:
                    moves.append(Move(direction="forward", distance=move.distance, speed=move.speed))

                if move.direction in Direction.LEFT.value:
                    moves.append(Move(direction="right", distance=move.distance, speed=move.speed))

                if move.direction in Direction.RIGHT.value:
                    moves.append(Move(direction="left", distance=move.distance, speed=move.speed))

                if move.direction in Command.TILT.value:
                    moves.append(Move(direction=move.direction, distance=move.distance, speed=move.speed))
        else:
            if self.loadMovesAsLocation(locationName):
                moves = self.movesList
                haveValidMoves = True

        # if we have valid moves then get moving
        if haveValidMoves:
            for move in moves:
                self.outputToConsole("Direction: {0}, Distance: {1}, Speed: {2}".format(move.direction, move.distance, move.speed))

                # we can either tilt or move
                if move.direction in Command.TILT.value:
                    self.tilt()
                else:
                    self.move(move.direction, move.distance, move.speed)

                # if we've found an edge or something in the way and the move was for a specified distance then we likely haven't made it to the location
                if (self.forwardStop or self.backwardStop) and move.distance != 0:
                    failedToExecuteMove = True

            # record that we got there if we did
            if not failedToExecuteMove:
                self.currentLocationName = locationName
            else:
                self.currentLocationName = LOCATION_UNKNOWN

            # if we went home then clear the moves list
            if locationName == LOCATION_HOME:
                self.movesList.clear()

        # we've moved to the location so we can record moves again
        self.recordMoves = True

    def saveMovesAsLocation(self, locationName):
        """
        Saves the currently recorded moves as a JSON file and then clears the moves list
        """

        # don't allow an empty location name
        if locationName == "":
            self.makeAlexaSpeak("You haven't specified a location name")
            return

        # don't allow "home" to be specified as a location
        if locationName == LOCATION_HOME:
            self.makeAlexaSpeak("Home is built in and cannot be used as a name for a custom location")
            return

        # ditch the file if it exists
        if path.exists(locationName):
            self.outputToConsole("Deleting existing location file")
            os.remove(locationName)

        # save the moves list as a JSON file
        with open(locationName, "w") as file:
            json.dump(Location(self.movesList), file, default=lambda o: o.__dict__, sort_keys=False, indent=4)

        # set the current location to the one we've just saved
        self.currentLocationName = locationName

        self.makeAlexaSpeak("Location saved as {}".format(locationName))

    def loadMovesAsLocation(self, locationName):
        """
        Loads the moves from the given JSON file name
        """
        if path.exists(locationName):
            self.currentLocationName = locationName

            with open(locationName, "r") as file:
                self.movesList = Location.from_json(json.load(file)).moves

            self.outputToConsole("Loaded location")
            return True
        else:
            self.makeAlexaSpeak("Hmm, I don't recognise a location called {}".format(locationName))
            return False

    def setCurrentLocation(self, locationName):
        """
        Sets the current location and loads the moves list if not the home location
        """
        self.movedSomewhere = False

        # don't allow an empty location name
        if locationName == "":
            self.makeAlexaSpeak("You haven't specified a location name")
            return

        # nothing to load if the location is home
        if locationName == LOCATION_HOME:
            self.currentLocationName = locationName
            self.movesList.clear()
            self.makeAlexaSpeak("Location set to home")
            return

        # load the location if found
        if path.exists(locationName):
            self.currentLocationName = locationName
            self.loadMovesAsLocation(locationName)
            self.makeAlexaSpeak("Location set to {}".format(locationName))
        else:
            self.currentLocationName = LOCATION_UNKNOWN
            self.makeAlexaSpeak("Hmm, I don't recognise a location called {}".format(locationName))

    def removeLocation(self, locationName):
        """
        Delete the specified location file
        """
        self.movedSomewhere = False

        # don't allow an empty location name
        if locationName == "":
            self.makeAlexaSpeak("You haven't specified a location name")
            return

        # don't allow "home" to be specified as a location
        if locationName == LOCATION_HOME:
            self.makeAlexaSpeak("Home is a built in location and cannot be removed")
            return

        if path.exists(locationName):
            os.remove(locationName)
            self.makeAlexaSpeak("Deleted location {}".format(locationName))
        else:
            self.makeAlexaSpeak("Hmm, I don't recognise a location called {}".format(locationName))

    def printMoves(self):
        """
        Print the recorded moves
        """
        self.movedSomewhere = False

        print("Current Location: {}".format(self.currentLocationName))

        for move in self.movesList:
            print("Direction: {0}, Distance: {1}, Speed: {2}".format(move.direction, move.distance, move.speed))

    def getBatteryInfo(self):
        """
        Return battery info
        """
        self.movedSomewhere = False

        batteryInfo = "My little helper's main battery level is {0:.2f} Volts and it's secondary battery level is {1:.2f} Volts.".format(self.powerSupply.measured_volts, (self.nxtBrick.get_battery_level() / 1000))

        self.makeAlexaSpeak(batteryInfo)

    def printData(self):
        """
        Print the latest data
        """
        print(
            "Front Left US (cm): {0}, Front Right US (cm): {1}, Front IR (cm) {2}, Rear Light (%): {3:.2f}, Tilt Button State: {4}, Flag Button State: {5}, Forward Stop: {6}, Backward Stop: {7}".format(
                self.frontLeftNxtUltrasonicDistance,
                self.frontRightNxtUltrasonicDistance,
                self.frontEv3InfraRedProximity,
                self.rearEv3ColourReflectedLight,
                self.nxtTiltButtonPressed,
                self.nxtFlagButtonPressed,
                self.forwardStop,
                self.backwardStop,
            )
        )

        self.printMoves()


if __name__ == "__main__":
    # Startup sequence
    gadget = MindstormsGadget()
    gadget.sound.set_volume(5, "Beep")

    # Gadget main entry point
    gadget.main()

    # Shutdown sequence
    gadget.sound.play_song((("E5", "e"), ("C4", "e")))

EV3 Application INI File

INI
The INI file that indicates the Alexa Gadget capabilities of the application
# Copyright 2019 Amazon.com, Inc. or its affiliates.  All Rights Reserved.
# 
# You may not use this file except in compliance with the terms and conditions 
# set forth in the accompanying LICENSE.TXT file.
#
# THESE MATERIALS ARE PROVIDED ON AN "AS IS" BASIS. AMAZON SPECIFICALLY DISCLAIMS, WITH 
# RESPECT TO THESE MATERIALS, ALL WARRANTIES, EXPRESS, IMPLIED, OR STATUTORY, INCLUDING 
# THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.

[GadgetSettings]
amazonId = A2WOIOKY5UEA4Z
alexaGadgetSecret = 1B2CECF4672B6EBF4A13432216A5F22937970E6CE83578EAF03CA25B2E874D0F

[GadgetCapabilities]
Custom.Mindstorms.Gadget = 1.0
Alexa.Gadget.StateListener = 1.0 - timeinfo, timers
Alerts = 1.1
Notifications = 1.0

Skill Node JS Lambda File

JavaScript
The Node JS file for the Alexa skill
/*
 * Copyright 2019 Amazon.com, Inc. or its affiliates.  All Rights Reserved.
 *
 * You may not use this file except in compliance with the terms and conditions 
 * set forth in the accompanying LICENSE.TXT file.
 *
 * THESE MATERIALS ARE PROVIDED ON AN "AS IS" BASIS. AMAZON SPECIFICALLY DISCLAIMS, WITH 
 * RESPECT TO THESE MATERIALS, ALL WARRANTIES, EXPRESS, IMPLIED, OR STATUTORY, INCLUDING 
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
*/

// This skill sample demonstrates how to send directives and receive events from an Echo connected gadget.
// This skill uses the Alexa Skills Kit SDK (v2). Please visit https://alexa.design/cookbook for additional
// examples on implementing slots, dialog management, session persistence, api calls, and more.

/*
ALEXA'S LITTLE HELPER (Skill invocation "your little helper")

Creator
-------
Keith Rosenheck 2019

Credits
-------
I learnt from a lot of good work by a lot of good people to help me get this project done. This included copying sample code and modifying it to suit this project.
My main sources of information and examples were:

- The tutorial for the Lego MindStorms Voice Challenge:
    - https://www.hackster.io/alexagadgets/lego-mindstorms-voice-challenge-setup-17300f

- Amazon's Gadget Toolkit and Rasberry PI Examples:
    - https://developer.amazon.com/en-US/docs/alexa/alexa-gadgets-toolkit/features.html
    - https://github.com/alexa/Alexa-Gadgets-Raspberry-Pi-Samples
*/

const Alexa = require('ask-sdk-core');
const Util = require('./util');
const Common = require('./common');

// The namespace of the custom directive to be sent by this skill
const NAMESPACE = 'Custom.Mindstorms.Gadget';

// The name of the custom directive to be sent this skill
const NAME_CONTROL = 'control';

async function handleTheSession(handlerInput) {
    let sessionAttributes = handlerInput.attributesManager.getSessionAttributes();

    // act based on whether we're already in a session or not based on checking if we have an endpointID in session
    if (!sessionAttributes.endpointId) {
        const request = handlerInput.requestEnvelope;
        const { apiEndpoint, apiAccessToken } = request.context.System;
        const apiResponse = await Util.getConnectedEndpoints(apiEndpoint, apiAccessToken);
        
        // no endpoint, no dice
        if ((apiResponse.endpoints || []).length === 0) {
            Util.putSessionAttribute(handlerInput, 'haveEndpoint', "no");
            return
        }

        // say the session has been created and we have an endpoint
        Util.putSessionAttribute(handlerInput, 'sessionState', "created");
        Util.putSessionAttribute(handlerInput, 'haveEndpoint', "yes");

        // Store the gadget endpointId to be used in this skill session
        const endpointId = apiResponse.endpoints[0].endpointId || [];
        Util.putSessionAttribute(handlerInput, 'endpointId', endpointId);

        // Set skill duration to 5 minutes (ten 60-seconds interval)
        Util.putSessionAttribute(handlerInput, 'duration', 10);

        // Set the token to track the event handler
        const token = handlerInput.requestEnvelope.request.requestId;
        Util.putSessionAttribute(handlerInput, 'token', token);
    }
    else
    {
        // say the session was already active
        Util.putSessionAttribute(handlerInput, 'sessionState', "active");
    }
}

const LaunchRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest';
    },
    handle: async function (handlerInput) {
        // sort out the things we need for a session like the endpointID, event token and so on
        await handleTheSession(handlerInput);

        // get the session data to use
        let sessionAttributes = handlerInput.attributesManager.getSessionAttributes();
        
        // no endpoint, no dice
        if (sessionAttributes.haveEndpoint == "no") {
            return handlerInput.responseBuilder
                .speak(`I couldn't find my little helper, are you sure it's turned on and it's Bluetooth is working?.`)
                .withShouldEndSession(true)
                .getResponse();
        }
        
        // say we're ready to go
        return handlerInput.responseBuilder
            .speak("My little helper has been activated")
            .reprompt("Do you need my little helper to do something?")
            .addDirective(Util.buildStartEventHandler(sessionAttributes.token, 60000, {}))
            .getResponse();
    }
};

const YesIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.YesIntent';
    },
    handle: async function (handlerInput) {
        const attributesManager = handlerInput.attributesManager;
        let sessionAttributes = attributesManager.getSessionAttributes();

        if (sessionAttributes.lastQuestion == "powerDown_AreYouSure") {
            // Construct the directive with the payload containing the command
            let directive = Util.build(sessionAttributes.endpointId, NAMESPACE, NAME_CONTROL,
                {
                    type: 'command',
                    command: "power_down"
                });

            return handlerInput.responseBuilder
                .speak("Ok, telling my little helper to power down")
                .addDirective(directive)
                .withShouldEndSession(true)
                .getResponse();
        }
        else {
            return handlerInput.responseBuilder
                .speak("Hmm, I'm not sure what you're saying yes to")
                .withShouldEndSession(false)
                .getResponse();
        } 
    }
};

const NoIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.NoIntent';
    },
    handle: async function (handlerInput) {
        const attributesManager = handlerInput.attributesManager;
        let sessionAttributes = attributesManager.getSessionAttributes();
        
        if (sessionAttributes.lastQuestion == "powerDown_AreYouSure") {
            return handlerInput.responseBuilder
                .speak("That's good, keeping my little helper switched on")
                .withShouldEndSession(false)
                .getResponse();
        }
        else if (sessionAttributes.lastQuestion == "general_AnythingElse") {
            return handlerInput.responseBuilder
                .speak("Ok, no problem")
                .withShouldEndSession(true)
                .getResponse();
        }
        else {
            return handlerInput.responseBuilder
                .speak("Hmm, I'm not sure what you're saying no to")
                .withShouldEndSession(false)
                .getResponse();
        } 
    }
};

// Construct and send a custom directive to the connected gadget with
// data from the MoveIntent.
const MoveIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'MoveIntent';
    },
    handle: async function (handlerInput) {
        const request = handlerInput.requestEnvelope;
        const direction = Alexa.getSlotValue(request, 'Direction');

        // Distance is optional
        const distance = Alexa.getSlotValue(request, 'Distance') || "0";

        // sort out the things we need for a session like the endpointID, event token and so on
        await handleTheSession(handlerInput);

        // get the session data to use
        let sessionAttributes = handlerInput.attributesManager.getSessionAttributes();

        // no endpoint, no dice
        if (sessionAttributes.haveEndpoint == "no") {
            return handlerInput.responseBuilder
                .speak(`I couldn't find my little helper, are you sure it's turned on and it's Bluetooth is working?.`)
                .withShouldEndSession(true)
                .getResponse();
        }

        // Construct the directive with the payload containing the move parameters
        let directive = Util.build(sessionAttributes.endpointId, NAMESPACE, NAME_CONTROL,
            {
                type: 'move',
                direction: direction,
                distance: distance
            });

        // return with or without a directive to add a start event handler based on whether the session is already active or not
        if (sessionAttributes.sessionState == "active") {
            return handlerInput.responseBuilder
                .addDirective(directive)
                .getResponse();
        }
        else {
            return handlerInput.responseBuilder
                .addDirective(Util.buildStartEventHandler(sessionAttributes.token, 60000, {}))
                .addDirective(directive)
                .getResponse();
        }
    }
};

// Construct and send a custom directive to the connected gadget with data from
// the SetCommandIntent.
const SetCommandIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'SetCommandIntent';
    },
    handle: async function (handlerInput) {
        const request = handlerInput.requestEnvelope;

        let command = Alexa.getSlotValue(request, 'Command');
        if (!command) {
            return handlerInput.responseBuilder
                .speak("Can you repeat that?")
                .withShouldEndSession(false)
                .getResponse();
        }

        // location is optional
        const location = Alexa.getSlotValue(request, 'Location') || "";

        // sort out the things we need for a session like the endpointID, event token and so on
        await handleTheSession(handlerInput);

        // get the session data to use
        let sessionAttributes = handlerInput.attributesManager.getSessionAttributes();

        // no endpoint, no dice
        if (sessionAttributes.haveEndpoint == "no") {
            return handlerInput.responseBuilder
                .speak(`I couldn't find my little helper, are you sure it's turned on and it's Bluetooth is working?.`)
                .withShouldEndSession(true)
                .getResponse();
        }
         
        if ('power down, turn off, switch off'.includes(command)) {
            Util.putSessionAttribute(handlerInput, 'lastQuestion', "powerDown_AreYouSure");

            return handlerInput.responseBuilder
                .speak("Are you sure you want to turn my little helper off? That will make me sad.")
                .reprompt("So, did you want me to turn my little helper off?")
                .withShouldEndSession(false)
                .getResponse();
        }
        else {
            // Construct the directive with the payload containing the command
            let directive = Util.build(sessionAttributes.endpointId, NAMESPACE, NAME_CONTROL,
                {
                    type: 'command',
                    command: command,
                    location: location
                });

            // return with or without a directive to add a start event handler based on whether the session is already active or not
            if (sessionAttributes.sessionState == "active") {
                return handlerInput.responseBuilder
                    .addDirective(directive)
                    .getResponse();
            }
            else {
                return handlerInput.responseBuilder
                    .addDirective(Util.buildStartEventHandler(sessionAttributes.token, 60000, {}))
                    .addDirective(directive)
                    .getResponse();
            }
        }
    }
};

const EventsReceivedRequestHandler = {
    // Checks for a valid token and endpoint.
    canHandle(handlerInput) {
        let { request } = handlerInput.requestEnvelope;
        console.log('Request type: ' + Alexa.getRequestType(handlerInput.requestEnvelope));
        if (request.type !== 'CustomInterfaceController.EventsReceived') return false;

        const attributesManager = handlerInput.attributesManager;
        let sessionAttributes = attributesManager.getSessionAttributes();
        
        // Validate event token
        if (sessionAttributes.token !== request.token) {
            console.log("Event token doesn't match. Ignoring this event");
            return false;
        }
        return true;
    },
    handle(handlerInput) {
        console.log("== Received Custom Event ==");
        let customEvent = handlerInput.requestEnvelope.request.events[0];
        let payload = customEvent.payload;
        let name = customEvent.header.name;

        let speechOutput;

        if (name === 'Speech') {
            speechOutput = payload.speechOut;

        } else {
            speechOutput = "Event not recognized. Awaiting new command.";
        }
        
        Util.putSessionAttribute(handlerInput, 'lastQuestion', "general_AnythingElse");

        return handlerInput.responseBuilder
            .speak(speechOutput, "REPLACE_ALL")
            .reprompt("Need my little helper to do anything else?")
            .getResponse();
    }
};

const ExpiredRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'CustomInterfaceController.Expired'
    },
    handle(handlerInput) {
        console.log("== Custom Event Expiration Input ==");

        // Set the token to track the event handler
        const token = handlerInput.requestEnvelope.request.requestId;
        Util.putSessionAttribute(handlerInput, 'token', token);

        const attributesManager = handlerInput.attributesManager;
        let duration = attributesManager.getSessionAttributes().duration || 0;

        if (duration > 0) {
            Util.putSessionAttribute(handlerInput, 'duration', --duration);

            return handlerInput.responseBuilder
                .withShouldEndSession(false)
                .addDirective(Util.buildStartEventHandler(token, 60000, {}))
                .getResponse();
        }
        else {
            // End skill session
            return handlerInput.responseBuilder
                .speak("My little helper's taking a nap now.")
                .withShouldEndSession(true)
                .getResponse();
        }
    }
};

// The SkillBuilder acts as the entry point for your skill, routing all request and response
// payloads to the handlers above. Make sure any new handlers or interceptors you've
// defined are included below. The order matters - they're processed top to bottom.
exports.handler = Alexa.SkillBuilders.custom()
    .addRequestHandlers(
        LaunchRequestHandler,
        SetCommandIntentHandler,
        MoveIntentHandler,
        EventsReceivedRequestHandler,
        ExpiredRequestHandler,
        YesIntentHandler,
        NoIntentHandler,
        Common.HelpIntentHandler,
        Common.CancelAndStopIntentHandler,
        Common.SessionEndedRequestHandler,
        Common.IntentReflectorHandler, // make sure IntentReflectorHandler is last so it doesn't override your custom intent handlers
    )
    .addRequestInterceptors(Common.RequestInterceptor)
    .addErrorHandlers(
        Common.ErrorHandler,
    )
    .lambda();

Alexa Skill Common Functions

JavaScript
Alexa skill common functions as provided by the challenge tutorials
/*
 * Copyright 2019 Amazon.com, Inc. or its affiliates.  All Rights Reserved.
 *
 * You may not use this file except in compliance with the terms and conditions 
 * set forth in the accompanying LICENSE.TXT file.
 *
 * THESE MATERIALS ARE PROVIDED ON AN "AS IS" BASIS. AMAZON SPECIFICALLY DISCLAIMS, WITH 
 * RESPECT TO THESE MATERIALS, ALL WARRANTIES, EXPRESS, IMPLIED, OR STATUTORY, INCLUDING 
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
*/
'use strict'

const Alexa = require('ask-sdk-core');

const HelpIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.HelpIntent';
    },
    handle(handlerInput) {
        const speakOutput = 'You can say hello to me! How can I help?';

        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};
const CancelAndStopIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && (Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.CancelIntent'
                || Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.StopIntent');
    },
    handle(handlerInput) {
        const speakOutput = 'Goodbye!';
        return handlerInput.responseBuilder
            .speak(speakOutput)
            .getResponse();
    }
};
const SessionEndedRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'SessionEndedRequest';
    },
    handle(handlerInput) {
        // Any cleanup logic goes here.
        return handlerInput.responseBuilder.getResponse();
    }
};

// The intent reflector is used for interaction model testing and debugging.
// It will simply repeat the intent the user said. You can create custom handlers
// for your intents by defining them above, then also adding them to the request
// handler chain below.
const IntentReflectorHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest';
    },
    handle(handlerInput) {
        const intentName = Alexa.getIntentName(handlerInput.requestEnvelope);
        const speakOutput = `You just triggered ${intentName}`;

        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt("I don't understand this command, try again")
            .getResponse();
    }
};

// Generic error handling to capture any syntax or routing errors. If you receive an error
// stating the request handler chain is not found, you have not implemented a handler for
// the intent being invoked or included it in the skill builder below.
const ErrorHandler = {
    canHandle() {
        return true;
    },
    handle(handlerInput, error) {
        console.log(`~~~~ Error handled: ${error.stack}`);
        const speakOutput = `Sorry, I had trouble doing what you asked. Please try again.`;

        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};

// The request interceptor is used for request handling testing and debugging.
// It will simply log the request in raw json format before any processing is performed.
const RequestInterceptor = {
    process(handlerInput) {
        let { attributesManager, requestEnvelope } = handlerInput;
        let sessionAttributes = attributesManager.getSessionAttributes();

        // Log the request for debug purposes.
        console.log(`=====Request==${JSON.stringify(requestEnvelope)}`);
        console.log(`=========SessionAttributes==${JSON.stringify(sessionAttributes, null, 2)}`);
    }
};

module.exports = {
    HelpIntentHandler,
    CancelAndStopIntentHandler,
    SessionEndedRequestHandler,
    IntentReflectorHandler,
    ErrorHandler,
    RequestInterceptor
};

Alexa Skill Utility Functions

JavaScript
Alexa skill utility functions as provided by the challenge tutorials
/*
 * Copyright 2019 Amazon.com, Inc. or its affiliates.  All Rights Reserved.
 *
 * You may not use this file except in compliance with the terms and conditions 
 * set forth in the accompanying LICENSE.TXT file.
 *
 * THESE MATERIALS ARE PROVIDED ON AN "AS IS" BASIS. AMAZON SPECIFICALLY DISCLAIMS, WITH 
 * RESPECT TO THESE MATERIALS, ALL WARRANTIES, EXPRESS, IMPLIED, OR STATUTORY, INCLUDING 
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
*/

'use strict';

const Https = require('https');
const AWS = require('aws-sdk');
const Escape = require('lodash/escape');

const s3SigV4Client = new AWS.S3({
    signatureVersion: 'v4'
});

/**
 * Get the authenticated URL to access the S3 Object. This URL expires after 60 seconds.
 * @param s3ObjectKey - the S3 object key
 * @returns {string} the pre-signed S3 URL
 */
exports.getS3PreSignedUrl = function getS3PreSignedUrl(s3ObjectKey) {

    const bucketName = process.env.S3_PERSISTENCE_BUCKET;
    return Escape(s3SigV4Client.getSignedUrl('getObject', {
        Bucket: bucketName,
        Key: s3ObjectKey,
        Expires: 60 // the Expires is capped for 1 minute
    }));
};

/**
 * Builds a directive to start the EventHandler.
 * @param token - a unique identifier to track the event handler
 * @param {number} timeout - the duration to wait before sending back the expiration
 * payload to the skill.
 * @param payload - the expiration json payload
 * @see {@link https://developer.amazon.com/docs/alexa-gadgets-toolkit/receive-custom-event-from-gadget.html#start}
 */
exports.buildStartEventHandler = function (token, timeout = 30000, payload)  {
    return {
        type: "CustomInterfaceController.StartEventHandler",
        token: token,
        expiration : {
            durationInMilliseconds: timeout,
            expirationPayload: payload
        }
    };
};

/**
 *
 * Builds a directive to stops the active event handler.
 * The event handler is identified by the cached token in the session attribute.
 * @param {string} handlerInput - the JSON payload from Alexa Service
 * @see {@link https://developer.amazon.com/docs/alexa-gadgets-toolkit/receive-custom-event-from-gadget.html#stop}
 */
exports.buildStopEventHandlerDirective = function (handlerInput) {

    let token = handlerInput.attributesManager.getSessionAttributes().token || '';
    return {
        "type": "CustomInterfaceController.StopEventHandler",
        "token": token
    }
};

/**
 * Build a custom directive payload to the gadget with the specified endpointId
 * @param {string} endpointId - the gadget endpoint Id
 * @param {string} namespace - the namespace of the skill
 * @param {string} name - the name of the skill within the scope of this namespace
 * @param {object} payload - the payload data
 * @see {@link https://developer.amazon.com/docs/alexa-gadgets-toolkit/send-gadget-custom-directive-from-skill.html#respond}
 */
exports.build = function (endpointId, namespace, name, payload) {
    // Construct the custom directive that needs to be sent
    // Gadget should declare the capabilities in the discovery response to
    // receive the directives under the following namespace.
    return {
        type: 'CustomInterfaceController.SendDirective',
        header: {
            name: name,
            namespace: namespace
        },
        endpoint: {
            endpointId: endpointId
        },
        payload
    };
};

/**
 * A convenience routine to add the a key-value pair to the session attribute.
 * @param handlerInput - the handlerInput from Alexa Service
 * @param key - the key to be added
 * @param value - the value be added
 */
exports.putSessionAttribute = function(handlerInput, key, value) {
    const attributesManager = handlerInput.attributesManager;
    let sessionAttributes = attributesManager.getSessionAttributes();
    sessionAttributes[key] = value;
    attributesManager.setSessionAttributes(sessionAttributes);
};

/**
 * To get a list of all the gadgets that meet these conditions,
 * Call the Endpoint Enumeration API with the apiEndpoint and apiAccessToken to
 * retrieve the list of all connected gadgets.
 *
 * @param {string} apiEndpoint - the Endpoint API url
 * @param {string} apiAccessToken  - the token from the session object in the Alexa request
 * @see {@link https://developer.amazon.com/docs/alexa-gadgets-toolkit/send-gadget-custom-directive-from-skill.html#call-endpoint-enumeration-api}
 */
exports.getConnectedEndpoints = function(apiEndpoint, apiAccessToken) {

    // The preceding https:// need to be stripped off before making the call
    apiEndpoint = (apiEndpoint || '').replace('https://', '');

    return new Promise(((resolve, reject) => {

        const options = {
            host: apiEndpoint,
            path: '/v1/endpoints',
            method: 'GET',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Bearer ' + apiAccessToken
            }
        };

        const request = Https.request(options, (response) => {
            response.setEncoding('utf8');
            let returnData = '';
            response.on('data', (chunk) => {
                returnData += chunk;
            });

            response.on('end', () => {
                resolve(JSON.parse(returnData));
            });

            response.on('error', (error) => {
                reject(error);
            });
        });
        request.end();
    }));
};

Skill Model

JSON
Alexa skill JSON model
{
  "interactionModel": {
      "languageModel": {
          "invocationName": "your little helper",
          "intents": [
              {
                  "name": "AMAZON.CancelIntent",
                  "samples": []
              },
              {
                  "name": "AMAZON.HelpIntent",
                  "samples": []
              },
              {
                  "name": "AMAZON.StopIntent",
                  "samples": []
              },
              {
                  "name": "AMAZON.NavigateHomeIntent",
                  "samples": []
              },
              {
                  "name": "MoveIntent",
                  "slots": [
                      {
                          "name": "Direction",
                          "type": "DirectionType"
                      },
                      {
                          "name": "Distance",
                          "type": "AMAZON.NUMBER"
                      }
                  ],
                  "samples": [
                      "turn {Direction} {Distance} degrees",
                      "turn {Direction}",
                      "move {Direction}",
                      "{Direction} {Distance}",
                      "move {Direction} {Distance}",
                      "{Direction} {Distance} centimeters",
                      "move {Direction} {Distance} centimeters",
                      "move {Direction} for {Distance} centimeters"
                  ]
              },
              {
                  "name": "SetCommandIntent",
                  "slots": [
                      {
                          "name": "Command",
                          "type": "CommandType"
                      },
                      {
                          "name": "Location",
                          "type": "LocationType"
                      }
                  ],
                  "samples": [
                      "the {Command}",
                      "{Command} location as {Location}",
                      "{Command} the current location as {Location}",
                      "{Command} location {Location}",
                      "{Command} position as {Location}",
                      "{Command} the current position as {Location}",
                      "{Command} position {Location}",
                      "{Command} as {Location}",
                      "{Command} to {Location}",
                      "{Command} {Location}",
                      "{Command}",
                      "what is the {Command}"
                  ]
              },
              {
                  "name": "AMAZON.YesIntent",
                  "samples": []
              },
              {
                  "name": "AMAZON.NoIntent",
                  "samples": []
              }
          ],
          "types": [
              {
                  "name": "DirectionType",
                  "values": [
                      {
                          "name": {
                              "value": "brake"
                          }
                      },
                      {
                          "name": {
                              "value": "go backward"
                          }
                      },
                      {
                          "name": {
                              "value": "go forward"
                          }
                      },
                      {
                          "name": {
                              "value": "go right"
                          }
                      },
                      {
                          "name": {
                              "value": "go left"
                          }
                      },
                      {
                          "name": {
                              "value": "right"
                          }
                      },
                      {
                          "name": {
                              "value": "left"
                          }
                      },
                      {
                          "name": {
                              "value": "backwards"
                          }
                      },
                      {
                          "name": {
                              "value": "backward"
                          }
                      },
                      {
                          "name": {
                              "value": "forwards"
                          }
                      },
                      {
                          "name": {
                              "value": "forward"
                          }
                      }
                  ]
              },
              {
                  "name": "CommandType",
                  "values": [
                      {
                          "name": {
                              "value": "remove"
                          }
                      },
                      {
                        "name": {
                            "value": "delete"
                        }
                      },
                      {
                          "name": {
                              "value": "save"
                          }
                      },
                      {
                          "name": {
                              "value": "go to"
                          }
                      },
                      {
                          "name": {
                              "value": "goto"
                          }
                      },
                      {
                        "name": {
                            "value": "set"
                        }
                      },
                      {
                          "name": {
                              "value": "tilt"
                          }
                      },
                      {
                          "name": {
                              "value": "print"
                          }
                      },
                      {
                          "name": {
                              "value": "battery"
                          }
                      },
                      {
                          "name": {
                              "value": "battery level"
                          }
                      },
                      {
                          "name": {
                              "value": "power down"
                          }
                      },
                      {
                          "name": {
                              "value": "turn off"
                          }
                      },
                      {
                          "name": {
                              "value": "switch off"
                          }
                      }
                  ]
              },
              {
                  "name": "LocationType",
                  "values": [
                      {
                          "name": {
                              "value": "music"
                          }
                      },
                      {
                          "name": {
                              "value": "recipe"
                          }
                      },
                      {
                          "name": {
                              "value": "cooking"
                          }
                      },
                      {
                          "name": {
                              "value": "table"
                          }
                      },
                      {
                          "name": {
                              "value": "dining"
                          }
                      },
                      {
                          "name": {
                              "value": "eating"
                          }
                      }
                  ]
              }
          ]
      }
  }
}

Credits

bondington

bondington

1 project • 0 followers

Comments