Saturday, November 12, 2016

Amazon Dot Lambda function

Well, we've created a 'thing' and managed to send data from our Raspberry Pi over the internet to it and can see it in the AWSIoT console. Now we'll build a 'Lambda' funtion to interface between the shadow document and the Alexa voice service. The Lambda server is yet another cloud service offered by Amazon, and yet another, step in getting the data all the way back to us on the Amazon Dot. This is where we get to write some more code, this time in node.js.

I'm not going to teach you node.js; mostly because I don't know it all that well, and there are several thousand other sites that can help you with this part of the project. What I will do is show you the first bit of node.js code I put together for my project. This is another piece that is more than just an example; I ran this code for a couple of days getting my feet wet in handling this stuff.

So ... login to the Amazon AWS console. Yes, you've been here before, and you'll be here several more times getting this working. I can't show you a picture of what it will look like for you since this is another one of those pages that change as you do stuff and get more bits of the project implemented. I can show you what it looks like for me:


Look around the screen and find 'Services', it's most likely up in the top black bar like this screen shows, and select it. You'll get the drop down menu of services and look for 'Lambda'. When you get to the next screen, you want to 'Create a Lambda Function'; it should be a big blue button. You be prompted to choose a blueprint for the function. You want to use Node.js 4.3 and you can filter on 'Alexa' to see what they offer for blueprints. I recommend just choose the 'Blank Function' from the menu and use the code I have placed below.

Now, since web postings hang around for years and years, look at the date of this post and remember that things have probably changed a lot since I wrote it and make an informed decision.

Anyway, the next screen wants you to configure a trigger. A trigger in this case is what will be calling your Lambda function, and you want Alexa to be the thing that activates the new function you're going to make. On the screen you'll see a rounded, dashed box beside the word 'Lambda', click there.


That will give you a drop down menu, choose 'Alexa Skills Set', NOT 'Alexa Smart Home'. 'Alexa Smart Home' is an even more complex interface to transfer data that was designed for businesses to implement their various smart devices into the AWS environment. I went that way at first and was driven to the brink of tossing the Dot back in the box and shipping it back to Amazon. I may go look at it again when my distaste subsides a bit, but that will take a while. I just couldn't get past the TON of requirements that seemed to crop up every time I tried to do something. Nope, just use the 'Skills' interface, you'll thank me later.

After you get that part done, click 'Next' and you'll get to the 'Configuration' screen. This is the place you'll do most of your work. When you get some code in and save it, you'll also get a 'Test' and other screens you can play with. For now, name your function and choose the runtime Node.js 4.3. You have other choices there including Python, but I couldn't find enough documentation on the python interface to do what needed to be done. There may be enough documentation in place when you get this far, but I gave up on finding it and just used Node.

A little lower on the screen you'll see a bit of code they supply as an example to get you started, replace that with the code I include below:

//Environment Configuration 

var config = {};
config.IOT_BROKER_ENDPOINT      = "yournumber.iot.us-east-1.amazonaws.com";
config.IOT_BROKER_REGION        = "us-east-1";
config.IOT_THING_NAME           = "house";
config.params                   = { thingName: 'house' };

//Loading AWS SDK libraries
var AWS = require('aws-sdk');

AWS.config.region = config.IOT_BROKER_REGION;
//Initializing client for IoT
var iotData = new AWS.IotData({endpoint: config.IOT_BROKER_ENDPOINT});

// Route the incoming request based on type (LaunchRequest, IntentRequest,
// etc.) The JSON body of the request is provided in the event parameter.
exports.handler = function (event, context) {
    try {
        console.log("event.session.application.applicationId=" + event.session.application.applicationId);

//        if (event.session.application.applicationId !== "amzn1.ask.skill.yoursecretnumbergoeshere") {
//             context.fail("Invalid Application ID");
        }

        if (event.session.new) {
            onSessionStarted({requestId: event.request.requestId}, event.session);
        }

        if (event.request.type === "LaunchRequest") {
            onLaunch(event.request,
                event.session,
                function callback(sessionAttributes, speechletResponse) {
                    context.succeed(buildResponse(sessionAttributes, speechletResponse));
                });
        } else if (event.request.type === "IntentRequest") {
            onIntent(event.request,
                event.session,
                function callback(sessionAttributes, speechletResponse) {
                    context.succeed(buildResponse(sessionAttributes, speechletResponse));
                });
        } else if (event.request.type === "SessionEndedRequest") {
            onSessionEnded(event.request, event.session);
            context.succeed();
        }
    } catch (e) {
        context.fail("Exception: " + e);
    }
};

/**
 * Called when the session starts.
 */

function onSessionStarted(sessionStartedRequest, session) {
    console.log("onSessionStarted requestId=" + sessionStartedRequest.requestId +", sessionId=" + session.sessionId);
}

/**
 * Called when the user launches the skill without specifying what they want.
 */
function onLaunch(launchRequest, session, callback) {
    console.log("onLaunch requestId=" + launchRequest.requestId + ", sessionId=" + session.sessionId);

    // Dispatch to your skill's launch.
    getWelcomeResponse(callback);
}

/**
 * Called when the user specifies an intent for this skill.
 */
function onIntent(intentRequest, session, callback) {
    console.log("onIntent requestId=" + intentRequest.requestId + ", sessionId=" + session.sessionId);

    var intent = intentRequest.intent,
        intentName = intentRequest.intent.name;

    //intent handling
    switch (intentName){
        case "GetTemperature":
            getTemperature(intent, session, callback);
            break;
        case "GetHumidity":
            getHumidity(intent, session, callback);
            break;
        case "AMAZON.HelpIntent":
            getHelp(callback);
            break;
        case "AMAZON.StopIntent":
        case "AMAZON.CancelIntent":
            handleSessionEndRequest(callback);
            break;
        default:
            throw "Invalid intent";
    }
}

/**
 * Called when the user ends the session.
 * Is not called when the skill returns shouldEndSession=true.
 */
function onSessionEnded(sessionEndedRequest, session) {
    console.log("onSessionEnded requestId=" + sessionEndedRequest.requestId +
        ", sessionId=" + session.sessionId);
    // Add cleanup logic here
}

// --------------- Functions that control the skill's behavior -----------------------

function getWelcomeResponse(callback) {
    var sessionAttributes = {};
    var cardTitle = "Welcome";
    var repromptText = "Are you there? What would you like to know?";
    var shouldEndSession = false;

    var speechOutput = "Desert home";
    callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

function getHelp(callback) {
    var cardTitle = "Help";
    var repromptText = "Are you there? What would you like to know?";
    var shouldEndSession = false;

    var speechOutput = "Welcome to Desert Home," + 
        "dave is still working on what to say here,";
    callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

function handleSessionEndRequest(callback) {
    var cardTitle = "Session Ended";
    var speechOutput = "Thank you";
    var shouldEndSession = true;
    callback({}, buildSpeechletResponse(cardTitle, speechOutput, null, shouldEndSession));
}

function getTemperature(intent, session, callback, sayit) {
    var cardTitle = "Temperature";
    var repromptText = "";
    var sessionAttributes = {};
    var shouldEndSession = false;
    var temp = 12;
    
   iotData.getThingShadow(config.params, function(err, data) {
       if (err)  {
           console.log(err, err.stack); // an error occurred
           temp = "an error";
        } else {
           //console.log(data.payload);           // successful response
           var payload = JSON.parse(data.payload);
           temp = payload.state.reported.temp;
         }

        speechOutput = "The temperature is " + temp + " degrees fahrenheit,";
        callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
    });
}

function getHumidity(intent, session, callback) {
   var cardTitle = "Humidity";
   var repromptText = "";
   var sessionAttributes = {};
   var shouldEndSession = false;

   var humidity = 12;

   iotData.getThingShadow({ thingName: 'house' }, function(err, data) {
       if (err)  {
           console.log("error back from getThingShadow");
           console.log(err, err.stack); // an error occurred
           humidity = "an error";
        } else {
           //console.log(data.payload);           // successful response
           payload = JSON.parse(data.payload);
           humidity = payload.state.reported.humid;
         }

        speechOutput = "The humidity is " + humidity + " percent.";
        callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
    });
}

// --------------- Helpers that build all of the responses -----------------------
function buildSpeechletResponse(title, output, repromptText, shouldEndSession) {
    return {
        outputSpeech: {
            type: "PlainText",
            text: output
        },
        card: {
            type: "Simple",
            title: title,
            content: output
        },
        reprompt: {
            outputSpeech: {
                type: "PlainText",
                text: repromptText
            }
        },
        shouldEndSession: shouldEndSession
    };
}

function buildResponse(sessionAttributes, speechletResponse) {
    return {
        version: "1.0",
        sessionAttributes: sessionAttributes,
        response: speechletResponse
    };
}

Nope, I'm not going to leave you in the dark; let's step through this code to see what the heck is going on. I won't cover every detail, but I promise to hit all the important parts.

It starts off with a boilerplate that you have to have to get various libraries and such into place and the first items you have to change are:

config.IOT_BROKER_ENDPOINT      = "yournumber.iot.us-east-1.amazonaws.com";
config.IOT_BROKER_REGION        = "us-east-1";
config.IOT_THING_NAME           = "house";
config.params                   = { thingName: 'house' };

Broker endpoint is taken from AWSIoT which we dealt with in the previous posting <link>, and looks like this:


The number you want is in the upper right of the black section and you want only the portion after the 'https://' through the 'com'. Something like: yournumber.iot.us-east-1.amazonaws.com .  Broker_region must be "us-east-1", since that is the only endpoint that currently supports Alexa. Thing name is whatever you named your thing from my previous post, and the last bit is some JSON that will be needed later in the code; just replace 'house' with whatever you named your thing.

Moving on down you'll see the exports handler. This is the actual entry point into the Lambda function and is boilerplate, just use it until you get a feel for what is going on. Later, you may want to change it to do something special, but let's leave it alone for now. One thing I want to mention here is the two lines that are commented out:

//        if (event.session.application.applicationId !== "amzn1.ask.skill.yoursecretnumbergoeshere") {
//             context.fail("Invalid Application ID");

These lines stop the handler from going any further and return an error if the 'application-id' is wrong. There's the possibility that your function could get something intended for some other function and these two lines stop anything bad from happening in that case. However, we don't know what the appliation-id is yet, so comment this check out for now. The application-id can be read once it is working and put into the statement.

A little further down is onSessionStarted and onLaunch, they just log some stuff and Launch calls welcome, we'll talk about it in a bit. Then, you get to onIntent; this is where the work actually starts.

An 'Intent' is nothing more than an entry in a JSON string that is sent to the Lambda function. When I finally get to creating the voice interaction, you'll see that you create this intent which gets forwarded to the Lambda function. That's why the code is relatively simple to understand. This little bit of code illustrates it:

switch (intentName){
        case "GetTemperature":
            getTemperature(intent, session, callback);
            break;
        case "GetHumidity":
            getHumidity(intent, session, callback);
            break;

It's simply comparing the contents of one item in the JSON string sent to a string you defined in the voice interaction stuff that is coming later. In the example I started with there was a rather involved if-then-else construct here that I kept getting lost in, so I changed it to a switch statement so I could keep track of what was supposed to happen; feel free to rebuild it into whatever you want. I happen to like this.

The code that follows is where you get to gather the data from AWSIOT, compose a voice response and hand it back to be sent to the Dot. The fun stuff happens in this code, so to get an understanding of how to work with it, take a closer look at getTemperature().

function getTemperature(intent, session, callback, sayit) {
    var cardTitle = "Temperature";
    var repromptText = "";
    var sessionAttributes = {};
    var shouldEndSession = false;
    var temp = 12;

This is just basic variable set up for a little further down. I intentionally set the temperature to 12 so I could see it change when I get it from the shadow document.

   iotData.getThingShadow(config.params, function(err, data) {
       if (err)  {
           console.log(err, err.stack); // an error occurred
           temp = "an error";
        } else {
           //console.log(data.payload);           // successful response
           var payload = JSON.parse(data.payload);
           temp = payload.state.reported.temp;
         }

This uses one of their interface functions to get entire 'reported' section of the shadow document and parse out just the payload portion. If there's an error, it just sets the temp variable to 'an error', but with no error it gets the temperature you sent up. 

        speechOutput = "The temperature is " + temp + " degrees fahrenheit,";
        callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));

And here is where you tell the Dot what to say, and it's pretty obvious what I did here for the message. The 'callback' routine is how you give control back to Alexa and get out of the Lambda funtion.

I put the humidity function in so you could see how to handle more that one intent. It's the same as temperature. The last two routines handle stuffing your possible responses into a JSON message that is returned to the Alexa code; they make things easier for you and are part of the boilerplate.

And, there you have it, the horrifying Lamba function explained. Look up the page, save it, and we're ready to test it. First though, look at the page for the tabs, Configuration, Triggers and Monitoring. Triggers should already be filled in for you, but double check, it should look like this:


If it doesn't, you need to add in the Alexa Skills Kit as above. The Monitoring tab has cool graphs that are useless at this point. You may want to use them later, but wait until you get something working to play around in there. The Configuration tab needs to be checked, and there's a really important item in there that we have to attend to. First, here's what the screen looks like:


Double check runtime and handler. The one that will probably be messed up is role; you need to select an existing role, but the existing role doesn't exist yet. I fought this forever trying to get things to work. There were several examples out there on the web that showed something for this, but they were wrong. They were correct in how to set the role up, but not the actual permissions needed.

Open another window in the browser and go to AWS IAM, remember that from your previous work in defining the user? Once in the IAM console, select 'Roles' from the left hand side and you'll want to create a new role. Once again, a role is a JSON document that is read to allow permission to do things. You do not want to attach a policy, instead, create an inline policy. Here's the screen I get for this, but remember the screens change, look for similarities:


And, here is the JSON for the policy I came up with that finally worked:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iot:*",
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "iot:*"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
I had to add a separate section to the JSON to allow access to all resources for AWSIot. Most of the examples out there had this all shoved into one section and it just didn't work. Save your policy, give it a name you can remember and go back to the Lambda function and select the (now) existing role you just created. Yes, you may have to refresh the screen to have it read the new role, but you should be used that kind of thing.

Go back to the 'Code' tab and there should be a 'Test' button up at the top. You're not quite ready to test it because you have to create a test event to do anything. There are some examples of test events, but they don't cover enough possibilities to really do you much good. The best way to get a test event is to use the Alexa voice service to create one, but I haven't talked about that yet.

In the next post we'll visit that and create a tiny voice model that will call the lambda function. That will give you a test event that you can plug in and work with. Then you can expand on all of this to your heart's content.

Talk to you next time.

The next post in this series is here <link>

No comments:

Post a Comment