Today’s post comes from J. Michael Palermo IV, Sr. Evangelist at Amazon Alexa. In this post you’ll learn what directives are and how to develop a smart home skill adapter from scratch using Node.js.
Much of the heavy lifting for creating smart home skills happens before writing a line of code. In my last post, I walked you through the five prerequisites to building a smart home skill. You may have noticed I left the code for the AWS Lambda function blank. In this post, you will start where that post ended. Not only will see how to manage the code workflow for a smart home skill adapter using Node.js, but you will also learn the fundamentals of the directive language, the communication protocol used between your skill adapter and the Smart Home Skill API.
Before tackling any lines of code, you should understand the role of your skill adapter in the smart home skill process workflow. When the Alexa service understands the intent of a voice command to be smart home related, it continues processing through the Smart Home Skill API. The Smart Home Skill API service then communicates with a skill adapter using directive language, a JSON protocol structured to convey requests and responses. Each unique instance of a request or a response is known as a directive. All communications between your skill adapter and the Smart Home Skill API use the directive language. Your skill adapter receives requests as directives and is responsible for providing responses as directives.
Figure 1 : Directive Language
The structure of a directive involves two primary components, a header and a payload. While the payload varies in structure based on context, the header is fixed with just four fields defined here.
Table 1 : Directive header fields
Field |
Value |
messageId |
A unique identifier (typically version 4 UUID) for each directive. Used for tracking/logging. Not to be used to support business logic. |
name |
The purpose of the directive, chosen from a predefined list of names. See Smart Home Skill API reference for supported directive names. |
namespace |
The category of the directive, such as “Alexa.ConnectedHome.Discovery” or “Alexa.ConnectedHome.Control” |
payloadVersion |
The API version in use. The current version is “2” |
Consider an example of an incoming directive from the Smart Home Skill API service to a skill adapter.
{ "header" : { "messageId" : "6d6d6e14-8aee-473e-8c24-0d31ff9c17a2", "name" : "DiscoverAppliancesRequest", "namespace" : "Alexa.ConnectedHome.Discovery", "payloadVersion" : "2" }, "payload" : { "accessToken" : "acc355t0ken" } }
In the header, the namespace reveals the category of the directive is “Alexa.ConnectedHome.Discovery”, and the name “DiscoverAppliancesRequest” indicates a device discovery request is being made to a skill adapter. Because all smart home skill adapters require account linking at time of enablement, the payload will include the access token associated with the customer’s device cloud account. The value shown above is a mocked value used for testing purposes only. For more background about account linking and access tokens, please see this blog post.
When your skill adapter receives a request, it must provide a response using the same directive format. Below is a response to the request made above.
{ "header" : { "messageId" : "ff746d98-ab02-4c9e-9d0d-b44711658414", "name" : "DiscoverAppliancesResponse", "namespace" : "Alexa.ConnectedHome.Discovery", "payloadVersion" : "2" }, "payload": { "discoveredAppliances": [] }
In the directive above, notice the value for messageId is not the same as the messageId found in the request directive, highlighting how each directive must have its own unique id. The namespace of the requesting directive is the same for the response. The name will typically be the ‘response’ equivalent to the request name. In this example, the request name is “DiscoverAppliancesRequest”, and the response name is “DiscoverAppliancesResponse.” The payload provides data to support the response. In the above example, an empty array indicates no devices found for this customer.
How is this managed in code? Let’s find out.
The best place to start is the entry point into the AWS Lambda function. Supported languages include Python, Java, and Node.js. In this post, all code samples shown are using Node.js. If you intend to follow along, it is assumed you have already met the prerequisites. Log into the AWS console to edit the code for your skill adapter. You should see something similar to this:
Figure 2 : Empty skill adapter code
Add the following code declaration of an empty function named exports.handler. This function is the entry point of the skill adapter.
// entry exports.handler = function (event, context, callback) { }
There are three parameters to this function. The event parameter is the request directive provided by the Smart Home Skill API service. The context parameter can provide you with runtime information of the executing Lambda function. The callback parameter is how you will return your response.
To manage code workflow, you will need to extract data from the incoming request directive (event parameter) and construct a properly syntaxed directive for the response. To prevent typing errors and improve code management, add the following lines of code above the exports.handler function, like so.
// namespaces const NAMESPACE_CONTROL = "Alexa.ConnectedHome.Control"; const NAMESPACE_DISCOVERY = "Alexa.ConnectedHome.Discovery"; // discovery const REQUEST_DISCOVER = "DiscoverAppliancesRequest"; const RESPONSE_DISCOVER = "DiscoverAppliancesResponse"; // control const REQUEST_TURN_ON = "TurnOnRequest"; const RESPONSE_TURN_ON = "TurnOnConfirmation"; const REQUEST_TURN_OFF = "TurnOffRequest"; const RESPONSE_TURN_OFF = "TurnOffConfirmation"; // errors const ERROR_UNSUPPORTED_OPERATION = "UnsupportedOperationError"; const ERROR_UNEXPECTED_INFO = "UnexpectedInformationReceivedError"; // entry exports.handler = function (event, context, callback) { }
The declared constants contain values you will use to manage directives, and they are categorized by namespaces, discovery, control, and errors. Though many more options exist, this is a good starting point for what your skill adapter supports.
Inside of the cody body of the exports.handler function, add the following code.
consoie.log(JSON.stringify(event); var requestedNamespace = event.header.namespace; var response = null; try { switch (requestedNamespace) { case NAMESPACE_DISCOVERY: response = handleDiscovery(event); break; case NAMESPACE_CONTROL: response = handleControl(event); break; default: response = handleUnexpectedInfo(requestedNamespace); break; }// switch } catch (error) { consol.log(JSON.stringify(error)); }// try-catch callback(null, response);
The first line of code logs the output of the entire incoming request directive. The next line of code traverses the directive to access the namespace, which is stored in a local variable. A local object named response is initialized to a null value, and will be set later in code to contain the full response directive.
The code enters a try-catch block, containing a switch statement to form a decision tree based on the value of the requested namespace. For all things dealing with discovery, the code will call a function named handleDiscovery with an expected response. For all things dealing with control, a function named handleControl is called returning an appropriate response. If neither namespace was encountered, then a function named handleUnexpectedInfo is executed, also returning a response. Note all three functions are going to be added shortly.
The final line above calls the callback function, which is how a response is returned. The first parameter is null, which indicates success. The second parameter must be an object complying with directive syntax containing the entire response.
The code to construct a directive is much easier to do when providing reusable support functions to help. So underneath the closing code brace of the exports.handler function, add the following support functions.
var createMessageId = function() { var d = new Date().getTime(); var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = (d + Math.random()*16)%16 | 0; d = Math.floor(d/16); return (c=='x' ? r : (r&0x3|0x8)).toString(16); }); return uuid; }// createMessageId var createHeader = function(namespace, name) { return { "messageId": createMessageId(), "namespace": namespace, "name": name, "payloadVersion": "2" }; }// createHeader var createDirective = function(header, payload) { return { "header" : header, "payload" : payload }; }// createDirective var log = function(title, msg) { console.log('**** ' + title + ': ' + JSON.stringify(msg)); }// log
The function named getMessageId returns a level 4 UUID that you can provide as the value of the messageId header field in your response. It is used in the next function named getHeader, which constructs the full header for the directive using the values passed in for name and namespace. The getDirective function does the final construction of the directive by setting the passed in values for the header and payload.
The final support function named log is a wrapper of the console.log method to simply logging.
Returning to the exports.hander function, there are three functions called in the switch code block that have not been created yet. You will add code for each of these now. First add the following code as a sibling to the support functions (declared outside exports.handler) to handle discovery.
var handleDiscovery = function(event) { var header = createHeader(NAMESPACE_DISCOVERY, RESPONSE_DISCOVER); var payload = { "discoveredAppliances": [] }; return createDirective(header,payload); }// handleDiscovery
The above handler begins construction of the response directive header by calling the getHeader support function, supplying the values of the constants declared at the top of the code file. For now, the payload will return an empty array, indicating no discovered devices. Finally, the value of the entire directive is returned.
From a code path perspective, handling discovery is much simpler than handling control. The “Alexa.ConnectedHome.Discovery” namespace (value in NAMESPACE_DISCOVERY constant) only has one request type. This is not true for the “Alexa.ConnectedHome.Control” namespace. The type of control available includes turning things off and on, setting temperature, setting modes, and setting percentages. In this example, you will only support turning on and off things.
Below the handleDiscovery function, add the following code to handle control.
var handleControl = function(event) { var response = null; var requestedName = event.header.name; switch (requestedName) { case REQUEST_TURN_ON : response = handleControlTurnOn(event); break; case REQUEST_TURN_OFF : response = handleControlTurnOff(event); break; default: log("Error", "Unsupported operation" + requestedName); response = handleUnsupportedOperation(); break; }// switch return response; }// handleControl var handleControlTurnOn = function(event) { var header = createHeader(NAMESPACE_CONTROL,RESPONSE_TURN_ON); var payload = {}; return createDirective(header,payload); }// handleControlTurnOn var handleControlTurnOff = function(event) { var header = createHeader(NAMESPACE_CONTROL,RESPONSE_TURN_OFF); var payload = {}; return createDirective(header,payload); }// handleControlTurnOff var handleUnsupportedOperation = function() { var header = createHeader(NAMESPACE_CONTROL,ERROR_UNSUPPORTED_OPERATION); var payload = {}; return createDirective(header,payload); }// handleUnsupportedOperation
There are three possible outcomes defined in the handleControl function, of which a supporting handler function is called to process. If the incoming request is to turn a device on, the handleControlTurnOn function is called. Likewise if the request is to turn off a device, the handleControlTurnOff function is called. In both cases, the supporting functions construct a response directive for return. As was the case with discovery, static responses are being returned for now.
The third outcome occurs if an unsupported control operation was requested. For example, if a request came in to set the temperature for a device it would result in this outcome because your skill adapter does not support it. Granted, the limitation to only supporting turn on|off behaviors is a choice made in code. If you later wanted to support other operations, you would add more case statements respectively.
Take a look at the handleUnsupportedOperation function. Like the other two functions above it, it is constructing a response directive. What makes this one interesting is the content is actually an error message. The value of the constant ERROR_UNSUPPORTED_OPERATION is “UnsupportedOperationError” and is just one of many types of errors. In fact, before it is forgotten, add the following code to complete the code path from exports.hander.
var handleUnexpectedInfo = function(fault) { var header = createHeader(NAMESPACE_CONTROL,ERROR_UNEXPECTED_INFO); var payload = { "faultingParameter" : fault }; return createDirective(header,payload); }// handleUnexpectedInfo
The above function also constructs a response directive to return error information. However, this error supports more metadata to be returned, as shown in the payload. What are all the available errors and what do they support? The answers are found in the ‘Error Messages’ section at the Smart Home Skill API reference.
With minor variation to logging in exports.handler, here is the completed code.
// namespaces const NAMESPACE_CONTROL = "Alexa.ConnectedHome.Control"; const NAMESPACE_DISCOVERY = "Alexa.ConnectedHome.Discovery"; // discovery const REQUEST_DISCOVER = "DiscoverAppliancesRequest"; const RESPONSE_DISCOVER = "DiscoverAppliancesResponse"; // control const REQUEST_TURN_ON = "TurnOnRequest"; const RESPONSE_TURN_ON = "TurnOnConfirmation"; const REQUEST_TURN_OFF = "TurnOffRequest"; const RESPONSE_TURN_OFF = "TurnOffConfirmation"; // errors const ERROR_UNSUPPORTED_OPERATION = "UnsupportedOperationError"; const ERROR_UNEXPECTED_INFO = "UnexpectedInformationReceivedError"; // entry exports.handler = function (event, context, callback) { log("Received Directive", event); var requestedNamespace = event.header.namespace; var response = null; try { switch (requestedNamespace) { case NAMESPACE_DISCOVERY: response = handleDiscovery(event); break; case NAMESPACE_CONTROL: response = handleControl(event); break; default: log("Error", "Unsupported namespace: " + requestedNamespace); response = handleUnexpectedInfo(requestedNamespace); break; }// switch } catch (error) { log("Error", error); }// try-catch callback(null, response); }// exports.handler var handleDiscovery = function(event) { var header = createHeader(NAMESPACE_DISCOVERY, RESPONSE_DISCOVER); var payload = { "discoveredAppliances": [] }; return createDirective(header,payload); }// handleDiscovery var handleControl = function(event) { var response = null; var requestedName = event.header.name; switch (requestedName) { case REQUEST_TURN_ON : response = handleControlTurnOn(event); break; case REQUEST_TURN_OFF : response = handleControlTurnOff(event); break; default: log("Error", "Unsupported operation" + requestedName); response = handleUnsupportedOperation(); break; }// switch return response; }// handleControl var handleControlTurnOn = function(event) { var header = createHeader(NAMESPACE_CONTROL,RESPONSE_TURN_ON); var payload = {}; return createDirective(header,payload); }// handleControlTurnOn var handleControlTurnOff = function(event) { var header = createHeader(NAMESPACE_CONTROL,RESPONSE_TURN_OFF); var payload = {}; return createDirective(header,payload); }// handleControlTurnOff var handleUnsupportedOperation = function() { var header = createHeader(NAMESPACE_CONTROL,ERROR_UNSUPPORTED_OPERATION); var payload = {}; return createDirective(header,payload); }// handleUnsupportedOperation var handleUnexpectedInfo = function(fault) { var header = createHeader(NAMESPACE_CONTROL,ERROR_UNEXPECTED_INFO); var payload = { "faultingParameter" : fault }; return createDirective(header,payload); }// handleUnexpectedInfo // support functions var createMessageId = function() { var d = new Date().getTime(); var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = (d + Math.random()*16)%16 | 0; d = Math.floor(d/16); return (c=='x' ? r : (r&0x3|0x8)).toString(16); }); return uuid; }// createMessageId var createHeader = function(namespace, name) { return { "messageId": createMessageId(), "namespace": namespace, "name": name, "payloadVersion": "2" }; }// createHeader var createDirective = function(header, payload) { return { "header" : header, "payload" : payload }; }// createDirective var log = function(title, msg) { console.log('**** ' + title + ': ' + JSON.stringify(msg)); }// log
Save your work.
To confirm your code is functional, you can test your skill adapter by simulating the Smart Home API service by providing request directives. Try it. Click on the “Actions” button above your code and select “Configure test event.”
Figure 3 : Configure test event
Choose any sample event template from the dropdown menu you don’t mind changing. Replace the contents with the directive example shown here.
{ "header" : { "messageId" : "6d6d6e14-8aee-473e-8c24-0d31ff9c17a2", "name" : "DiscoverAppliancesRequest", "namespace" : "Alexa.ConnectedHome.Discovery", "payloadVersion" : "2" }, "payload" : { "accessToken" : "acc355t0ken" } }
Figure 4 : Input test event
After clicking the “Save and test” button, you should get results similar to what is shown here.
Figure 5 : Execution result succeeded
Click on the “logs” link and in the screen that follows, click on the link appearing in the first row of the log streams table. The result will show the log data as seen below.
Figure 6 : Event data log
Go back and test your skill adapter again. Configure the input test to use the following.
{ "header": { "messageId" : "5d599a53-fe40-405f-b0ab-233611e2dc5c", "name" : "TurnOnRequest", "namespace" : "Alexa.ConnectedHome.Control", "payloadVersion" : "2" }, "payload" : { "accessToken" : "acc355t0ken" } }
Save and test. You should see another successful execution. Go back and change the name in the above directive to the value shown here.
"name" : "SetTargetTemperatureRequest",
The value “SetTargetTemperatureRequest” is one of the valid control options in the Smart Home Skill API reference. Save and test. You should get another successful execution, but look at the results carefully. Do you see the name field contains the value “UnsupportedOperationError”? Because the skill adapter only coded support for turn on|off operations, it rejected setting temperature.
Figure 7 : Unsupported operation error directive
Engaging your skill adapter with multiple testing scenarios confirms it will respond appropriately before publishing. You should keep a copy and save to file each of your test scripts throughout your skill development lifecycle and future updates.
Coding skill adapters requires parsing incoming request directives from the Smart Home Skill API and constructing directives in response. In this post you learned how to create an entry point for all incoming transmissions as well as support code to help generate a directive to return. You also observed how to test your skill adapter to confirm expected behaviors.
The responses in this post provide minimal content for testing and a is a great way to dive into building a smart home skill. In my upcoming blog posts I’ll dive into what device discovery or control look like and and how to integrate communications with your device cloud. Stay tuned.