From 8ba2dee5c045c8b3d48d52201218dfb2f1fa53b9 Mon Sep 17 00:00:00 2001 From: Gary Wicker Date: Thu, 28 Jan 2016 16:28:14 -0800 Subject: [PATCH] Version 1.0.10. Fixes issue #30, incorporates pull requests #28 and #29. --- CHANGELOG.md | 11 + README.md | 146 ++++++-- common/lib/is-undefined.js | 5 +- common/lib/tls-reader.js | 26 +- device/index.js | 190 +++++++++- examples/device-example.js | 6 +- examples/echo-example.js | 6 +- examples/lib/cmdline.js | 57 +-- .../temperature-control.js | 92 ++++- examples/thing-example.js | 353 +++++++++--------- examples/thing-passthrough-example.js | 311 +++++++++------ gulpfile.js | 39 +- package.json | 19 +- thing/index.js | 164 ++++---- 14 files changed, 947 insertions(+), 478 deletions(-) mode change 100644 => 100755 common/lib/tls-reader.js mode change 100644 => 100755 thing/index.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e5e146..7788267 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [1.0.10](https://github.com/aws/aws-iot-device-sdk-js/releases/tag/v1.0.10) (January 28, 2016) + +Features: + - Added support for WebSocket connections to AWS IoT + +Bugfixes/Improvements + - Incorporated github pull requests [#28](https://github.com/aws/aws-iot-device-sdk-js/pull/28) and [#29](https://github.com/aws/aws-iot-device-sdk-js/pull/29) + - Fixes for github issues [#30](https://github.com/aws/aws-iot-device-sdk-js/issues/30) + - Added unit tests to release + - Updated documentation + ## [1.0.7](https://github.com/aws/aws-iot-device-sdk-js/releases/tag/v1.0.7) (October 30, 2015) Bugfixes/Improvements: diff --git a/README.md b/README.md index 510ddfd..5196ba1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AWS IoT SDK for JavaScript +# AWS IoT SDK for Node.js The aws-iot-device-sdk.js package allows developers to write JavaScript applications which access the AWS IoT Platform; it is intended for use in embedded devices which support Node.js, but it can be used in other Node.js @@ -8,8 +8,10 @@ environments as well. * [Installation](#install) * [Examples](#examples) * [API Documentation](#api) +* [Connection Types](#connections) * [Example Programs](#programs) * [Troubleshooting](#troubleshooting) +* [Unit Tests](#unittests) * [License](#license) * [Support](#support) @@ -19,9 +21,9 @@ This document provides instructions on how to install and configure the AWS IoT device SDK for Node.js and includes examples demonstrating use of the SDK APIs. -### MQTT connection +### MQTT Connection This package is built on top of [mqtt.js](https://github.com/mqttjs/MQTT.js/blob/master/README.md) and provides two classes: 'device' -and 'thingShadow'. The 'device' class loosely wraps [mqtt.js](https://github.com/mqttjs/MQTT.js/blob/master/README.md) to provide a +and 'thingShadow'. The 'device' class wraps [mqtt.js](https://github.com/mqttjs/MQTT.js/blob/master/README.md) to provide a secure connection to the AWS IoT platform and expose the [mqtt.js](https://github.com/mqttjs/MQTT.js/blob/master/README.md) interfaces upward via an instance of the mqtt client. @@ -98,14 +100,16 @@ var thingShadows = awsIot.thingShadow({ }); // -// Thing shadow state +// Client token value returned from thingShadows.update() operation // -var rgbLedLampState = {"state":{"desired":{"red":187,"green":114,"blue":222}}}; +var clientTokenUpdate; // -// Client token value returned from thingShadows.update() operation +// Simulated device values // -var clientTokenUpdate; +var rval = 187; +var gval = 114; +var bval = 222; thingShadows.on('connect', function() { // @@ -114,7 +118,7 @@ thingShadows.on('connect', function() { // thingShadows.register( 'RGBLedLamp' ); // -// 2 seconds after registering, update the Thing Shadow named +// 5 seconds after registering, update the Thing Shadow named // 'RGBLedLamp' with the latest device state and save the clientToken // so that we can correlate it with status or timeout events. // @@ -124,27 +128,58 @@ thingShadows.on('connect', function() { // method for more details. // setTimeout( function() { +// +// Thing shadow state +// + var rgbLedLampState = {"state":{"desired":{"red":rval,"green":gval,"blue":bval}}}; + clientTokenUpdate = thingShadows.update('RGBLedLamp', rgbLedLampState ); - }, 2000 ); +// +// The update method returns a clientToken; if non-null, this value will +// be sent in a 'status' event when the operation completes, allowing you +// to know whether or not the update was successful. If the update method +// returns null, it's because another operation is currently in progress and +// you'll need to wait until it completes (or times out) before updating the +// shadow. +// + if (clientTokenUpdate === null) + { + console.log('update shadow failed, operation still in progress'); + } + }, 5000 ); }); thingShadows.on('status', function(thingName, stat, clientToken, stateObject) { console.log('received '+stat+' on '+thingName+': '+ JSON.stringify(stateObject)); +// +// These events report the status of update(), get(), and delete() +// calls. The clientToken value associated with the event will have +// the same value which was returned in an earlier call to get(), +// update(), or delete(). Use status events to keep track of the +// status of shadow operations. +// }); thingShadows.on('delta', function(thingName, stateObject) { - console.log('received delta '+' on '+thingName+': '+ + console.log('received delta on '+thingName+': '+ JSON.stringify(stateObject)); }); thingShadows.on('timeout', function(thingName, clientToken) { - console.log('received timeout '+' on '+operation+': '+ - clientToken); + console.log('received timeout on '+thingName+ + ' with token: '+ clientToken); +// +// In the event that a shadow operation times out, you'll receive +// one of these events. The clientToken value associated with the +// event will have the same value which was returned in an earlier +// call to get(), update(), or delete(). +// }); + ``` @@ -168,7 +203,7 @@ thingShadows.on('timeout', Returns an instance of the [mqtt.Client()](https://github.com/mqttjs/MQTT.js/blob/master/README.md#client) class, configured for a TLS connection with the AWS IoT platform and with -arguments as specified in `options`. The awsIot-specific arguments are as +arguments as specified in `options`. The AWSIoT-specific arguments are as follows: * `region`: the AWS IoT region you will operate in (default 'us-east-1') @@ -179,6 +214,7 @@ follows: * `clientCert`: same as `certPath`, but can also accept a buffer containing client certificate data * `privateKey`: same as `keyPath`, but can also accept a buffer containing private key data * `caCert`: same as `caPath`, but can also accept a buffer containing CA certificate data + * `protocol`: the connection type, either 'mqtts' (default) or 'wss' (WebSocket/TLS) All certificates and keys must be in PEM format. @@ -302,7 +338,8 @@ the `clientToken` will be supplied as one of the parameters, allowing the application to keep track of the status of each operation. The caller may create their own `clientToken` value; if `stateObject` contains a `clientToken` property, that will be used rather than the internally generated value. Note -that it should be of atomic type (i.e. numeric or string). +that it should be of atomic type (i.e. numeric or string). This function +returns 'null' if an operation is already in progress. ------------------------------------------------------- @@ -319,7 +356,8 @@ the `clientToken` will be supplied as one of the parameters, allowing the application to keep track of the status of each operation. The caller may supply their own `clientToken` value (optional); if supplied, the value of `clientToken` will be used rather than the internally generated value. Note -that this value should be of atomic type (i.e. numeric or string). +that this value should be of atomic type (i.e. numeric or string). This +function returns 'null' if an operation is already in progress. ------------------------------------------------------- @@ -336,7 +374,8 @@ the `clientToken` will be supplied as one of the parameters, allowing the application to keep track of the status of each operation. The caller may supply their own `clientToken` value (optional); if supplied, the value of `clientToken` will be used rather than the internally generated value. Note -that this value should be of atomic type (i.e. numeric or string). +that this value should be of atomic type (i.e. numeric or string). This +function returns 'null' if an operation is already in progress. ------------------------------------------------------- @@ -374,6 +413,18 @@ method on the MQTT connection owned by the `thingShadow` class. The `force` and `callback` parameters are optional and identical in function to the parameters in the [mqtt.Client#end()](https://github.com/mqttjs/MQTT.js/blob/master/README.md#end) method. + +## Connection Types + +This SDK supports two types of connections to the AWS IoT platform: + +* MQTT over TLS with mutual certificate authentication using port 8883 +* MQTT over WebSocket/TLS with SigV4 authentication using port 443 + +The default connection type is MQTT over TLS with mutual certificate authentication; to +configure a WebSocket/TLS connection, set the `protocol` option to `wss` when instantiating +the [awsIot.device()](#device) or [awsIot.thingShadow()](#thingShadow) classes. + ## Example Programs @@ -404,11 +455,34 @@ follows: ```sh node examples/ -h ``` + +### WebSocket Configuration + +The example programs can be configured to use a WebSocket/TLS connection to +the AWS IoT platform by adding '--protocol=wss' to the command line to +override the default setting of 'mqtts'. + +```sh + -P, --protocol=PROTOCOL connect using PROTOCOL (mqtts|wss) +``` + +When using a WebSocket/TLS connection, you'll need to set the following environment +variables: + +```sh + export AWS_ACCESS_KEY_ID=[a valid AWS access key ID] + export AWS_SECRET_ACCESS_KEY=[a valid AWS secret access key] +``` + +The values of `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` must contain valid +AWS Identity and Access Management (IAM) credentials. For more information about AWS +IAM, [visit the AWS IAM home page.](https://aws.amazon.com/iam/) -### Certificates +### Certificate Configuration -The example programs require certificates (created using either the [AWS +When not configured to use a WebSocket/TLS connection, the example programs +require a client certificate and private key (created using either the [AWS IoT Console](https://console.aws.amazon.com/iot) or the [AWS IoT CLI](https://aws.amazon.com/cli/)) in order to authenticate with AWS IoT. Each example program uses command line options to specify the @@ -420,8 +494,8 @@ names and/or locations of certificates as follows: -f, --certificate-dir=DIR look in DIR for certificates ``` -The --certificate-dir (-f) option will read all certificates from the -directory specified. Default certificate names are as follows: +The --certificate-dir (-f) option will read all certificate and key files from the +directory specified. Default certificate/key file names are as follows: * certificate.pem.crt: your AWS IoT certificate * private.pem.key: the private key associated with your AWS IoT certificate @@ -456,7 +530,7 @@ The configuration file is in JSON format, and may contain the following properties: * host - the host name to connect to -* port - the port number to use when connecting to the host (8883 for AWS IoT) +* port - the port number to use when connecting to the host (8883 for AWS IoT with client certificate) * clientId - the client ID to use when connecting * privateKey - file containing the private key * clientCert - file containing the client certificate @@ -480,7 +554,7 @@ each process performs. It's easiest to run each process in its own terminal window so that you can see the output generated by each. Note that in the following examples, all certificates are located in the ~/certs directory and have the default names as specified in the -[Certificates section](#certificates). +[Certificate Configuration section](#certificates). #### _Terminal Window 1_ ```sh @@ -498,19 +572,19 @@ processes which communicate with one another via the AWS IoT platform. thing-example.js uses a Thing Shadow to synchronize state between the two processes, and the command line option '--test-mode (-t)' is used to set which role each process performs. As with device-example.js, -it's best to run each process in its own terminal window. Note -that in the following examples, all certificates are located in the -~/certs directory and have the default names as specified in the -[Certificates section](#certificates). +it's best to run each process in its own terminal window or on separate +hosts. In this example, the example programs are configured to use +WebSocket/TLS connections to the AWS IoT platform as specified in the +[WebSocket Configuration](#websockets). #### _Terminal Window 1_ ```sh -node examples/thing-example.js -f ~/certs --test-mode=1 +node examples/thing-example.js -P=wss --test-mode=1 ``` #### _Terminal Window 2_ ```sh -node examples/thing-example.js -f ~/certs --test-mode=2 +node examples/thing-example.js -P=wss --test-mode=2 ``` ### thing-passthrough-example.js @@ -523,7 +597,7 @@ is used to set which role each process performs. As with thing-example.js, it's best to run each process in its own terminal window. Note that in the following examples, all certificates are located in the ~/certs directory and have the default names as specified in the -[Certificates section](#certificates). +[Certificate Configuration section](#certificates). #### _Terminal Window 1_ ```sh @@ -561,7 +635,7 @@ Like thing-example.js, temperature-control.js runs in two separate terminal windows and is configured via command-line options; in the following example, all certificates are located in the ~/certs directory and have the default names as specified in the -[Certificates section](#certificates). The process running +[Certificate Configuration section](#certificates). The process running with '--test-mode=2' simulates an internet-connected temperature control device, and the process running with '--test-mode=1' simulates a mobile application which is monitoring/controlling it. The processes may be @@ -677,6 +751,18 @@ but if you are using a [JSON configuration file](#configurationFile), you'll need to explictly specify client IDs for both programs using the '-i' command line option. + +## Unit Tests + +This package includes unit tests which can be run as follows: + +```sh +npm test +``` + +Running the unit tests will also generate code coverage data in the 'reports' +directory. + ## License diff --git a/common/lib/is-undefined.js b/common/lib/is-undefined.js index 32b81fc..8f73025 100644 --- a/common/lib/is-undefined.js +++ b/common/lib/is-undefined.js @@ -28,8 +28,5 @@ * @access public */ module.exports = function(value) { - if ((typeof(value) === 'undefined') || (typeof(value) === null)) { - return true; - } - return false; + return typeof value === 'undefined' || typeof value === null; }; diff --git a/common/lib/tls-reader.js b/common/lib/tls-reader.js old mode 100644 new mode 100755 index 3ba0a7b..2279fa2 --- a/common/lib/tls-reader.js +++ b/common/lib/tls-reader.js @@ -14,7 +14,7 @@ */ //node.js deps -const fs = require('fs'); +const filesys = require('fs'); //npm deps @@ -55,8 +55,8 @@ module.exports = function(options) { } else { - if (fs.existsSync(options.caCert)) { - options.ca = fs.readFileSync(options.caCert); + if (filesys.existsSync(options.caCert)) { + options.ca = filesys.readFileSync(options.caCert); } else { throw new Error(exceptions.INVALID_CA_CERT_OPTION); @@ -69,8 +69,8 @@ module.exports = function(options) { } else { - if (fs.existsSync(options.privateKey)) { - options.key = fs.readFileSync(options.privateKey); + if (filesys.existsSync(options.privateKey)) { + options.key = filesys.readFileSync(options.privateKey); } else { throw new Error(exceptions.INVALID_PRIVATE_KEY_OPTION); @@ -83,8 +83,8 @@ module.exports = function(options) { } else { - if (fs.existsSync(options.clientCert)) { - options.cert = fs.readFileSync(options.clientCert); + if (filesys.existsSync(options.clientCert)) { + options.cert = filesys.readFileSync(options.clientCert); } else { throw new Error(exceptions.INVALID_CLIENT_CERT_OPTION); @@ -95,20 +95,20 @@ module.exports = function(options) { // Parse PEM files. Options ending in 'Path' must be files // and will override options which do not end in 'Path'. - if (fs.existsSync(options.keyPath)) { - options.key = fs.readFileSync(options.keyPath); + if (filesys.existsSync(options.keyPath)) { + options.key = filesys.readFileSync(options.keyPath); } else if (!isUndefined(options.keyPath)) { throw new Error(exceptions.INVALID_KEY_PATH_OPTION); } - if (fs.existsSync(options.certPath)) { - options.cert = fs.readFileSync(options.certPath); + if (filesys.existsSync(options.certPath)) { + options.cert = filesys.readFileSync(options.certPath); } else if (!isUndefined(options.certPath)) { throw new Error(exceptions.INVALID_CERT_PATH_OPTION); } - if (fs.existsSync(options.caPath)) { - options.ca = fs.readFileSync(options.caPath); + if (filesys.existsSync(options.caPath)) { + options.ca = filesys.readFileSync(options.caPath); } else if (!isUndefined(options.caPath)) { throw new Error(exceptions.INVALID_CA_PATH_OPTION); diff --git a/device/index.js b/device/index.js index 9d527b9..b0ada36 100644 --- a/device/index.js +++ b/device/index.js @@ -16,7 +16,8 @@ //node.js deps //npm deps -const mqtt = require('mqtt'); +const mqtt = require('mqtt'); +const crypto = require('crypto-js'); //app deps const exceptions = require('./lib/exceptions'), @@ -26,12 +27,126 @@ const exceptions = require('./lib/exceptions'), //begin module const reconnectPeriod = 3 * 1000; +function makeTwoDigits( n ) { + if (n > 9) + { + return n; + } + else + { + return '0' + n; + } +} + +function getDateTimeString() { + var d = new Date(); + +// +// The additional ''s are used to force JavaScript to interpret the +// '+' operator as string concatenation rather than arithmetic. +// + return d.getUTCFullYear() + '' + + makeTwoDigits(d.getUTCMonth()+1) + '' + + makeTwoDigits(d.getUTCDate()) + 'T' + '' + + makeTwoDigits(d.getUTCHours()) + '' + + makeTwoDigits(d.getUTCMinutes()) + '' + + makeTwoDigits(d.getUTCSeconds()) + 'Z'; +} + +function getDateString( dateTimeString ) { + return dateTimeString.substring(0, dateTimeString.indexOf('T')); +} + +function getSignatureKey(key, dateStamp, regionName, serviceName) { + var kDate = crypto.HmacSHA256(dateStamp, 'AWS4' + key, { asBytes: true}); + var kRegion = crypto.HmacSHA256(regionName, kDate, { asBytes: true }); + var kService =crypto.HmacSHA256(serviceName, kRegion, { asBytes: true }); + var kSigning = crypto.HmacSHA256('aws4_request', kService, { asBytes: true }); + return kSigning; +} + +function signUrl(method, scheme, hostname, path, queryParams, accessId, secretKey, + region, serviceName, payload, today, now, debug ) { + + var signedHeaders = 'host'; + + var canonicalHeaders = 'host:' + hostname + '\n'; + + var canonicalRequest = method + '\n' + // method + path + '\n' + // path + queryParams + '\n' + // query params + canonicalHeaders +// headers + '\n' + // no idea why this needs to be here, but it fails without + signedHeaders + '\n' + // signed header list + crypto.SHA256(payload, { asBytes: true }); // hash of payload (empty string) + + if (debug === true) { + console.log('canonical request: ' + canonicalRequest + '\n'); + } + + var hashedCanonicalRequest = crypto.SHA256(canonicalRequest, { asBytes: true }); + + if (debug === true) { + console.log('hashed canonical request: ' + hashedCanonicalRequest + '\n'); + } + + var stringToSign = 'AWS4-HMAC-SHA256\n' + + now + '\n' + + today + '/' + region + '/' + serviceName + '/aws4_request\n' + + hashedCanonicalRequest; + + if (debug === true) { + console.log('string to sign: ' + stringToSign + '\n'); + } + + var signingKey = getSignatureKey(secretKey, today, region, serviceName); + + if (debug === true) { + console.log('signing key: ' + signingKey + '\n'); + } + + var signature = crypto.HmacSHA256(stringToSign, signingKey, { asBytes: true }); + + if (debug === true) { + console.log('signature: ' + signature + '\n'); + } + + var finalParams = queryParams + '&X-Amz-Signature=' + signature; + + var url = scheme + hostname + path + '?' + finalParams; + + if (debug === true) { + console.log('url: ' + url + '\n'); + } + + return url; +} + +function prepareWebsocketUrl( options, awsAccessId, awsSecretKey ) +{ + var now = getDateTimeString(); + var today = getDateString( now ); + var path = '/mqtt'; + var awsServiceName = 'iotdata'; + var queryParams = 'X-Amz-Algorithm=AWS4-HMAC-SHA256' + + '&X-Amz-Credential=' + awsAccessId + '%2F' + today + '%2F' + options.region + '%2F' + awsServiceName + '%2Faws4_request' + + '&X-Amz-Date=' + now + + '&X-Amz-SignedHeaders=host'; + + return signUrl('GET', 'wss://', options.host, path, queryParams, + awsAccessId, awsSecretKey, options.region, awsServiceName, '', today, now, options.debug ); +} + // // This method is the exposed module; it validates the mqtt options, // creates a secure mqtt connection via TLS, and returns the mqtt // connection instance. // module.exports = function(options) { + + var awsAccessId; + var awsSecretKey; + // // Validate options, set default reconnect period if not specified. // @@ -42,14 +157,12 @@ module.exports = function(options) { if (isUndefined(options.reconnectPeriod)) { options.reconnectPeriod = reconnectPeriod; } - - // set port, do not override existing definitions if available - if (isUndefined(options.port)) + // set protocol, do not override existing definitions if available + if (isUndefined(options.protocol)) { - options.port = 8883; + options.protocol = 'mqtts'; } - options.protocol = 'mqtts'; - + if (isUndefined(options.host)) { if (!(isUndefined(options.region))) @@ -62,8 +175,39 @@ module.exports = function(options) { } } - //read and map certificates - tlsReader(options); + if (options.protocol === 'mqtts') + { + // set port, do not override existing definitions if available + if (isUndefined(options.port)) + { + options.port = 8883; + } + + //read and map certificates + tlsReader(options); + } + else if (options.protocol === 'wss') + { + //AWS access id and secret key must be available in environment + awsAccessId = process.env.AWS_ACCESS_KEY_ID; + awsSecretKey = process.env.AWS_SECRET_ACCESS_KEY; + + if (isUndefined( awsAccessId ) || (isUndefined( awsSecretKey ))) + { + console.log('AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be defined in environment'); + throw new Error(exceptions.INVALID_CONNECT_OPTIONS); + } + // set port, do not override existing definitions if available + if (isUndefined(options.port)) + { + options.port = 443; + } + } + else + { + console.log('unsupported protocol: '+options.protocol); + throw new Error(exceptions.INVALID_CONNECT_OPTIONS); + } if ((!isUndefined(options)) && (options.debug===true)) { @@ -71,9 +215,32 @@ module.exports = function(options) { console.log('attempting new mqtt connection...'); } //connect and return the client instance to map all mqttjs apis - const device = mqtt.connect(options); + + var protocols = {}; + protocols.mqtts = require('./lib/tls'); + protocols.wss = require('./lib/ws'); + + function wrapper (client) { + if (options.protocol === 'wss') + { + // + // Access id and secret key are available, prepare URL. + // + var url = prepareWebsocketUrl( options, awsAccessId, awsSecretKey ); + + if (options.debug === true) { + console.log('using websockets, will connect to \''+url+'\'...'); + } + + options.url = url; + } + return protocols[options.protocol](client, options); + } + + const device = new mqtt.MqttClient(wrapper, options); //handle some exceptions + device .on('error', function(error) { //certificate issue @@ -81,5 +248,8 @@ module.exports = function(options) { throw new Error(error); } }); + return device; }; + +module.exports.prepareWebsocketUrl = prepareWebsocketUrl; diff --git a/examples/device-example.js b/examples/device-example.js index 3e7856c..b73e2d9 100644 --- a/examples/device-example.js +++ b/examples/device-example.js @@ -36,7 +36,11 @@ const device = deviceModule({ caPath: args.caCert, clientId: args.clientId, region: args.region, - reconnectPeriod: args.reconnectPeriod + reconnectPeriod: args.reconnectPeriod, + protocol: args.Protocol, + port: args.Port, + host: args.Host, + debug: args.Debug }); var timeout; diff --git a/examples/echo-example.js b/examples/echo-example.js index e5e11e6..7871e36 100644 --- a/examples/echo-example.js +++ b/examples/echo-example.js @@ -43,7 +43,11 @@ const thingShadows = thingShadow({ caPath: args.caCert, clientId: args.clientId, region: args.region, - reconnectPeriod: args.reconnectPeriod + reconnectPeriod: args.reconnectPeriod, + protocol: args.Protocol, + port: args.Port, + host: args.Host, + debug: args.Debug }); // diff --git a/examples/lib/cmdline.js b/examples/lib/cmdline.js index df188cf..6ce457f 100644 --- a/examples/lib/cmdline.js +++ b/examples/lib/cmdline.js @@ -52,6 +52,9 @@ module.exports = function( description, args, processFunction, argumentHelp ) { ' Options\n\n' + ' -g, --aws-region=REGION AWS IoT region\n' + ' -i, --client-id=ID use ID as client ID\n' + + ' -H, --host-name=HOST connect to HOST (overrides --aws-region)\n' + + ' -p, --port=PORT connect to PORT (overrides defaults)\n' + + ' -P, --protocol=PROTOCOL connect using PROTOCOL (mqtts|wss)\n' + ' -k, --private-key=FILE use FILE as private key\n' + ' -c, --client-certificate=FILE use FILE as client certificate\n' + ' -a, --ca-certificate=FILE use FILE as CA certificate\n' + @@ -60,10 +63,12 @@ module.exports = function( description, args, processFunction, argumentHelp ) { ' -r, --reconnect-period-ms=VALUE use VALUE as the reconnect period (ms)\n' + ' -t, --test-mode=[1-n] set test mode for multi-process tests\n' + ' -T, --thing-name=THINGNAME access thing shadow named THINGNAME\n' + - ' -d, --delay-ms=VALUE delay in milliseconds before publishing\n\n' + + ' -d, --delay-ms=VALUE delay in milliseconds before publishing\n' + + ' -D, --debug print additional debugging information\n\n' + ' Default values\n\n' + ' aws-region us-east-1\n' + ' client-id $USER\n' + + ' protocol mqtts\n' + ' private-key private.pem.key\n' + ' client-certificate certificate.pem.crt\n' + ' ca-certificate root-CA.crt\n' + @@ -76,9 +81,10 @@ module.exports = function( description, args, processFunction, argumentHelp ) { }; args = minimist(args, { string: ['certificate-dir', 'aws-region', 'private-key', 'client-certificate', - 'ca-certificate', 'client-id', 'thing-name', 'configuration-file' ], - integer: [ 'reconnect-period-ms', 'test-mode', 'delay-ms' ], - boolean: ['help'], + 'ca-certificate', 'client-id', 'thing-name', 'configuration-file', + 'host-name', 'protocol' ], + integer: [ 'reconnect-period-ms', 'test-mode', 'port', 'delay-ms' ], + boolean: ['help','debug'], alias: { region: ['g', 'aws-region'], clientId: ['i', 'client-id'], @@ -91,18 +97,23 @@ module.exports = function( description, args, processFunction, argumentHelp ) { testMode: ['t', 'test-mode'], thingName: ['T', 'thing-name'], delay: ['d', 'delay-ms'], - debug: 'D', + Port: ['p', 'port'], + Protocol: ['P', 'protocol'], + Host: ['H', 'host-name'], + Debug: ['D', 'debug'], help: 'h' }, default: { region: 'us-east-1', + protocol: 'mqtts', clientId: clientIdDefault, privateKey: 'private.pem.key', clientCert: 'certificate.pem.crt', caCert: 'root-CA.crt', testMode: 1, reconnectPeriod: 3*1000, /* milliseconds */ - delay: 4*1000 /* milliseconds */ + delay: 4*1000, /* milliseconds */ + Debug: false }, unknown: function() { console.error('***unrecognized options***'); doHelp(); process.exit(1); @@ -193,23 +204,27 @@ module.exports = function( description, args, processFunction, argumentHelp ) { } } + if (args.Protocol === 'mqtts') + { // -// Client certificate, private key, and CA certificate must all exist. +// Client certificate, private key, and CA certificate must all exist if +// connecting via mqtts. // - if (!fs.existsSync( args.privateKey )) - { - console.error( '\n' + args.privateKey + ' doesn\'t exist (--help for usage)\n'); - return; - } - if (!fs.existsSync( args.clientCert )) - { - console.error( '\n' + args.clientCert + ' doesn\'t exist (--help for usage)\n'); - return; - } - if (!fs.existsSync( args.caCert )) - { - console.error( '\n' + args.caCert + ' doesn\'t exist (--help for usage)\n'); - return; + if (!fs.existsSync( args.privateKey )) + { + console.error( '\n' + args.privateKey + ' doesn\'t exist (--help for usage)\n'); + return; + } + if (!fs.existsSync( args.clientCert )) + { + console.error( '\n' + args.clientCert + ' doesn\'t exist (--help for usage)\n'); + return; + } + if (!fs.existsSync( args.caCert )) + { + console.error( '\n' + args.caCert + ' doesn\'t exist (--help for usage)\n'); + return; + } } processFunction( args ); diff --git a/examples/temperature-control/temperature-control.js b/examples/temperature-control/temperature-control.js index 6191307..9509842 100644 --- a/examples/temperature-control/temperature-control.js +++ b/examples/temperature-control/temperature-control.js @@ -157,7 +157,10 @@ const thingShadows = thingShadow({ caPath: args.caCert, clientId: args.clientId, region: args.region, - reconnectPeriod: args.reconnectPeriod + reconnectPeriod: args.reconnectPeriod, + protocol: args.Protocol, + port: args.Port, + host: args.Host }); var opClientToken; @@ -214,16 +217,30 @@ var bar = blessed.listbar({ commands: { 'mode': { callback: function() { + var renderScreen = true; + var enabledStatus=( deviceControlState.enabled===true?'OFF':' ON'); deviceControlState.enabled=(deviceControlState.enabled===true?false:true); - log.log('temperature control '+(deviceControlState.enabled?'enabled':'disabled')); if (networkEnabled===true) { opClientToken = thingShadows.update('TemperatureControl', { state: { desired: deviceControlState } }); +// +// If we receive null from the update method, that means an operation +// is still in progress; revert our local state and don't update the UI. +// + if (opClientToken === null) + { + deviceControlState.enabled=(deviceControlState.enabled===true?false:true); + renderScreen = false; + } + } + if (renderScreen === true) + { + log.log('temperature control '+(deviceControlState.enabled?'enabled':'disabled')); + lcd4.setDisplay( enabledStatus ); + screen.render(); } - lcd4.setDisplay( enabledStatus ); - screen.render(); } }, 'network': { @@ -244,7 +261,10 @@ var bar = blessed.listbar({ // get a 'rejected' status if another entity has updated this thing shadow // in the meantime and we will have to re-sync. // - thingShadows.update( 'TemperatureControl', { state: { desired: deviceControlState } } ); + if (thingShadows.update( 'TemperatureControl', { state: { desired: deviceControlState } } ) === null) + { + log.log( 'operation in progress'); + } } else { @@ -270,25 +290,54 @@ bar.focus(); screen.key('up', function( ch, key ) { if (deviceControlState.setPoint < 90) { + var renderScreen = true; + deviceControlState.setPoint++; - lcd1.setDisplay(deviceControlState.setPoint+'F'); if (networkEnabled===true) { opClientToken = thingShadows.update('TemperatureControl', { state: { desired: deviceControlState } }); +// +// If we receive null from the update method, that means an operation +// is still in progress; revert our local state and don't update the UI. +// + if (opClientToken === null) + { + deviceControlState.setPoint--; + renderScreen = false; + } + } + if (renderScreen === true) + { + lcd1.setDisplay(deviceControlState.setPoint+'F'); + screen.render(); } - screen.render(); } }); + screen.key('down', function( ch, key ) { if (deviceControlState.setPoint > 50) { + var renderScreen = true; + deviceControlState.setPoint--; - lcd1.setDisplay(deviceControlState.setPoint+'F'); if (networkEnabled===true) { opClientToken = thingShadows.update('TemperatureControl', { state: { desired: deviceControlState } }); +// +// If we receive null from the update method, that means an operation +// is still in progress; revert our local state and don't update the UI. +// + if (opClientToken === null) + { + deviceControlState.setPoint++; + renderScreen = false; + } + } + if (renderScreen === true) + { + lcd1.setDisplay(deviceControlState.setPoint+'F'); + screen.render(); } - screen.render(); } }); @@ -314,6 +363,10 @@ thingShadows // setTimeout( function() { opClientToken = thingShadows.get('TemperatureControl'); + if (opClientToken === null) + { + log.log('operation in progress'); + } }, 2000 ); }); @@ -340,6 +393,10 @@ thingShadows // setTimeout( function() { opClientToken = thingShadows.update('TemperatureControl', { state: { desired: deviceControlState } }); + if (opClientToken === null) + { + log.log('operation in progress'); + } }, 2000 ); }); @@ -372,6 +429,10 @@ thingShadows { log.log('resync with thing shadow'); opClientToken = thingShadows.get(thingName); + if (opClientToken === null) + { + log.log('operation in progress'); + } } } if (statusType === 'accepted') @@ -432,6 +493,7 @@ thingShadows setInterval( function() { var difference; + var currentInteriorTemp = deviceMonitorState.intTemp; // // If the device is enabled, the internal temperature will move towards // the setpoint; otherwise, it will move towards the external temperature. @@ -466,10 +528,20 @@ thingShadows { deviceMonitorState.curState = 'stopped'; } - if (networkEnabled===true) +// +// Update the thing shadow only if the interior temperature has changed. +// + if ((networkEnabled===true) && + (deviceMonitorState.intTemp !== currentInteriorTemp)) { opClientToken = thingShadows.update('TemperatureStatus', { state: { desired: deviceMonitorState } }); + +// +// We don't worry about rejected operations here since we do these +// once per second; if the shadow couldn't be updated due to an operation +// in progress, it will simply re-attempt it on the next invocation. +// } }, 1000 ); } diff --git a/examples/thing-example.js b/examples/thing-example.js index 1dd60a9..d383780 100644 --- a/examples/thing-example.js +++ b/examples/thing-example.js @@ -19,17 +19,24 @@ //app deps const thingShadow = require('..').thingShadow; -const isUndefined = require('../common/lib/is-undefined'); const cmdLineProcess = require('./lib/cmdline'); //begin module +// +// Simulate the interaction of a mobile device and a remote thing via the +// AWS IoT service. The remote thing will be a dimmable color lamp, where +// the individual RGB channels can be set to an intensity between 0 and 255. +// One process will simulate each side, with testMode being used to distinguish +// between the mobile app (1) and the remote thing (2). The remote thing +// will update its state periodically using an 'update thing shadow' operation, +// and the mobile device will listen to delta events to receive the updated +// state information. +// + function processTest( args ) { // -// The thing module exports the thing class through which we -// can register and unregister interest in thing shadows, perform -// update/get/delete operations on them, and receive delta updates -// when the cloud state differs from the device state. +// Instantiate the thing shadow class. // const thingShadows = thingShadow({ keyPath: args.privateKey, @@ -37,198 +44,194 @@ const thingShadows = thingShadow({ caPath: args.caCert, clientId: args.clientId, region: args.region, - reconnectPeriod: args.reconnectPeriod + reconnectPeriod: args.reconnectPeriod, + protocol: args.Protocol, + port: args.Port, + host: args.Host, + debug: args.Debug }); // -// Track operations in here using clientTokens as indices. +// Operation timeout in milliseconds +// +const operationTimeout = 10000; + +const thingName = 'RGBLedLamp'; + +var currentTimeout = null; + +// +// For convenience, use a stack to keep track of the current client +// token; in this example app, this should never reach a depth of more +// than a single element, but if your application uses multiple thing +// shadows simultaneously, you'll need some data structure to correlate +// client tokens with their respective thing shadows. +// +var stack = []; + +function genericOperation( operation, state ) +{ + var clientToken = thingShadows[operation]( thingName, state ); + + if (clientToken === null) + { +// +// The thing shadow operation can't be performed because another one +// is pending; if no other operation is pending, reschedule it after an +// interval which is greater than the thing shadow operation timeout. +// + if (currentTimeout !== null) { + console.log('operation in progress, scheduling retry...'); + currentTimeout = setTimeout( + function() { genericOperation( operation, state ); }, + operationTimeout * 2 ); + } + } + else + { +// +// Save the client token so that we know when the operation completes. // -var operationCallbacks = { }; + stack.push( clientToken ); + } +} + +function generateRandomState() +{ + var rgbValues={ red: 0, green: 0, blue: 0 }; + + rgbValues.red = Math.floor(Math.random() * 255); + rgbValues.green = Math.floor(Math.random() * 255); + rgbValues.blue = Math.floor(Math.random() * 255); + + return {state: { desired: rgbValues }}; +} -var role='DEVICE'; +function mobileAppConnect() +{ + thingShadows.register( thingName, { ignoreDeltas: false, + operationTimeout: operationTimeout } ); +} + +function deviceConnect() +{ + thingShadows.register( thingName, { ignoreDeltas: true, + operationTimeout: operationTimeout } ); + genericOperation( 'update', generateRandomState() ); +} -if (args.testMode===1) +function handleConnections() { - role='MOBILE APP'; + if (args.testMode === 1) { + mobileAppConnect(); + } else { + deviceConnect(); + } } -var rgbValues={ red: 0, green: 0, blue: 0 }; -var mobileAppOperation='update'; +function handleStatus( thingName, stat, clientToken, stateObject ) +{ + var expectedClientToken = stack.pop(); + + if (expectedClientToken === clientToken) { + console.log( 'got \''+stat+'\' status on: '+thingName); + } + else { + console.log('(status) client token mismtach on: '+thingName); + } + + if (args.testMode === 2) { + console.log('updated state to thing shadow'); // -// Simulate the interaction of a mobile device and a remote thing via the -// AWS IoT service. The remote thing will be a dimmable color lamp, where -// the individual RGB channels can be set to an intensity between 0 and 255. -// One process will simulate each side, with testMode being used to distinguish -// between the mobile app (1) and the remote thing (2). The mobile app -// will wait a random number of seconds and then change the LED lamp's values; -// the LED lamp will synchronize with them upon receipt of an .../update/delta. +// If no other operation is pending, restart it after 10 seconds. // -thingShadows - .on('connect', function() { + if (currentTimeout === null) { + currentTimeout = setTimeout( function() { + currentTimeout = null; + genericOperation( 'update', generateRandomState()); + }, 10000 ); + } + } +} + +function handleDelta( thingName, stateObject ) +{ + if (args.testMode === 2) { + console.log('unexpected delta in device mode: ' + thingName ); + } + else { + console.log( 'delta on: '+thingName+JSON.stringify(stateObject) ); + } +} + +function handleTimeout( thingName, clientToken ) +{ + var expectedClientToken = stack.pop(); + + if (expectedClientToken === clientToken) { + console.log('timeout on: '+thingName); + } + else { + console.log('(timeout) client token mismtach on: '+thingName); + } + + if (args.testMode === 2) { + genericOperation( 'update', generateRandomState()); + } +} + +thingShadows.on('connect', function() { console.log('connected to things instance, registering thing name'); + handleConnections(); +}); - if (args.testMode === 1) - { - thingShadows.register( 'RGBLedLamp', { ignoreDeltas: true, - persistentSubscribe: true } ); - } - else - { - thingShadows.register( 'RGBLedLamp' ); - } - var rgbLedLampState = { }; - - var opFunction = function() { - if (args.testMode === 1) - { -// -// The mobile app sets new values for the LED lamp. -// - rgbValues.red = Math.floor(Math.random() * 255); - rgbValues.green = Math.floor(Math.random() * 255); - rgbValues.blue = Math.floor(Math.random() * 255); - - rgbLedLampState={state: { desired: rgbValues }}; - } - - var clientToken; - - if (args.testMode === 1) - { - if (mobileAppOperation === 'update') - { - clientToken = thingShadows[mobileAppOperation]('RGBLedLamp', - rgbLedLampState ); - } - else // mobileAppOperation === 'get' - { - clientToken = thingShadows[mobileAppOperation]('RGBLedLamp' ); - } - operationCallbacks[clientToken] = { operation: mobileAppOperation, - cb: null }; -// -// Force the next operation back to update in case we had to do a get after -// a 'rejected' status. -// - mobileAppOperation = 'update'; - } - else - { -// -// The device gets the latest state from the thing shadow after connecting. -// - clientToken = thingShadows.get('RGBLedLamp'); - operationCallbacks[clientToken] = { operation: 'get', cb: null }; - } - if (args.testMode === 1) - { - operationCallbacks[clientToken].cb = - function( thingName, operation, statusType, stateObject ) { - - console.log(role+':'+operation+' '+statusType+' on '+thingName+': '+ - JSON.stringify(stateObject)); -// -// If this operation was rejected, force a 'get' as the next operation; it is -// probably a version conflict, and it can be resolved by simply getting the -// latest thing shadow. -// - if (statusType !== 'accepted') - { - mobileAppOperation = 'get'; - } - opFunction(); - }; - } - else - { - operationCallbacks[clientToken].cb = - function( thingName, operation, statusType, stateObject ) { - - console.log(role+':'+operation+' '+statusType+' on '+thingName+': '+ - JSON.stringify(stateObject)); - }; - } - }; - opFunction(); - }); -thingShadows - .on('close', function() { +thingShadows.on('close', function() { console.log('close'); - thingShadows.unregister( 'RGBLedLamp' ); - }); -thingShadows - .on('reconnect', function() { + thingShadows.unregister( thingName ); +}); + +thingShadows.on('reconnect', function() { console.log('reconnect'); - if (args.testMode === 1) + handleConnections(); +}); + +thingShadows.on('offline', function() { +// +// If any timeout is currently pending, cancel it. +// + if (currentTimeout !== null) { - thingShadows.register( 'RGBLedLamp', { ignoreDeltas: true, - persistentSubscribe: true } ); + clearTimeout(currentTimeout); + currentTimeout=null; } - else - { - thingShadows.register( 'RGBLedLamp' ); +// +// If any operation is currently underway, cancel it. +// + while (stack.length) { + stack.pop(); } - }); -thingShadows - .on('offline', function() { console.log('offline'); - }); -thingShadows - .on('error', function(error) { +}); + +thingShadows.on('error', function(error) { console.log('error', error); - }); -thingShadows - .on('message', function(topic, payload) { +}); + +thingShadows.on('message', function(topic, payload) { console.log('message', topic, payload.toString()); - }); -thingShadows - .on('status', function(thingName, stat, clientToken, stateObject) { - if (!isUndefined( operationCallbacks[clientToken] )) - { - setTimeout( function() { - operationCallbacks[clientToken].cb( thingName, - operationCallbacks[clientToken].operation, - stat, - stateObject ); - - delete operationCallbacks[clientToken]; - }, 2000 ); - } - else - { - console.warn( 'status:unknown clientToken \''+clientToken+'\' on \''+ - thingName+'\'' ); - } - }); -// -// Only the simulated device is interested in delta events. -// -if (args.testMode===2) -{ - thingShadows - .on('delta', function(thingName, stateObject) { - console.log(role+':delta on '+thingName+': '+ - JSON.stringify(stateObject)); - rgbValues=stateObject.state; - }); -} +}); -thingShadows - .on('timeout', function(thingName, clientToken) { - if (!isUndefined( operationCallbacks[clientToken] )) - { - operationCallbacks[clientToken].cb( thingName, - operationCallbacks[clientToken].operation, - 'timeout', - { } ); - delete operationCallbacks[clientToken]; - } - else - { - console.warn( 'timeout:unknown clientToken \''+clientToken+'\' on \''+ - thingName+'\'' ); - } - }); +thingShadows.on('status', function(thingName, stat, clientToken, stateObject) { + handleStatus( thingName, stat, clientToken, stateObject ); +}); + +thingShadows.on('delta', function(thingName, stateObject) { + handleDelta( thingName, stateObject ); +}); + +thingShadows.on('timeout', function(thingName, clientToken) { + handleTimeout( thingName, clientToken ); +}); } module.exports = cmdLineProcess; diff --git a/examples/thing-passthrough-example.js b/examples/thing-passthrough-example.js index e55dd04..44f4bd2 100644 --- a/examples/thing-passthrough-example.js +++ b/examples/thing-passthrough-example.js @@ -23,6 +23,13 @@ const cmdLineProcess = require('./lib/cmdline'); //begin module +// +// This test demonstrates the use of thing shadows along with +// non-thing topics. One process updates a thing shadow and +// subscribes to a non-thing topic; the other receives delta +// updates on the thing shadow on publishes to the non-thing +// topic. +// function processTest( args ) { // // Instantiate the thing shadow class. @@ -33,154 +40,216 @@ const thingShadows = thingShadow({ caPath: args.caCert, clientId: args.clientId, region: args.region, - reconnectPeriod: args.reconnectPeriod + reconnectPeriod: args.reconnectPeriod, + protocol: args.Protocol, + port: args.Port, + host: args.Host, + debug: args.Debug }); +// +// Operation timeout in milliseconds +// +const operationTimeout = 10000; + +const thingName = 'thingShadow1'; +const nonThingName = 'nonThingTopic1'; + +// +// The current operation count +// var count=1; -var clientToken; -var timer; + +var currentTimeout = null; // -// This test demonstrates the use of thing shadows along with -// non-thing topics. One process updates a thing shadow and -// subscribes to a non-thing topic; the other receives delta -// updates on the thing shadow on publishes to the non-thing -// topic. +// For convenience, use a stack to keep track of the current client +// token; in this example app, this should never reach a depth of more +// than a single element, but if your application uses multiple thing +// shadows simultaneously, you'll need some data structure to correlate +// client tokens with their respective thing shadows. // -function updateThingShadow( ) -{ - console.log('updating thing shadow...'); - clientToken = thingShadows.update( 'thingShadow1', - { state: { desired: { value: count++ }}} ); -} +var stack = []; -thingShadows - .on('connect', function() { - console.log('connected to things instance, registering thing name'); +function genericOperation( operation, state ) +{ + var clientToken = thingShadows[operation]( thingName, state ); - if (args.testMode === 1) - { + if (clientToken === null) + { // -// This process will update a thing shadow and subscribe to a non- -// thing topic. +// The thing shadow operation can't be performed because another one +// is pending; if no other operation is pending, reschedule it after an +// interval which is greater than the thing shadow operation timeout. // - thingShadows.register( 'thingShadow1', { ignoreDeltas: true } ); - console.log('subscribing to non-thing topic'); - thingShadows.subscribe( 'nonThingTopic1' ); + if (currentTimeout !== null) { + console.log('operation in progress, scheduling retry...'); + currentTimeout = setTimeout( + function() { genericOperation( operation, state ); }, + operationTimeout * 2 ); + } + } + else + { // -// Begin by updating the state on the thing shadow; this will start the -// exchange between the two processes. +// Save the client token so that we know when the operation completes. // - setTimeout( updateThingShadow(), 3000 ); + stack.push( clientToken ); + } +} + +function generateState() +{ + return { state: { desired: { value: count++ }}}; +} + +function thingUpdateeConnect() +{ +// +// This process receives deltas from the thing shadow and publishes the +// data to the non-thing topic. +// + thingShadows.register( thingName, { ignoreDeltas: false, + operationTimeout: operationTimeout } ); +} + +function thingUpdaterConnect() +{ // -// If no message has been received after 10 seconds, try again. +// This process updates the thing shadow and subscribes to the non-thing +// topic. // - timer = setInterval( function() { - updateThingShadow(); - }, 10000 ); - } - else - { + thingShadows.register( thingName, { ignoreDeltas: true, + operationTimeout: operationTimeout } ); + genericOperation( 'update', generateState() ); + thingShadows.subscribe( nonThingName ); +} + +function handleConnections() +{ + if (args.testMode === 1) { + thingUpdateeConnect(); + } else { + thingUpdaterConnect(); + } +} + +function handleStatus( thingName, stat, clientToken, stateObject ) +{ + var expectedClientToken = stack.pop(); + + if (expectedClientToken === clientToken) { + console.log( 'got \''+stat+'\' status on: '+thingName); + } + else { + console.log('(status) client token mismtach on: '+thingName); + } + + if (args.testMode === 2) { + console.log('updated state to thing shadow'); // -// This process will listen to deltas on a thing shadow and publish to a -// non-thing topic. +// If no other operation is pending, restart it after 10 seconds. // - thingShadows.register( 'thingShadow1' ); - } - }); -thingShadows - .on('close', function() { + if (currentTimeout === null) { + currentTimeout = setTimeout( function() { + currentTimeout = null; + genericOperation( 'update', generateState()); + }, 10000 ); + } + } +} + +function handleDelta( thingName, stateObject ) +{ + if (args.testMode === 2) { + console.log('unexpected delta in device mode: ' + thingName ); + } + else { + console.log('received delta on '+thingName+ + ', publishing on non-thing topic...'); + thingShadows.publish( nonThingName, + JSON.stringify({ message: 'received '+ + JSON.stringify(stateObject.state) } )); + } +} + +function handleTimeout( thingName, clientToken ) +{ + var expectedClientToken = stack.pop(); + + if (expectedClientToken === clientToken) { + console.log('timeout on: '+thingName); + } + else { + console.log('(timeout) client token mismtach on: '+thingName); + } + + if (args.testMode === 2) { + genericOperation( 'update', generateState()); + } +} + +function handleMessage( topic, payload ) +{ + console.log('received on \''+topic+'\': '+ payload.toString()); +} + +thingShadows.on('connect', function() { + console.log('connected to things instance, registering thing name'); + handleConnections(); +}); + +thingShadows.on('close', function() { console.log('close'); - thingShadows.unregister( 'thingShadow1' ); - if (args.testMode === 1) - { - clearInterval( timer ); - timer=null; - } - }); -thingShadows - .on('reconnect', function() { + thingShadows.unregister( thingName ); +}); + +thingShadows.on('reconnect', function() { console.log('reconnect'); - if (args.testMode === 1) - { - thingShadows.register( 'thingShadow1', { ignoreDeltas: true } ); - thingShadows.subscribe( 'nonThingTopic1' ); + handleConnections(); +}); + +thingShadows.on('offline', function() { // -// Begin by updating the state on the thing shadow; this will start the -// exchange between the two processes. +// If any timeout is currently pending, cancel it. // - setTimeout( updateThingShadow(), 3000 ); + if (currentTimeout !== null) + { + clearTimeout(currentTimeout); + currentTimeout=null; + } // -// If no message has been received after 10 seconds, try again. +// If any operation is currently underway, cancel it. // - timer = setInterval( function() { - updateThingShadow(); - }, 10000 ); - } - else - { - thingShadows.register( 'thingShadow1' ); + while (stack.length) { + stack.pop(); } - }); -thingShadows - .on('offline', function() { console.log('offline'); - }); -thingShadows - .on('error', function(error) { +}); + +thingShadows.on('error', function(error) { console.log('error', error); - }); -thingShadows - .on('message', function(topic, payload) { - console.log('received on \''+topic+'\': '+ payload.toString()); - clearInterval( timer ); -// -// After a few seconds, update the thing shadow and if no message has -// been received after 10 seconds, try again. -// - setTimeout( function() { - updateThingShadow(); - timer = setInterval( function() { - updateThingShadow(); - }, 10000 ); - }, 3000 ); +}); - }); -// -// Only the second process is interested in delta events. -// -if (args.testMode===2) -{ - thingShadows - .on('delta', function(thingName, stateObject) { - console.log('received delta on '+thingName+', publishing on non-thing topic...'); - thingShadows.publish( 'nonThingTopic1', - JSON.stringify({ message: 'received '+ - JSON.stringify(stateObject.state) } )); - }); -} +thingShadows.on('message', function(topic, payload) { + console.log('message', topic, payload.toString()); +}); + +thingShadows.on('status', function(thingName, stat, clientToken, stateObject) { + handleStatus( thingName, stat, clientToken, stateObject ); +}); -thingShadows - .on('status', function(thingName, statusType, clientToken, stateObject) { - if (statusType !== 'accepted') - { -// -// This update wasn't accepted; do a get operation to re-sync. Wait -// a few seconds, then get the thing shadow to re-sync; restart the -// interval timer. -// - clearInterval( timer ); - - console.log('status: '+statusType+', state: '+ - JSON.stringify(stateObject)); - setTimeout( function() { - clientToken = thingShadows.get( 'thingShadow1' ); - timer = setInterval( function() { - updateThingShadow(); - }, 10000 ); - }, 3000 ); - } - }); +thingShadows.on('delta', function(thingName, stateObject) { + handleDelta( thingName, stateObject ); +}); + +thingShadows.on('timeout', function(thingName, clientToken) { + handleTimeout( thingName, clientToken ); +}); + +thingShadows.on('message', function(topic, payload) { + handleMessage( topic, payload ); +}); } module.exports = cmdLineProcess; diff --git a/gulpfile.js b/gulpfile.js index 62ea30f..a5635e0 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,31 +1,38 @@ -/* - * Copyright 2010-2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ /*jshint node:true*/ 'use strict'; var gulp = require('gulp'), jshint = require('gulp-jshint'), concat = require('gulp-concat'), + mocha = require('gulp-mocha'), + cover = require('gulp-coverage'), jscs = require('gulp-jscs'); -gulp.task('default', ['jshint']); +gulp.task('default', ['test']); + +gulp.task('test', ['jshint'], function() { + console.log('Running unit tests'); + return gulp.src(['test/*unit-tests.js'], {read: false}) + .pipe(cover.instrument({ + pattern: ['common/lib/*.js','device/**/*.js','thing/*.js','index.js'], + debugDirectory: 'debug' + })) + .pipe(mocha({ + reporter: 'spec', + globals: {} + })) + .pipe(cover.gather()) + .pipe(cover.format()) + .pipe(gulp.dest('reports')) + .once('end', function() { + process.exit(); + }); +}); gulp.task('jshint', function() { console.log('Analyzing source with JSHint and JSCS'); return gulp - .src(['common/lib/*.js','examples/**/*.js','device/*.js','thing/*.js','index.js']) + .src(['common/lib/*.js','examples/**/*.js', 'device/**/*.js','thing/*.js','index.js', '!node_modules/**/*.js', '!examples/**/node_modules/**/*.js']) .pipe(jshint()) .pipe(jshint.reporter('jshint-stylish', {verbose: true})) .pipe(jshint.reporter('fail')) diff --git a/package.json b/package.json index 68f30fd..a673efc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aws-iot-device-sdk", "description": "AWS IoT JavaScript SDK for Embedded Devices", - "version": "1.0.7", + "version": "1.0.10", "author": { "name": "Amazon Web Services", "email": "", @@ -10,7 +10,9 @@ "homepage": "https://github.com/aws/aws-iot-device-sdk-js", "contributors": [ "Gary Wicker ", - "Frank Lovecchio " + "Frank Lovecchio ", + "Tristam MacDonald ", + "Liusu Zeng " ], "main": "index.js", "engines": { @@ -21,8 +23,7 @@ "url": "git://github.com/aws/aws-iot-device-sdk-js" }, "bugs": { - "url": "http://github.com/aws/aws-iot-device-sdk-js/issues", - "mail": "IotDeviceSDKSupport@amazon.com" + "url": "http://github.com/aws/aws-iot-device-sdk-js/issues" }, "license": "Apache-2.0", "keywords": [ @@ -34,7 +35,9 @@ ], "dependencies": { "mqtt": "1.4.3", - "minimist": "1.2.0" + "minimist": "1.2.0", + "websocket-stream": "^2.0.2", + "crypto-js": "3.1.5" }, "devDependencies": { "gulp": "^3.9.0", @@ -42,10 +45,12 @@ "gulp-jscs": "^1.6.0", "gulp-jshint": "^1.11.1", "gulp-mocha": "^2.1.2", - "jshint-stylish": "^2.0.1" + "jshint-stylish": "^2.0.1", + "gulp-coverage": "^0.3.38", + "sinon": "^1.17.2", + "rewire": "^2.5.1" }, "scripts": { - "postinstall": "node ./node_modules/gulp/bin/gulp.js --verbose", "test": "node ./node_modules/gulp/bin/gulp.js --verbose" } } diff --git a/thing/index.js b/thing/index.js old mode 100644 new mode 100755 index 15864b3..74c1cb7 --- a/thing/index.js +++ b/thing/index.js @@ -176,7 +176,7 @@ function ThingShadowsClient( deviceOptions, thingShadowOptions ) { } // // Subscribe/unsubscribe from the topics and perform callback when complete. -// +// if (!isUndefined( callback )) { device[devFunction]( topics, { qos: 0 }, callback ); @@ -276,6 +276,10 @@ function ThingShadowsClient( deviceOptions, thingShadowOptions ) { thingShadows[thingName].timeouts[clientToken]); delete thingShadows[thingName].timeouts[clientToken]; +// +// Mark this operation as complete. +// + thingShadows[thingName].pending = false; // // Unsubscribe from the 'accepted' and 'rejected' sub-topics unless we are @@ -352,125 +356,146 @@ function ThingShadowsClient( deviceOptions, thingShadowOptions ) { } } }); + this._thingOperation = function( thingName, operation, stateObject ) { var rc = null; if (thingShadows.hasOwnProperty( thingName )) { // +// Don't allow a new operation if an existing one is still in process. +// + if (thingShadows[thingName].pending === false) { +// +// Starting a new operation +// + thingShadows[thingName].pending = true; +// // If not provided, construct a clientToken from the clientId and a rolling // operation count. The clientToken is transmitted in any published stateObject // and is returned to the caller for each operation. Applications can use // clientToken values to correlate received responses or timeouts with // the original operations. // - var clientToken; + var clientToken; - if (isUndefined(stateObject.clientToken)) - { - clientToken = deviceOptions.clientId+'-'+operationCount++; - } - else - { - clientToken = stateObject.clientToken; - } + if (isUndefined(stateObject.clientToken)) + { + clientToken = deviceOptions.clientId+'-'+operationCount++; + } + else + { + clientToken = stateObject.clientToken; + } - var publishTopic = buildThingShadowTopic( thingName, - operation ); + var publishTopic = buildThingShadowTopic( thingName, + operation ); // // Subscribe to the 'accepted' and 'rejected' sub-topics for this get // operation and set a timeout beyond which they will be unsubscribed if // no messages have been received for either of them. // - thingShadows[thingName].timeouts[clientToken] = setTimeout( - function( thingName, clientToken ) { + thingShadows[thingName].timeouts[clientToken] = setTimeout( + function( thingName, clientToken ) { // // Timed-out. Unsubscribe from the 'accepted' and 'rejected' sub-topics unless // we are persistently subscribing to this thing shadow. // - if (thingShadows[thingName].persistentSubscribe === false) - { - that._handleSubscriptions( thingName, - [ operation ], - ['accepted','rejected'], - 'unsubscribe' ); - } + if (thingShadows[thingName].persistentSubscribe === false) + { + that._handleSubscriptions( thingName, + [ operation ], + ['accepted','rejected'], + 'unsubscribe' ); + } +// +// Mark this operation as complete. +// + thingShadows[thingName].pending = false; // // Emit an event for the timeout; the clientToken is included as an argument // so that the application can correlate timeout events to the operations // they are associated with. // - that.emit( 'timeout', thingName, clientToken ); + that.emit( 'timeout', thingName, clientToken ); // // Delete the timeout handle for this thingName/clientToken combination. // - delete thingShadows[thingName].timeouts[clientToken]; - }, operationTimeout, - thingName, clientToken ); - + delete thingShadows[thingName].timeouts[clientToken]; + }, operationTimeout, + thingName, clientToken ); // // Subscribe to the 'accepted' and 'rejected' sub-topics unless we are // persistently subscribing, in which case we can publish to the topic immediately // since we are already subscribed to all applicable sub-topics. // - if (thingShadows[thingName].persistentSubscribe === false) - { - this._handleSubscriptions( thingName, - [ operation ], - [ 'accepted', 'rejected' ], - 'subscribe', - function(err) { + if (thingShadows[thingName].persistentSubscribe === false) + { + this._handleSubscriptions( thingName, + [ operation ], + [ 'accepted', 'rejected' ], + 'subscribe', + function(err) { // // If 'stateObject' is defined, publish it to the publish topic for this // thingName+operation. // - if (err !== null) - { - console.warn('failed subscription to accepted/rejected topics'); - return; - } - if (!isUndefined(stateObject)) - { -// -// Add the 'version' (if known) and 'clientToken' properties to the stateObject. -// - if (!isUndefined(thingShadows[thingName].version)) + if (err !== null) { - stateObject.version=thingShadows[thingName].version; + console.warn('failed subscription to accepted/rejected topics'); + return; } - stateObject.clientToken=clientToken; - setTimeout( function() { - device.publish( publishTopic, JSON.stringify(stateObject) ); - if (!(isUndefined(thingShadows[thingName])) && - thingShadows[thingName].debug === true) + if (!isUndefined(stateObject)) + { +// +// Add the 'version' (if known) and 'clientToken' properties to the stateObject. +// + if (!isUndefined(thingShadows[thingName].version)) { - console.log('publishing \''+JSON.stringify(stateObject)+ - ' on \''+publishTopic+'\''); + stateObject.version=thingShadows[thingName].version; } - }, postSubscribeTimeout ); - } - }); - } - else - { + stateObject.clientToken=clientToken; + + setTimeout( function() { + device.publish( publishTopic, JSON.stringify(stateObject) ); + if (!(isUndefined(thingShadows[thingName])) && + thingShadows[thingName].debug === true) + { + console.log('publishing \''+JSON.stringify(stateObject)+ + ' on \''+publishTopic+'\''); + } + }, postSubscribeTimeout ); + } + }); + } + else + { // // Add the 'version' (if known) and 'clientToken' properties to the stateObject. // - if (!isUndefined(thingShadows[thingName].version)) - { - stateObject.version=thingShadows[thingName].version; + if (!isUndefined(thingShadows[thingName].version)) + { + stateObject.version=thingShadows[thingName].version; + } + stateObject.clientToken=clientToken; + + device.publish( publishTopic, JSON.stringify(stateObject) ); + if (thingShadows[thingName].debug === true) + { + console.log('publishing \''+JSON.stringify(stateObject)+ + ' on \''+publishTopic+'\''); + } } - stateObject.clientToken=clientToken; - - device.publish( publishTopic, JSON.stringify(stateObject) ); - if (thingShadows[thingName].debug === true) + rc = clientToken; // return the clientToken to the caller + } + else + { + if (deviceOptions.debug === true) { - console.log('publishing \''+JSON.stringify(stateObject)+ - ' on \''+publishTopic+'\''); + console.error(operation+' still in progress on thing: ', thingName); } } - rc = clientToken; // return the clientToken to the caller } else { @@ -493,7 +518,8 @@ function ThingShadowsClient( deviceOptions, thingShadowOptions ) { thingShadows[thingName] = {timeouts: { }, persistentSubscribe: true, debug: false, - discardStale: true }; + discardStale: true, + pending: false }; if (!isUndefined( options )) {