Search code examples
node.jsaws-lambdaalexa-skills-kit

Callback & Async problems with Node & Lambda


I'm creating a lambda function for an Alexa Smart Home skill to control my receiver. I'm using the boiler plate sample code that Amazon provides, but I'm new to node and I'm having a lot of trouble getting it to wait for my callback to set the response. I've been trying to operate as if their general structure is correct here.

I can call this and it executes the POST request just fine, but it always returns null (which Alexa doesn't like). It's not logging anything inside the response.on('end' loop either. Seems like it's blasting right through the marantzAPI call. I've tried using a callback in the marantzAPI function (commands, callback) and I end up with the same result. I think there's something I just don't get here.

Here's the lambda function:

'use strict';

var http = require('http');
var qs = require('querystring');

/**
 * We're not discovering our devices, we're just hardcoding them.  Easy!
 */
const USER_DEVICES = [
    {
        applianceId: 'marantz-sr6010-shield',
        manufacturerName: 'Marantz nVidia',
        modelName: 'SR6010 Shield',
        version: '1.0',
        friendlyName: 'Shield',
        friendlyDescription: 'nVidia Shield via Marantz SR6010',
        isReachable: true,
        actions: ['turnOn', 'turnOff'],
    }
];

/**
 * Utility functions
 */

function log(title, msg) {
    console.log(`[${title}] ${msg}`);
}

/**
 * Generate a unique message ID
 *
 * https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
 * This isn't UUID V4 but it's good enough for what we're doing
 */
function generateMessageID() {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
        var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
        return v.toString(16);
    });
}

/**
 * Generate a response message
 *
 * @param {string} name - Directive name
 * @param {Object} payload - Any special payload required for the response
 * @returns {Object} Response object
 */
function generateResponse(name, payload) {
    return {
        header: {
            messageId: generateMessageID(),
            name: name,
            namespace: 'Alexa.ConnectedHome.Control',
            payloadVersion: '2',
        },
        payload: payload,
    };
}

/**
 * This is a lot easier when I'm just hard-coding my devices
 */
function getDevicesFromPartnerCloud() {
    return USER_DEVICES;
}


/**
 * The meat and potatoes, I'm butchering just the things I Need from the solid work done by Nathan Totten:
 * https://github.com/ntotten/marantz-avr/blob/master/lib/avreciever.js
 */

function marantzAPI(commands, apiCallback) {
    log('DEBUG', `MarantzAPI Invoked: ` + JSON.stringify(commands));
    var postData = {};

    // format commands for the Marantz POST (cmd0: cmd1: etc)
    // note: may need to send commands one at a time??
    for (var i=0; i<commands.length; i++) {
        postData['cmd' + i] = commands[i];
    }

    log('DEBUG', `MarantzAPI POST Data: ` + qs.stringify(postData));

    var serverError = function (e) {
        log('Error', e.message);
        apiCallback(generateResponse('UnexpectedInformationReceivedError', e.message));
    };    

    var httpCallback = function(response) {
        response.on('end', function () {
            log('DEBUG', `API Request Complete`);
            apiCallback(generateResponse('APIRequestComplete', postData));
        });        

        response.on('error', serverError); 
    };

    var apiRequest = http.request({
        hostname: process.env.receiverIp,
        path: '/MainZone/index.put.asp',
        port: process.env.receiverPort,
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'Content-Length': Buffer.byteLength(qs.stringify(postData))
        },
    }, httpCallback);  

    apiRequest.on('error', serverError);
    apiRequest.write(qs.stringify(postData));
    apiRequest.end();    
} 


/**
 * Main logic
 */

function handleDiscovery(request, callback) {
    log('DEBUG', `Discovery Request: ${JSON.stringify(request)}`);

    const userAccessToken = request.payload.accessToken.trim();

    const response = {
        header: {
            messageId: generateMessageID(),
            name: 'DiscoverAppliancesResponse',
            namespace: 'Alexa.ConnectedHome.Discovery',
            payloadVersion: '2',
        },
        payload: {
            discoveredAppliances: getDevicesFromPartnerCloud(userAccessToken),
        },
    };

    log('DEBUG', `Discovery Response: ${JSON.stringify(response)}`);

    callback(null, response);
}

function handleControl(request, callback) {
    log('DEBUG', `Control Request: ${JSON.stringify(request)}`);

    const userAccessToken = request.payload.accessToken.trim();
    const applianceId = request.payload.appliance.applianceId;

    let response;
    var commands = [];

    switch (request.header.name) {
        case 'TurnOnRequest':
            // turn on the device
            commands.push('PutZone_OnOff/ON');

            // set the input
            switch (applianceId) {
                case 'marantz-sr6010-shield':
                    commands.push('PutZone_InputFunction/MPLAY');
                    break;
            }

            // I guess?  Not even sure if it actually does all this.
            commands.push('aspMainZone_WebUpdateStatus/');

            marantzAPI(commands, function(response) {
                callback(null, response);
            });
            break;

        default: {
            log('ERROR', `No supported directive name: ${request.header.name}`);
            callback(null, generateResponse('UnsupportedOperationError', {}));
            return;
        }
    }

    // I think I need to remove these, because response is not set at execution time
    // log('DEBUG', `Control Confirmation: ${JSON.stringify(response)}`);
    // callback(null, response);
}

exports.handler = (request, context, callback) => {

    switch (request.header.namespace) {
        case 'Alexa.ConnectedHome.Discovery':
            handleDiscovery(request, callback);
            break;

        case 'Alexa.ConnectedHome.Control':
            handleControl(request, callback);
            break;

        default: {
            const errorMessage = `No supported namespace: ${request.header.namespace}`;
            log('ERROR', errorMessage);
            callback(new Error(errorMessage));
        }
    }
};

Solution

  • I've got it working. Here's my updated marantzAPI function. Looks like the response.on('data' is crucial to success of the HTTP request? Had no idea.

    function marantzAPI(commands, apiCallback) {
        var postData = {};
    
        // format commands for the Marantz POST (cmd0: cmd1: etc)
        // note: may need to send commands one at a time??
        for (var i=0; i<commands.length; i++) {
            postData['cmd' + i] = commands[i];
        }
    
        log('DEBUG', `MarantzAPI Called w Data: ` + qs.stringify(postData));
    
        var serverError = function (e) {
            log('Error', e.message);
            apiCallback(false, e.message);
        };    
    
        var apiRequest = http.request({
            hostname: process.env.receiverIp,
            path: '/MainZone/index.put.asp',
            port: process.env.receiverPort,
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Content-Length': Buffer.byteLength(qs.stringify(postData))
            },
        }, function(response) {
    
            response.setEncoding('utf8');
            response.on('data', function (chunk) {
                log('DEBUG', 'CHUNK RECEIVED');
            });
    
            response.on('end', function () {
                log('DEBUG', `API Request Complete`);
                apiCallback(true, '');
            });        
    
            response.on('error', serverError); 
        });
    
        apiRequest.on('error', serverError);
        apiRequest.write(qs.stringify(postData));
        apiRequest.end();    
    }