Source: lib/sensor.js

"use strict";

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

var util = require('util'),
    EventEmitter = require('events'),
    deepEqual = require('deep-equal'),
    u = require('./util'),
    xforms = require('./xforms'),
    MonitoringConfig = require('./sensorconfig'),
    OperationUnsupportedError = require('./error/OperationUnsupportedError'),
    RetryUnsuccessfulError = require('./error/RetryUnsuccessfulError');

const tagEventStates = {
    "0": "Disarmed",
    "1": "Armed",
    "2": "Moved",
    "3": "Opened",
    "4": "Closed",
    "5": "Event Detected",
    "6": "Timed Out",
    "7": "Stabilizing",
    "8": "Carried Away",
    "9": "In Free Fall"
};
const tempEventStates = {
    "0": "Not Monitoring",
    "1": "Normal",
    "2": "Too Hot",
    "3": "Too Cold"
};
const humidityEventStates = {
    "0": "N.A.",
    "1": "Not Monitoring",
    "2": "Normal",
    "3": "Too Dry",
    "4": "Too Humid"
};
const moistureEventStates = {
    "0": "N.A.",
    "1": "Not Monitoring",
    "2": "Normal",
    "3": "Too Dry",
    "4": "Too Wet"
};
const lightEventStates = {
    "0": "N.A.",
    "1": "Not Monitoring",
    "2": "Normal",
    "3": "Too Dark",
    "4": "Too Bright"
};
const outOfRangeGracePeriods = {
    // in seconds
    "0": 0,
    "1": 120,
    "2": 240,
    "3": 360,
    "4": 480,
    "5": 600,
    "7": 840,
    "10": 1200,
    "15": 1800
};

/* eslint-disable no-invalid-this */
const sensorPropertiesMap = {
    'motion': {
    },
    'event': {
        reading: ["eventState", xforms.mapFunction(tagEventStates)],
        eventState: ["eventState", xforms.mapFunction(tagEventStates)],
        eventStateValues: [undefined, xforms.valuesInMapFunction(tagEventStates)]
    },
    'light': {
        reading: ["lux", function(x) { return u.round(x, 2) }],
        eventState: ["lightEventState", xforms.mapFunction(lightEventStates)],
        eventStateValues: [undefined, xforms.valuesInMapFunction(lightEventStates)]
    },
    'temp': {
        reading: ["temperature",
                   function(x) {
                       let mconf = this.monitoringConfig();
                       if (mconf && mconf.unit === "degF") {
                           x = xforms.degCtoF(x);
                       }
                       return this.wirelessTag.canHighPrecTemp() ?
                           u.round(x, 2) : u.round(x, 1);
                  }],
        eventState: ["tempEventState", xforms.mapFunction(tempEventStates)],
        eventStateValues: [undefined, xforms.valuesInMapFunction(tempEventStates)],
        probeType: [
            "ds18",
            function(x) {
                if (x) return "DS18B20";
                let tag = this.wirelessTag;
                // ds18 is false means external probe is
                // (i) a thermocouple, not a DS18B20
                if (tag.hasSecondaryTempSensor()) return "Thermocouple";
                // (ii) required but not connected
                if (tag.isExternalTempProbe()) return undefined;
                // (iii) optional but not connected, or not supported
                return "Internal";
            }],
        probeDisconnected: [
            "ds18",
            function(x) {
                if (x) return false;
                // ds18 is false means external probe is
                // (i) required but not connected,
                if (this.wirelessTag.isExternalTempProbe()) return true;
                // (ii) supported but not detectable as connected or not, or
                // (iii) not supported,
                return undefined;
            }]
    },
    'secondarytemp': {
        reading: ["cap",   // yes, really
                   function(x) {
                       let mconf = this.monitoringConfig();
                       if (mconf && mconf.unit === "degF") {
                           x = xforms.degCtoF(x);
                       }
                       return this.wirelessTag.canHighPrecTemp() ?
                           u.round(x, 2) : u.round(x, 1);
                  }],
        probeType: [undefined, function() { return "Internal" }]
    },
    'humidity': {
        reading: ["cap", xforms.noop],
        eventState: ["capEventState", xforms.mapFunction(humidityEventStates)],
        eventStateValues: [undefined, xforms.valuesInMapFunction(humidityEventStates)],
        probeType: [undefined, function() { return "Internal" }]
    },
    'moisture': {
        reading: ["cap", xforms.noop],
        eventState: ["capEventState", xforms.mapFunction(moistureEventStates)],
        eventStateValues: [undefined, xforms.valuesInMapFunction(moistureEventStates)],
        probeType: [
            undefined,
            function() {
                return this.wirelessTag.isOutdoorTag() ? "BLDXXXX" : "Internal";
            }]
    },
    'water': {
        reading: ["shorted", xforms.noop],
        eventState: ["shorted",
                     function(x) { return x ? "Water Detected" : "Normal" }],
        eventStateValues: [undefined,
                           function() { return ["Normal", "Water Detected"] }],
        probeType: [undefined, function() { return "Internal" }]
    },
    'battery': {
        reading: ["batteryVolt",
                  function(x) { return u.round(x, 2) }],
        eventState: ["enLBN",
                     function(x) {
                         if (! x) return "Not Monitoring";
                         if (this.reading >= this.data.LBTh) return "Normal";
                         return "Battery Low";
                     }],
        eventStateValues: [
            undefined,
            function() {
                return ["Not Monitoring", "Normal", "Battery Low"];
            }
        ]
    },
    'outofrange': {
        reading: ["OutOfRange", xforms.noop],
        eventState: ["OutOfRange",
                     function(x) {
                         return x ? "Out Of Range" : "Normal";
                     }],
        eventStateValues: [undefined,
                           function() { return ["Normal", "Out Of Range"] }],
        gracePeriod: ["oorGrace",                 // we map this to seconds
                      xforms.mapFunction(outOfRangeGracePeriods),
                      xforms.revMapFunction(outOfRangeGracePeriods)]
    },
    'signal': {
        reading: ["signaldBm", xforms.noop]
    }
};
/* eslint-enable no-invalid-this */

const sensorApiURIs = {
    'motion': {
    },
    'event': {
        arm: "/ethClient.asmx/Arm",
        armData: { door_mode_set_closed: true },
        disarm: "/ethClient.asmx/Disarm"
    },
    'light': {
        arm: "/ethClient.asmx/ArmLightSensor",
        disarm: "/ethClient.asmx/DisarmLightSensor"
    },
    'temp': {
        arm: "/ethClient.asmx/ArmTempSensor",
        disarm: "/ethClient.asmx/DisarmTempSensor"
    },
    'secondarytemp': {
    },
    'humidity': {
        arm: "/ethClient.asmx/ArmCapSensor",
        disarm: "/ethClient.asmx/DisarmCapSensor"
    },
    'moisture': {
        arm: "/ethClient.asmx/ArmCapSensor",
        disarm: "/ethClient.asmx/DisarmCapSensor"
    },
    'water': {
    },
    'outofrange': {
        load: "/ethClient.asmx/LoadOutOfRangeConfig",
        save: "/ethClient.asmx/SaveOutOfRangeConfig2"
    },
    'battery': {
        load: "/ethClient.asmx/LoadLowBatteryConfig",
        save: "/ethClient.asmx/SaveLowBatteryConfig2"
    }
};

/**
 * Represents a sensor of a {@link WirelessTag}. Physical tags have
 * multiple sensors (e.g., tenperature, humidity, light, motion, etc),
 * as well as dynamic status (such as out of range) and numeric (such
 * as signal strength and battery voltage) properties. We abstract all
 * of these out to sensors, which allows us to treat temperature, out
 * of range status, and battery voltage as conceptually the
 * same. Sensors do not all have the same capabilities (e.g., some can
 * be armed for monitoring, others, such as signal, cannot). However,
 * all sensors have a type (humidity, motion, light, signal, etc).
 *
 * A user will not normally need to create instances directly; instead
 * they are found, and created by {@link WirelessTag#discoverSensors}.
 *
 * @param {WirelessTag} tag - the tag instance that has this sensor
 * @param {string} sensorType - the type of the sensor
 *
 * @class
 * @alias WirelessTagSensor
 *
 * @property {number|string|boolean} reading - The current reading of the
 *             sensor. The type of the value depends on the type of sensor.
 *             Some sensors (light, temperature, humidity, moisture, battery,
 *             signal) have numeric readings, some (outofrange, water)
 *             have boolean, and some (event) have string values.
 *             For some sensors (currently only motion) the reading is
 *             undefined because the Wireless Tag platform does not provide
 *             access to a regularly updated value.
 * @property {string} eventState - The current event state of the sensor.
 *             Unarmed sensors will be in state `Not Monitoring` or `Disarmed`,
 *             whereas armed sensors can be in `Normal`, `Too Hot`, `Too Dry`,
 *             and other states, depending on the type of sensor. Not every
 *             sensor has an event state; for example, the signal sensor does
 *             not. (Nor does the motion sensor, because it would be redundant
 *             with the event sensor.)
 * @property {string[]} eventStateValues - the possible values for `eventState`
 * @property {string} probeType - only for temperature (`temp`,
 *             `secondarytemp`), `humidity`, and `moisture` sensors, the type
 *             of measurement probe or mechanism. Typically `Internal`, but
 *             can be `DS18B20` and `Thermocouple` for temperature, and
 *             `BLDXXXX` for moisture/humidity sensors, respectively, of tags
 *             capable of using an alternative probe.
 * @property {boolean} probeDisconnected - `true` if an external probe is
 *             detected as disconnected, `false` if it is detected as connected,
 *             and `undefined` (or not present as a property) if the
 *             connection status can't be detected (or if the tag doesn't
 *             support external probes).
 * @property {number} gracePeriod - only for `outofrange` sensors, the grace
 *             period in seconds after which a tag will go into `Out Of Range`
 *             state after losing contact with the tag manager.
 */
function WirelessTagSensor(tag, sensorType) {
    EventEmitter.call(this);
    /** @member {WirelessTag} */
    this.wirelessTag = tag;
    let platform = tag.wirelessTagManager ?
        tag.wirelessTagManager.wirelessTagPlatform : undefined;
    /** @member {function} - see {@link WirelessTagPlatform.callAPI} */
    this.callAPI = tag.callAPI ||
        (tag.wirelessTagManager ? tag.wirelessTagManager.callAPI : undefined) ||
        (platform ? platform.callAPI : undefined);
    /**
     * @name sensorType
     * @type {string}
     * @memberof WirelessTagSensor#
     */
    Object.defineProperty(this, "sensorType", {
        enumerable: true,
        value: sensorType
    });
    this.errorHandler = tag.errorHandler || u.defaultHandler;
    Object.defineProperty(this, "data", {
        enumerable: true,
        get: () => this.wirelessTag.data
    });
    u.defineLinkedPropertiesFromMap(this, sensorPropertiesMap, sensorType);
    if (sensorType === 'event' || sensorType === 'motion') {
        // motion and event sensors when armed can be reset to non-triggered
        this.reset = resetMotion;
    }
}
util.inherits(WirelessTagSensor, EventEmitter);

/**
 * String representation of the sensor object and its data. Includes a
 * reference to the tag (as `name`, `uuid`, and `slaveId`), properties, and
 * the sensor's monitoring configuration object.
 *
 * @returns {string}
 */
WirelessTagSensor.prototype.toString = function() {
    let propsObj = {
        tag: {
            name: this.wirelessTag.name,
            uuid: this.wirelessTag.uuid,
            slaveId: this.wirelessTag.slaveId
        }
    };
    // all data properties except private ones and the data dict
    for (let propName of Object.getOwnPropertyNames(this)) {
        if (! (propName.startsWith('_')
               || (propName === 'wirelessTag')
               || (propName === 'domain')
               || (propName === 'data')
               || ('function' === typeof this[propName]))) {
            propsObj[propName] = this[propName];
        }
    }
    // monitoring configuration
    propsObj.monitoringConfig = this.monitoringConfig().asJSON();
    return JSON.stringify(propsObj);
};

/**
 * Whether the sensor is armed. An armed sensor will generate notifications
 * upon certain thresholds being exceeded.
 *
 * @returns {boolean} `Undefined` if the sensor doesn't define an armed state,
 *            `true` if it is armed, and `false` otherwise.
 */
WirelessTagSensor.prototype.isArmed = function() {
    if (this.eventState === undefined) return undefined;
    return ["Disarmed", "Not Monitoring", "N.A."].indexOf(this.eventState) < 0;
};

/**
 * Whether the sensor can be armed. Most sensors can be armed but some (such
 * as `signal`) cannot.
 */
WirelessTagSensor.prototype.canArm = function() {
    let apiSpec = sensorApiURIs[this.sensorType];
    return apiSpec && apiSpec.arm;
};

/**
 * Whether the sensor can be disarmed. Most sensors that are armed can be
 * disarmed but some (such as `water`) cannot.
 */
WirelessTagSensor.prototype.canDisarm = function() {
    let apiSpec = sensorApiURIs[this.sensorType];
    return apiSpec && apiSpec.disarm;
};

/**
 * Arms this sensor.
 *
 * @param {module:wirelesstags~apiCallback} [callback]
 * @returns {Promise} Resolves to the sensor when arming completes. Will
 *          [retry updating]{@link WirelessTag#retryUpdateUntil}
 *          until the tag's data reflect the armed state. Rejects
 *          with an [OperationIncompleteError]{@link WirelessTagPlatform.OperationIncompleteError}
 *          if this is still not the case after the
 *          [default number of retries]{@link module:lib/tag~DEFAULT_RETRY_OPTIONS}.
 * @throws {WirelessTagPlatform.OperationUnsupportedError} if the sensor
 *         does not support arming
 */
WirelessTagSensor.prototype.arm = function(callback) {
    if (this.isArmed()) return Promise.resolve(this);
    if (! this.canArm()) {
        let e = new OperationUnsupportedError(this.sensorType
                                              + " does not support arming",
                                              this,
                                              "arm");
        if (callback) callback(e);
        return Promise.reject(e);
    }
    return changeArmedStatus(this, callback);
};

/**
 * Disarms this sensor.
 *
 * @param {module:wirelesstags~apiCallback} [callback]
 * @returns {Promise} Resolves to the sensor when disarming completes. Will
 *          [retry updating]{@link WirelessTag#retryUpdateUntil}
 *          until the tag's data reflect the armed state. Rejects
 *          with an [OperationIncompleteError]{@link WirelessTagPlatform.OperationIncompleteError}
 *          if this is still not the case after the
 *          [default number of retries]{@link module:lib/tag~DEFAULT_RETRY_OPTIONS}.
 * @throws {WirelessTagPlatform.OperationUnsupportedError} if the sensor
 *         does not support arming
 */
WirelessTagSensor.prototype.disarm = function(callback) {
    if (! this.isArmed()) return Promise.resolve(this);
    if (! this.canDisarm()) {
        let e = new OperationUnsupportedError(this.sensorType
                                              + " does not support disarming",
                                              this,
                                              "disarm");
        if (callback) callback(e);
        return Promise.reject(e);
    }
    return changeArmedStatus(this, callback);
};

/**
 * Obtains (or sets) the [monitoring configuration]{@link MonitoringConfig}
 * of the sensor.
 *
 * The monitoring configuration for some sensors is not only stricly about
 * parameters controlling behavior and event notification when armed. For
 * example, for temperature and humidity sensors it includes the unit (°C
 * versus °F, %humidity versus dew point temperature).
 *
 * @param {MonitoringConfig} [newConfig] - on set, the new monitoring
 *          configuration object
 * @returns {MonitoringConfig} The monitoring configuration object active
 *          for this sensor. For sensor objects that haven't been fully
 *          initialized (see {@link WirelessTag#createSensor}), the returned
 *          object will need to be [updated from the cloud]{@link MonitoringConfig#update}
 *          first before its properties reflect the currently active values.
 */
WirelessTagSensor.prototype.monitoringConfig = function(newConfig) {
    if (newConfig) {
        let oldConfData = this._config ? this._config.data : {};
        this._config = newConfig;
        if (! deepEqual(oldConfData, newConfig.data)) {
            this.emit('config', this, newConfig, 'set');
        }
    } else if (! this._config) {
        this._config = MonitoringConfig.create(this);
    }
    return this._config;
};

function changeArmedStatus(sensor, callback) {
    var isArmed = sensor.isArmed();
    var action = isArmed ? "disarm" : "arm";
    var apiSpec = sensorApiURIs[sensor.sensorType];
    var uri = apiSpec[action];
    if (! uri) throw new OperationUnsupportedError(
        "undefined API for " + action + "ing "
            + sensor.sensorType + " sensor of " + sensor.wirelessTag.name,
        sensor,
        action);
    var data = apiSpec[action + "Data"] || {};
    data.id = sensor.wirelessTag.slaveId;
    var req = sensor.callAPI(uri, data, callback);
    return req.then((result) => {
        sensor.wirelessTag.data = result;
        if (isArmed !== undefined && isArmed === sensor.isArmed()) {
            // the API call itself succeeded, so this should resolve
            // itself if we retry updating after some delay
            return sensor.wirelessTag.retryUpdateUntil((tag, n) => {
                let s = tag[sensor.sensorType + "Sensor"];
                if (isArmed !== s.isArmed()) return true;
                throw new RetryUnsuccessfulError(
                    "Event state for " + s.sensorType
                        + " of " + tag.name + " failed to change to "
                        + action + "ed after " + n + " update attempts",
                    s,
                    action,
                    n);
            }).then((tag) => tag[sensor.sensorType + "Sensor"]);
        }
        if (callback) callback(null, { object: sensor });
        return sensor;
    });
}

/**
 * Resets the motion event status of this sensor. This method is only
 * available for 'event' sensors (which tags with motion, light, PIR,
 * and Reed sensors have).
 *
 * @param {module:wirelesstags~apiCallback} [callback]
 * @returns {Promise} Resolves when the reset completes.
 *
 * @method reset
 * @memberof WirelessTagSensor#
 */
/* eslint-disable no-invalid-this */
function resetMotion(callback) {
    if (! this.isArmed()) return Promise.resolve(this);
    let req = this.callAPI('/ethClient.asmx/ResetTag',
                           { id: this.wirelessTag.slaveId },
                           callback);
    return req.then((result) => {
        this.wirelessTag.data = result;
        if (callback) callback(null, { object: this });
        return this;
    });
}
/* eslint-enable no-invalid-this */