Source: lib/tag.js

"use strict";

/** module */
module.exports = WirelessTag;

var request = require('request'),
    util = require('util'),
    EventEmitter = require('events'),
    u = require('./util'),
    OperationIncompleteError = require('./error/OperationIncompleteError'),
    RetryUnsuccessfulError = require('./error/RetryUnsuccessfulError'),
    WirelessTagSensor = require('./sensor'),
    WirelessTagPlatform = require('./platform');

const roTagProps = ["uuid","slaveId","tagType","alive","rev"];
const rwTagProps = ["name",
                    ["updateInterval","postBackInterval"],
                    ["lowPowerMode","rssiMode"],
                    ];

// the following are in milliseconds
const RETRY_UPDATE_DELAY = 5000;         // time before retrying update
const MIN_UPDATE_LOOP_WAIT = 3000;       // minimum wait time between loops
const MAX_UPDATE_LOOP_WAIT = 30 * 60000; // maximum wait time between loops
const CLOUD_DATA_DELAY = 55 * 1000;   // delay with which data shows up in cloud

const DEFAULT_RETRY_OPTIONS = {
    minTimeout: MIN_UPDATE_LOOP_WAIT,
    maxTimeout: MAX_UPDATE_LOOP_WAIT,
    retries: 4
};

/**
 * The cloud instance of a Wireless Tag. One {@link WirelessTagManager}
 * can manage multiple Wireless Tags. A user will not normally need to
 * create instances directly; instead they are found, and created by
 * {@link WirelessTagManager#discoverTags}.
 *
 * @param {WirelessTagManager} tagManager - the tag manager instance that
 *                             discovered this tag
 * @param {Object} tagData - the object comprising the tag's status
 *                           properties, as returned by the API endpoint.
 *
 * @class
 * @alias WirelessTag
 */
function WirelessTag(tagManager, tagData) {
    EventEmitter.call(this);
    this.wirelessTagManager = tagManager;
    this.errorHandler =
        tagManager ? tagManager.errorHandler : u.defaultHandler;
    this.callAPI = WirelessTagPlatform.callAPI;
    u.setObjProperties(this, roTagProps, rwTagProps);
    this.data = tagData || {};
    this.on('_data', this.bounceUpdateLoop.bind(this));
}
util.inherits(WirelessTag, EventEmitter);

WirelessTag.prototype.sensorCapabilities = function() {
    var capabilities = [];
    for (let propName in this) {
        if (propName.startsWith("has") && propName.endsWith("Sensor")) {
            let propValue = this[propName];
            if ((('function' === typeof propValue) && propValue.call(this))
                || (('function' !== typeof propValue) && propValue)) {
                capabilities.push(propName.
                                  replace(/has(\w+)Sensor/,'$1').
                                  toLowerCase());
            }
        }
    }
    return capabilities;
};

// list of positive capabilities (canXXX()) and facts (isYYY(), hasZZZ())
WirelessTag.prototype.hardwareFacts = function() {
    let facts = [];
    for (let cap in this) {
        if (cap.startsWith("can")
            || cap.startsWith("is")
            || (cap.startsWith("has") && !cap.endsWith("Sensor"))) {
            let propValue = this[cap];
            if ((('function' === typeof propValue) && propValue.call(this))
                || (('function' !== typeof propValue) && propValue)) {
                facts.push(cap);
            }
        }
    }
    return facts;
};

WirelessTag.prototype.eachSensor = function(action) {
    if (action === undefined) action = (s) => { return s };
    let capabilities = this.sensorCapabilities();
    let retVals = [];
    for (let cap of capabilities) {
        let sensor = this[cap + "Sensor"];
        if (sensor) retVals.push(action(sensor));
    }
    return retVals;
};

WirelessTag.prototype.version = function() {
    switch (this.data.version1) {
    case 2:
	switch (this.rev) {
        case 14: return "2.1";
	case 15: return "2.2";
	case 31: return "2.3";
        case 32: return "2.4";
	default: return "2.0";
        }
        break; // technically not needed since every preceding case returns
    case 3:
	return "3.0";
    case 4:
	return "4.0";
    }
    // future proof with a default
    if (this.data.version1 >= 5) return this.data.version1.toFixed(1);
    // at this point version1 == 1 or undefined (which we'll take as 1 too)
    if (this.tagType === 12) {
        switch (this.rev) {
        case  0: return "1.1";
        case  1: return "1.2";
        case 11: return "1.3";
        case 12: return "1.4";
        case 13: return "1.5";
        }
    }
    return this.data.version1 ? this.data.version1.toFixed(1) : "1.0";
};

WirelessTag.prototype.hasMotionSensor = function() {
    return (this.tagType === 12 || this.tagType === 13 || this.tagType === 21);
};
WirelessTag.prototype.hasLightSensor = function() {
    return (this.tagType === 26);
};
WirelessTag.prototype.hasMoistureSensor = function() {
    return (this.tagType === 32 || this.tagType === 33);
};
WirelessTag.prototype.hasWaterSensor = function() {
    return (this.tagType === 32 || this.tagType === 33);
};
WirelessTag.prototype.hasReedSensor = function() {
    return (this.tagType === 52 || this.tagType === 53);
};
WirelessTag.prototype.hasPIRSensor = function() {
    return (this.tagType === 72);
};
WirelessTag.prototype.hasEventSensor = function() {
    return (this.hasMotionSensor()
            || this.hasLightSensor()
            || this.hasReedSensor()
            || this.hasPIRSensor());
};
WirelessTag.prototype.hasHumiditySensor = function() {
    return this.isHTU();
};
WirelessTag.prototype.hasTempSensor = function() {
    return !(this.tagType === 82 || this.tagType === 92);
};
WirelessTag.prototype.hasCurrentSensor = function() {
    return this.tagType === 42;
};
WirelessTag.prototype.hasOutOfRangeSensor = function() {
    return this.isPhysicalTag();
};
WirelessTag.prototype.hasBatterySensor = function() {
    return this.isPhysicalTag();
};
WirelessTag.prototype.hasSignalSensor = function() {
    return this.isPhysicalTag();
};
WirelessTag.prototype.hasAccelerometer = function() {
    return this.hasMotionSensor() && ((this.rev & 0x0F) == 0x0A);
};
WirelessTag.prototype.canMotionTimeout = function() {
    return this.hasMotionSensor()
        && this.rev >= 14
        && (this.tagType !== 12 || this.rev !== 15);
};
WirelessTag.prototype.canBeep = function() {
    return this.tagType === 13
        || this.tagType === 12
        || this.tagType === 21
        || this.tagType === 26;
};
WirelessTag.prototype.canPlayback = function() {
    return (this.tagType === 21);
};
WirelessTag.prototype.isHTU = function() {
    return this.tagType === 13 || this.tagType === 21 // motion && type != 12
        || this.tagType === 52                        // reed && type != 53
        || this.tagType === 26                        // ambient light
        || this.tagType === 72                        // PIR
        || this.isKumostat()
    ;
};
WirelessTag.prototype.isPhysicalTag = function() {
    return ! (this.isKumostat()
              || this.isNest()
              || this.isWeMo()
              || this.isCamera());
};
WirelessTag.prototype.isKumostat = function() {
    return (this.tagType === 62);
};
WirelessTag.prototype.isNest = function() {
    return (this.data.thermostat !== null
            && this.data.thermostat.nest_id !== null);
};
WirelessTag.prototype.isWeMo = function() {
    return (this.tagType === 82);
};
WirelessTag.prototype.isWeMoLED = function() {
    return (this.isWeMo() && (this.data.cap > 0));
};
WirelessTag.prototype.isCamera = function() {
    return (this.tagType === 92);
};

WirelessTag.prototype.lastUpdated = function() {
    return new Date(u.FILETIMEtoDate(this.data.lastComm));
};

WirelessTag.prototype.discoverSensors = function(tag) {
    if (! tag) tag = this;
    let capabilities = tag.sensorCapabilities();
    let proms = [];
    for (let cap of capabilities) {
        let propName = cap + "Sensor";
        let sensor = tag[propName];
        if (sensor) {
            proms.push(Promise.resolve(sensor));
        } else {
            let retValue = createSensor(tag, cap);
            sensor = retValue[0];
            Object.defineProperty(tag, propName, {
                enumerable: true,
                value: sensor
            });
            proms.push(retValue[1]);
        }
    }
    return Promise.all(proms);
};

function createSensor(tag, sensorType) {
    let sensor = new WirelessTagSensor(tag, sensorType);
    let req = WirelessTagSensor.loadMonitoringConfig(sensor).then(
        (config) => {
            try {
                sensor.monitoringConfig(config);
                return sensor;
            }
            finally {
                tag.emit('discover', sensor);
            }
        },
        (error) => {
            tag.emit('discover', sensor);
            if (error instanceof Error) {
                tag.errorHandler()(error);
            } else {
                console.warn(error);
            }
        }
    );
    return [sensor, req];
}

WirelessTag.prototype.update = function(tag, callback)  {
    if ((tag === undefined) || ('function' === typeof tag)) {
        callback = tag;
        tag = this;
    }
    var req = tag.callAPI(
        '/ethClient.asmx/GetTagForSlaveId',
        { slaveid: tag.slaveId },
        callback);
    return req.then(
        (result) => {
            tag.data = result;
            if (callback) callback(null, { object: this });
            return tag;
        });
};

WirelessTag.prototype.retryUpdateUntil = function(success, options) {
    let successFunc = (tag, attempt) => {
        if (success(tag, attempt)) return true;
        // the success() function did not return truthy, convert to throw
        throw new RetryUnsuccessfulError(
            "retrying tag update remains unsuccessful",
            tag,
            "update",
            attempt);
    };
    options = Object.assign(DEFAULT_RETRY_OPTIONS, options);
    return u.retryUntil(this.update.bind(this), successFunc, options);
};

WirelessTag.prototype.updateUntil = function(success, retryOptions) {
    return this.update().then((tag) => {
        if (success(tag)) return tag;
        // success function didn't return truthy, convert to throw
        throw new OperationIncompleteError("update of tag deemed unsuccessful",
                                           tag,
                                           "update");
    }).catch((e) => {
        if (e instanceof OperationIncompleteError) {
            return this.retryUpdateUntil(success, retryOptions);
        }
        throw e;
    });
};

WirelessTag.prototype.liveUpdate = function(callback)  {
    var req = this.callAPI(
        '/ethClient.asmx/RequestImmediatePostback',
        { id: this.slaveId },
        callback);
    return req.then(
        (result) => {
            this.data = result;
            if (callback) callback(null, { object: this });
            return this;
        });
};

WirelessTag.prototype.startUpdateLoop = function(minWait) {
    if (minWait === undefined) {
        minWait = MIN_UPDATE_LOOP_WAIT;
    } else if (minWait > MAX_UPDATE_LOOP_WAIT) {
        minWait = MAX_UPDATE_LOOP_WAIT;
    }
    if (this._updateTimer) return this._updateTimer;
    this._updateTimer = true; // placeholder to avoid race conditions
    let action = () => {
        this._updateTimer = true;  // timer is done but action not yet
        this.update().then(() => {
            // reset wait time upon success
            minWait = undefined;
        }).catch((err) => {
            console.error(err.stack ? err.stack : err);
            minWait *= minWait;
        }).then(() => {
            // with the preceding catch() this is in essence a finally()
            if (this._updateTimer) {
                this._updateTimer = null;
                this.startUpdateLoop(minWait);
            }
            // otherwise we have been cancelled while running the update
        });
    };
    let timeNextExpected =
        this.lastUpdated().getTime()
        + (this.updateInterval * 1000)
        + CLOUD_DATA_DELAY;
    let remainingTime = timeNextExpected - Date.now();
    if (remainingTime < minWait) {
        remainingTime = minWait;
    }
    // ensure that updates weren't cancelled since we entered here
    if (this._updateTimer === true) {
        this._updateTimer = setTimeout(action, remainingTime);
    }
    return this._updateTimer;
};

WirelessTag.prototype.stopUpdateLoop = function() {
    let timer = this._updateTimer;
    this._updateTimer = null;   // avoid race conditions
    if (timer && timer !== true) {
        clearTimeout(timer);
    }
};

WirelessTag.prototype.bounceUpdateLoop = function(tag) {
    if (! tag) tag = this;
    let timer = tag._updateTimer;
    if (timer && timer !== true) {
        console.warn("## bouncing update timer loop for tag", tag.slaveId);
        tag.stopUpdateLoop();
        tag.startUpdateLoop();
    }
};

WirelessTag.prototype.toString = function() {
    let propsObj = {
        manager : {
            name: this.wirelessTagManager.name,
            mac: this.wirelessTagManager.mac
        }
    };
    // all data properties except private ones
    for (let propName of Object.getOwnPropertyNames(this)) {
        if (! (propName.endsWith('Sensor')
               || propName.startsWith('_')
               || (propName === 'wirelessTagManager')
               || ('function' === typeof this[propName]))) {
            propsObj[propName] = this[propName];
        }
    }
    // remove domain, data, and other undesired properties picked up above
    delete propsObj.data;
    delete propsObj.domain;
    // when tag was last updated
    propsObj.lastUpdated = this.lastUpdated().toString();
    // version
    propsObj.version = this.version();
    // list of positive capabilities (canXXX()) and facts (isYYY(), hasZZZ())
    propsObj.facts = this.hardwareFacts();
    // list of sensor capabilities
    propsObj.sensors = this.sensorCapabilities();
    return JSON.stringify(propsObj);
};

WirelessTag.prototype.setUpdateInterval = function(value, callback) {
    if ('function' === typeof value) {
        callback = value;
        value = undefined;
    }
    if (value === undefined) value = this.updateInterval;
    if (('number' !== typeof value) || (value <= 0)) {
        throw new TypeError("invalid update interval for tag " + this.name);
    }
    var req = this.callAPI(
        '/ethClient.asmx/SetPostbackIntervalFor',
        { id: this.slaveId, sec: value },
        callback);
    return req.then(
        (result) => {
            this.data = result;
            if (this.updateInterval != value) {
                // the API call itself succeeded, so this should resolve
                // itself if we retry updating after a short delay
                return this.retryUpdateUntil((tag) => {
                    return tag.updateInterval == value;
                });
            }
            if (callback) callback(null, { object: this });
            return this;
        });
};

WirelessTag.prototype.setLowPowerMode = function(value, callback) {
    if ('function' === typeof value) {
        callback = value;
        value = undefined;
    }
    if (value === undefined) {
        value = this.lowPowerMode;
    } else if ('boolean' === typeof value) {
        if (this.lowPowerMode === value) {
            if (callback) callback(null, { Object: this });
            return Promise.resolve(this);
        }
    } else {
        throw new TypeError("invalid power mode value for tag " + this.name);
    }

    var req = this.callAPI(
        '/ethClient.asmx/SetLowPowerWOR',
        { id: this.slaveId, enable: value },
        callback);
    return req.then(
        (result) => {
            this.data = result;
            if (this.lowPowerMode !== value) {
                // the API call itself succeeded, so this should resolve
                // itself if we retry updating after a short delay
                return this.retryUpdateUntil((tag) => {
                    return tag.lowPowerMode === value;
                });
            }
            if (callback) callback(null, { object: this });
            return this;
        });
};