Source: lib/platform.js

"use strict";

/** @module */
module.exports = WirelessTagPlatform;

var request = require('request'),
    http = require('http'),
    util = require('util'),
    delay = require('timeout-as-promise'),
    EventEmitter = require('events');

var u = require('./util'),
    WirelessTagManager = require('./tagmanager'),
    WirelessTag = require('./tag');

/**
 * @const {string} - The default base URI for the JSON API server.
 * @default
 */
const API_BASE_URI = 'https://www.mytaglist.com';

/**
 * @const {number} - The time in milliseconds to wait before retrying an
 *                   operation that failed because the tag did not respond.
 * @default
 */
const WAIT_BEFORE_RETRY = 8000;

/**
 * Instantiates {@link WirelessTagPlatform}.
 *
 * @param {Object} [options]
 * @param {String} [options.log] - a custom log function.
 * @param {function} [options.errorHandler] - a function returning a custom
 *                       error handler, will be passed a callback function.
 * @param {String} [options.apiBaseURI] - the base URI of the API
 *                       server if hosted on a different server than
 *                       the default ([API_BASE_URI]{@link
 *                       module:lib/platform~API_BASE_URI})
 * @param {Object} [options.factory] - a factory for tag and tag manager
 *                       objects, see {@link WirelessTagPlatform.factory}
 *                       which will be used by default
 *
 * @class
 * @alias WirelessTagPlatform
 */
function WirelessTagPlatform(options) {
    EventEmitter.call(this);
    if (options === undefined) options = {};
    this.log = options.log || console;
    this.errorHandler = options.errorHandler || u.defaultHandler;
    /** @member {string} - see default ([API_BASE_URI]{@link module:lib/platform~API_BASE_URI}) */
    this.apiBaseURI = options.apiBaseURI || API_BASE_URI;
    /** @member {function} - see {@link WirelessTagPlatform.callAPI} */
    this.callAPI = WirelessTagPlatform.callAPI;
    this._tagManagersByMAC = new Map();
    /**
     * @member {WirelessTagPlatform~factory}
     * @since 0.6.0
     */
    this.factory = options.factory || WirelessTagPlatform.factory(this);
    /**
     * Whether or not this object is currently in the process of connecting
     * (i.e., signing in).
     * @name connecting
     * @type {boolean}
     * @memberof WirelessTagPlatform#
     * @since 0.6.0
     */
    Object.defineProperty(this, "connecting", {
        get: function() { return this._connecting === true }
    });
    /** @member {function} - alias for {@link WirelessTagPlatform#signin} */
    this.connect = this.signin;
    /**
     * @member {function} - alias for {@link WirelessTagPlatform#signoff}
     * @since 0.6.0
     */
    this.disconnect = this.signoff;
    /** @member {function} - alias for {@link WirelessTagPlatform#isSignedIn} */
    this.isConnected = this.isSignedIn;
}
util.inherits(WirelessTagPlatform, EventEmitter);

/**
 * Connect event. Emitted after the platform object successfully
 * connects to the cloud.
 *
 * @event WirelessTagPlatform#connect
 * @type {WirelessTagPlatform}
 */
/**
 * Discover event. Emitted for every {@link WirelessTagManager}
 * instance discovered.
 *
 * @event WirelessTagPlatform#discover
 * @type {WirelessTagManager}
 */

/**
 * Signs in to the cloud API with the given credentials. Because there is no
 * persistent connection, here this is synonymous with connecting. (This used
 * to be named `connect()` prior to v0.6.0, which remains an alias.)
 *
 * @param {Object} opts - connection parameters
 * @param {String} opts.username - the username (email) for connecting
 * @param {String} opts.password - the password for connecting
 * @param {module:wirelesstags~apiCallback} [callback]
 *
 * @since 0.6.0
 * @fires WirelessTagPlatform#connect
 * @returns {Promise} resolves to 'this' upon success
 */
WirelessTagPlatform.prototype.signin = function(opts, callback) {

    this._connecting = true;

    return this.callAPI(
        '/ethAccount.asmx/Signin',
        { email: opts.username, password: opts.password },
        callback
    ).then(
        () => {
            this._connecting = false;
            this.emit('connect', this);
            if (callback) callback(null, { object: this });
            return this;
        },
        (err) => {
            this._connecting = false;
            return this.errorHandler(callback)(err);
        }
    );
};

/**
 * Signs off from the cloud API, which here is synonymous with disconnecting.
 *
 * @param {module:wirelesstags~apiCallback} [callback]
 *
 * @since 0.6.0
 * @fires WirelessTagPlatform#disconnect
 * @returns {Promise} resolves to 'this' upon success
 */
WirelessTagPlatform.prototype.signoff = function(callback) {

    return this.callAPI(
        '/ethClient.asmx/SignOut',
        {},
        callback
    ).then(
        () => {
            this.emit('disconnect', this);
            if (callback) callback(null, { object: this });
            return this;
        },
        this.errorHandler(callback)
    );
};

/**
 * Tests whether this instance is signed in to the cloud API. (This used to
 * be named `isConnected()` prior to v0.6.0, which remains an alias.)
 *
 * @param {module:wirelesstags~apiCallback} [callback]
 *
 * @returns {Promise} resolves to true if signed in, and false otherwise
 * @since 0.6.0
 */
WirelessTagPlatform.prototype.isSignedIn = function(callback) {
    return this.callAPI('/ethAccount.asmx/IsSignedIn', {}, callback).
        then((res) => {
            if (callback) callback(null, { object: this, value: res });
            return res;
        });
};

/**
 * Retrieves the tag managers available to the connected account. The
 * list is optionally filtered depending on the supplied query
 * parameter.
 *
 * Note that using the 'query' parameter as opposed to filtering the
 * returned tag manager objects will really only be useful to prevent
 * 'discover' events from being fired for undesired tag manager
 * objects. The filtering does not happen at the API endpoint, and so
 * has almost no performance benefits, unless there are many tag
 * managers under the account and the 'discover' event listener were
 * somehow expensive to execute.
 *
 * @param {Object} [query] - an object with keys and values that a tag
 *                 manager data object returned by the API has to
 *                 meet. The most useful ones are likely 'name' and
 *                 'mac'. Consult the [GetTagManagers JSON API]{@link
 *                 http://wirelesstag.net/media/mytaglist.com/ethAccount.asmx@op=GetTagManagers.html}
 *                 for possible keys.
 * @param {module:wirelesstags~apiCallback} [callback] - if provided,
 *                `query` must be provided too, even if as value undefined.
 *
 * @fires WirelessTagPlatform#discover
 * @returns {Promise} resolves to an array of (optionally filtered)
 *                    {@link WirelessTagManager} instances
 */
WirelessTagPlatform.prototype.discoverTagManagers = function(query, callback) {

    var req = this.callAPI(
        '/ethAccount.asmx/GetTagManagers',
        {},
        callback);
    return req.then(
        (result) => {
            let knownMgrs = new Map(this._tagManagersByMAC);
            let tagManagers = [];
            result = result.filter(u.createFilter(query));
            for (let mgrData of result) {
                let tagManager = knownMgrs.get(mgrData.mac);
                if (tagManager) {
                    tagManager.data = mgrData;
                } else {
                    tagManager = this.factory.createTagManager(mgrData);
                    this.emit('discover', tagManager);
                }
                tagManagers.push(tagManager);
            }

            // In the absence of filtering we should have a complete list of
            // tag managers currently accessible to the logged-in account.
            // Use this to rebuild the cache of known tag managers, so that
            // tag managers to which the logged in account no longer
            // has access are removed from the cache.
            let usedFilter = query && (Object.keys(query).length >= 0);
            let mgrCache = usedFilter ? this._tagManagersByMAC : new Map();
            tagManagers.forEach((m) => mgrCache.set(m.mac, m));
            // also try to make the change of the cache as atomic of an
            // operation as possible
            if (! usedFilter) this._tagManagersByMAC = mgrCache;

            if (callback) callback(null, { object: this, value: tagManagers });
            return tagManagers;
        },
        this.errorHandler(callback)
    );
};

/**
 * Retrieves the tag manager with the given MAC identifier. If the
 * matching object is cached from an earlier call to this or the
 * {@link WirelessTagPlatform#discoverTagManagers} method, the cached
 * object is returned. Otherwise the matching object, if one is
 * available and accessible to the logged-in account, is retrieved
 * from the cloud.
 *
 * Note that the [discover event]{@link WirelessTagPlatform#event:discover}
 * is only fired if the tag manager wasn't cached yet.
 *
 * @param {string} mac - the MAC identifier for the tag manager
 * @param {module:wirelesstags~apiCallback} [callback]
 *
 * @fires WirelessTagPlatform#discover
 * @returns {Promise} resolves to the matching {@link WirelessTagManager}
 *                    instance if one is accessible to the logged-in account,
 *                    and to undefined otherwise.
 * @since 0.6.0
 */
WirelessTagPlatform.prototype.findTagManager = function(mac, callback) {
    let mgr = this.getTagManager(mac);
    if (mgr) {
        if (callback) callback(null, { object: this, value: mgr });
        return Promise.resolve(mgr);
    }
    let ecb = (err) => { if (err) return callback(err); };
    return this.discoverTagManagers({ mac: mac }, ecb).then((mgrs) => {
        if (callback) callback(null, { object: this, value: mgrs[0] });
        return mgrs[0];
    });
};

/**
 * Retrieves the tag manager object with the given MAC identifier from
 * the cache.
 *
 * Note that this method will _not_ consult an API endpoint if the
 * object is not yet cached. Hence, no 'discover' event will be fired.
 *
 * @param {string} mac - the MAC identifier for the tag manager
 *
 * @returns {WirelessTagManager} the matching {@link WirelessTagManager}
 *                    instance if one is cached, and undefined otherwise.
 * @since 0.6.0
 */
WirelessTagPlatform.prototype.getTagManager = function(mac) {
    return this._tagManagersByMAC.get(mac);
};

/**
 * Invokes the given action on each tag manager object currently cached,
 * and returns the results as an array. If no action is specified, return
 * the currently cached tag manager objects.
 *
 * @param {function} action - the function to invoke for each tag manager object
 * @returns {Array} the results of each invocation
 * @since 0.6.2
 */
WirelessTagPlatform.prototype.eachTagManager = function(action) {
    let retVals = [];
    let mgrMap = this._tagManagersByMAC;
    if (action === undefined) {
        if (mgrMap.values) return Array.from(mgrMap.values());
        action = (mgr) => mgr;
    }
    this._tagManagersByMAC.forEach((mgr) => retVals.push(action(mgr)));
    return retVals;
};

/**
 * Retrieves the tags available to the connected account. The list is
 * optionally filtered depending on the supplied query parameter.
 *
 * This method offers an alternative discovery path compared to
 * discovering tag manager objects first (through {@link
 * WirelessTagPlatform#discoverTagManagers}), and then for each one
 * finding its associated tags. In the case of multiple tag managers
 * this method will be more efficient, because the respective Web
 * Service API endpoints do not support filtering results server-side
 * anyway.
 *
 * Note that tag manager objects newly created as a side effect will
 * generate ['discover']{@link WirelessTagPlatform#event:discover} events,
 * and the tag manager objects will in turn fire ['discover']{@link WirelessTagManager#event:discover}
 * events for each of their associated tags.
 *
 * @param {Object} [query] - an object with keys and values that a tag
 *                 data object returned by the API has to meet. The
 *                 most useful ones are likely `name` and
 *                 `uuid`. Consult the [GetTagForSlaveId JSON API]{@link http://wirelesstag.net/media/mytaglist.com/ethClient.asmx@op=GetTagForSlaveId.html}
 *                 for possible keys. The special key `wirelessTagManager`
 *                 can be used to add a query object for tag managers
 *                 (see {@link WirelessTagPlatform#discoverTagManagers}).
 * @param {module:wirelesstags~apiCallback} [callback] - if provided,
 *                `query` must be provided too, even if as value undefined.
 *
 * @fires WirelessTagPlatform#discover
 * @fires WirelessTagManager#discover
 * @returns {Promise} resolves to an array of {@link WirelessTag}
 *                    instances associated with tag managers
 *                    accessible to the logged-in account.
 * @since 0.6.0
 */
WirelessTagPlatform.prototype.discoverTags = function(query, callback) {

    // we will need all matching tag manager objects anyway, so request an
    // up-to-date cache of those upfront, possibly filtering if requested
    query = Object.assign({}, query);   // copy so we can manipulate keys
    let mgrFilter = u.createFilter(query.wirelessTagManager);
    let ecb = (err) => { if (err) return callback(err); };
    let req = this.discoverTagManagers(mgrFilter, ecb);
    delete query.wirelessTagManager;    // ensure this doesn't interfere below

    // only then make the actual API call for discovering tags
    req = req.then(() => this.callAPI('/ethClient.asmx/GetTagManagerTagList',
                                      {},
                                      callback));
    return req.then(
        (result) => {
            let filter = u.createFilter(query);
            let tagObjs = [];
            for (let rec of result) {
                let mgr = this.getTagManager(rec.mac);

                // skip this record if tag managers are filtered and
                // it doesn't pass the filter
                if (! mgrFilter(mgr || rec)) continue;

                // if record passes tag manager filter, the object is required
                if (! mgr) {
                    let e = new Error("Tag manager "+ rec.mac +" not found");
                    if (callback) callback(e);
                    throw e;  // if no callback, or the callback didn't throw
                }

                // create and populate tag objects
                let tagsList = rec.tags.filter(filter);
                for (let tagData of tagsList) {
                    let tag = this.factory.createTag(mgr, tagData);
                    // console.log(tagData);
                    tagObjs.push(tag);
                    mgr.emit('discover', tag);
                }
            }
            if (callback) callback(null, { object: this, value: tagObjs });
            return tagObjs;
        },
        this.errorHandler(callback)
    );
};

/**
 * Selects the given tag manager for subsequent API calls that expect
 * it, if the tag manager is not already selected. Note that the
 * library will call this automatically, and so a user will not
 * normally need to do so.
 *
 * @param {WirelessTagManager} tagManager - the tag manager instance to select
 * @param {module:wirelesstags~apiCallback} [callback]
 *
 * @returns {Promise} resolves to the tag manager instance
 */
WirelessTagPlatform.prototype.selectTagManager = function(tagManager, callback) {
    if (tagManager.selected) return Promise.resolve(tagManager);

    var req = this.callAPI(
        '/ethAccount.asmx/SelectTagManager',
        { mac: tagManager.mac },
        callback);
    return req.then(
        () => {
            tagManager.data.selected = true;
            if (callback) callback(null, { object: this, value: tagManager });
            return tagManager;
        },
        this.errorHandler(callback)
    );
};

/**
 * Queries and/or sets whether failed API calls should be retried. By
 * default this is off.
 *
 * Note that failed calls are only retried under certain conditions,
 * and only for a certain number of times. At present, the condition
 * is that failure be due to the tag not responding, and the call is
 * retried only once, after waiting [WAIT_BEFORE_RETRY]{@link
 * module:lib/platform~WAIT_BEFORE_RETRY} milliseconds.
 *
 * @param {boolean} [enable] - on set, whether or not to enable retrying
 * @returns {boolean} whether or not retrying is currently enabled
 */
WirelessTagPlatform.prototype.retryOnError = function(enable) {
    if (enable !== undefined) this._retryAPICalls = enable;
    return this._retryAPICalls || false;
};

/**
 * @typedef {Object} WirelessTagPlatform~factory
 * @property {function} createTag - expects two parameters, the {@link
 *              WirelessTagManager} instance with which the tag object
 *              to be created is associated, and the tag's attributes
 *              as an object (typically this is returned by the cloud
 *              API)
 * @property {function} createTagManager - expects one parameter, the
 *              tag manager's attributes as an object (typically this
 *              is returned by the cloud API).
 */

/**
 * Creates and returns a factory for Wireless Tag objects,
 * specifically tag and tag manager objects. It is the default factory.
 *
 * @param {WirelessTagPlatform} [platform] - the platform instance
 *                   that will be using the factory. Can be omitted,
 *                   but then any attempt to invoke cloud API-relying
 *                   methods of the created objects will fail.
 * @returns {WirelessTagPlatform~factory}
 * @since 0.6.0
 */
WirelessTagPlatform.factory = function(platform) {
    let f = {
        createTag: (mgr, data) => {
            if (! mgr.wirelessTagPlatform) mgr.wirelessTagPlatform = platform;
            let tag = new WirelessTag(mgr, data);
            if (! tag.callAPI) tag.callAPI = WirelessTagPlatform.callAPI;
            if (! tag.log) tag.log = console;
            return tag;
        },
        createTagManager: (data) => new WirelessTagManager(platform, data)
    };
    return f;
};

/**
 * Performs a call to the cloud JSON API. Users should not normally
 * need to call this method directly.
 *
 * Note that the method tries to infer necessary pre-steps based on
 * the value of 'this'. It is thus meant to be called as an instance
 * method, with this set to the instance in the library's class
 * hierarchy from where the call would be coming. For example,
 * tag-specific calls should have 'this' bound to a {@link
 * WirelessTag} instance.
 *
 * @param {string} uri - the uri for the API endpoint to be called;
 *                 will be prefixed with the base URI if not an
 *                 absolute URI.
 * @param {object} reqBody - the request body as an object
 * @param {module:wirelesstags~apiCallback} [callback] - note that
 *                 this method will only call this in the event of
 *                 error, and it is the caller's responsibility to
 *                 call it with the appropriately processed return
 *                 value in case of success.
 *
 * @returns {Promise} Resolves to the value of the 'd' property of the
 *                 response body from the API endpoint (or the body itself
 *                 if there is no 'd' property). Invokes error handler
 *                 function on error. The default handler will rethrow the
 *                 error, resulting in rejecting the promise.
 */
WirelessTagPlatform.callAPI = function(uri, reqBody, callback) {
    let platform, tagManager;

    /* eslint-disable consistent-this */
    if (this instanceof WirelessTagPlatform) {
        platform = this;
        // in this case no tag manager, and none should be needed
    } else if (this instanceof WirelessTagManager) {
        tagManager = this;
    } else {
        // caller could be sensor or tag instance, try first as sensor
        let tag = this.wirelessTag;
        // if not sensor, try as tag
        tagManager = tag ? tag.wirelessTagManager : this.wirelessTagManager;
    }
    if (tagManager) platform = tagManager.wirelessTagPlatform;
    /* eslint-enable consistent-this */

    // prefix with base URI if not already an absolute URI
    if (! (uri.startsWith('https://') || uri.startsWith('http://'))) {
        let api_base = platform ? platform.apiBaseURI : API_BASE_URI;
        uri = api_base + uri;
    }

    // if we got a tag manager instance and it may need to be selected,
    // specify it in the header
    let options;
    if (tagManager
        && ((!tagManager.selected)
            || (platform && platform.eachTagManager().length > 1))) {
        options = { headers: { 'X-Set-Mac': tagManager.mac } };
    }

    // perform the API call
    let apiCall = makeAPICall(uri, reqBody, options).catch((e) => {
        // if the call failed due to lack of response from a tag, try again
        // once if we're configured to do so
        if (platform
            && platform.retryOnError()
            && (e instanceof TagDidNotRespondError)) {
            return delay(WAIT_BEFORE_RETRY).then(
                () => makeAPICall(uri, reqBody, options)
            );
        }
        let handler = platform ?
            platform.errorHandler(callback) : u.defaultHandler(callback);
        handler(e);
    });
    return apiCall;
};

/**
 * Invokes an endpoint of the Wireless Tag JSON API, and returns a promise
 * that resolves to the result (see below).
 *
 * @param {string} uri - The URI of the endpoint to invoke. If defined and
 *          non-null, overrides the `uri` property possibly given in the
 *          `options` parameter.
 * @param {object} reqBody - The body for the request, as a JSON object. If
 *          defined and non-null, overrides the `body` property possibly
 *          given in the `options` parameter.
 * @param {object} [options] - options to be passed through to `request()`,
 *          such as custom headers.
 *
 * @returns {Promise} Resolves to the value of the `d` property of the
 *          response body from the API endpoint (or the body itself if there
 *          is no `d` property). Rejects in case of error.
 */
function makeAPICall(uri, reqBody, options) {
    let opts = {
        method: 'POST',
        json: true,
        jar: true,
        gzip: true
    };
    if (options) opts = Object.assign(opts, options);
    if (uri) opts.uri = uri;
    if (arguments.length < 3 && ! reqBody) reqBody = {};
    if (reqBody) opts.body = reqBody;
    let apiCall = new Promise((resolve, reject) => {
        request(opts, function (error, response, body) {
            error = checkAPIerror(error, response, opts.uri, opts.body, body);
            if (error) return reject(error);
            resolve(body.d === undefined ? body : body.d);
        });
    });
    return apiCall;
}

var APICallError = require('./error/APICallError'),
    TagDidNotRespondError = require('./error/TagDidNotRespondError');

/** Generic error calling cloud API. */
WirelessTagPlatform.APICallError = APICallError;
/** Error calling cloud API because tag needed to but did not respond. */
WirelessTagPlatform.TagDidNotRespondError = TagDidNotRespondError;
/** Error calling cloud API because the same command was sent again before a response to the first. */
WirelessTagPlatform.DuplicateEthCmdError = require('./error/DuplicateEthCmdError');
/** Error calling cloud API because the tag manager is offline. */
WirelessTagPlatform.TagManagerOfflineError = require('./error/TagManagerOfflineError');
/** Error calling cloud API because the tag manager needed to respond but timed out */
WirelessTagPlatform.TagManagerTimedOutError = require('./error/TagManagerTimedOutError');
/** Error calling cloud API because logged in user is not authorized. */
WirelessTagPlatform.UnauthorizedAccessError = require('./error/UnauthorizedAccessError');
/** Error calling cloud API because the requested operation is not valid */
WirelessTagPlatform.InvalidOperationError = require('./error/InvalidOperationError');
/** Thrown if the attempted cloud API call is not supported for the object for which it was made. */
WirelessTagPlatform.OperationUnsupportedError = require('./error/OperationUnsupportedError');
/**
 * The cloud API call failed to complete. Typically this means the API
 * call itself succeeded, but the object's attributes failed to update
 * to reflect the state change.
 */
WirelessTagPlatform.OperationIncompleteError = require('./error/OperationIncompleteError');
/**
 * Thrown if trying to complete or retry a previously incomplete or
 * failed operation fails.
 */
WirelessTagPlatform.RetryUnsuccessfulError = require('./error/RetryUnsuccessfulError');

function checkAPIerror(error, response, uri, reqBody, body) {
    if (error) return error;
    if (! response) return new Error("undefined response for URI " + uri);
    if (response.statusCode !== 200) {
        let apiCallProps = { statusCode: response.statusCode,
                             requestBody: reqBody,
                             url: uri };
        let APIError = APICallError;
        let message = http.STATUS_CODES[response.statusCode];
        if (body) {
            if (body.ExceptionType) {
                let typeMatch = body.ExceptionType.match(/^\w+\.\w+\+(\w+)$/);
                if (typeMatch === null) {
                    typeMatch = body.ExceptionType.match(/^\w+\.(\w+)$/);
                }
                if (typeMatch === null) {
                    message = body.ExceptionType;
                } else {
                    let errorType = typeMatch[1].replace("Exception", "Error");
                    if (WirelessTagPlatform[errorType]) {
                        APIError = WirelessTagPlatform[errorType];
                    } else {
                        message = body.ExceptionType;
                    }
                }
            }
            if (body.Message) message += ": " + body.Message;
        }
        return new APIError(message, apiCallProps);
    }
    return null;
}