Source: lib/sensor.js

"use strict";

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

var request = require('request'),
    util = require('util'),
    EventEmitter = require('events'),
    deepEqual = require('deep-equal'),
    u = require('./util'),
    OperationUnsupportedError = require('./error/OperationUnsupportedError'),
    RetryUnsuccessfulError = require('./error/RetryUnsuccessfulError'),
    WirelessTagPlatform = require('./platform');

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 motionResponsivenessStates = {
    1: "Highest",
    2: "Medium high",
    3: "Medium",
    4: "Medium low",
    5: "Lowest"
};
const capResponsivenessStates = {
     4: "Highest",
     8: "Medium high",
    16: "Medium",
    32: "Medium low",
    48: "Lowest"
};
const outOfRangeGracePeriods = {
    // in seconds
    0: 0,
    1: 120,
    2: 240,
    3: 360,
    4: 480,
    5: 600,
    6: 840,
    7: 1200,
    8: 1800,
};

const xforms = {
    noop: function(x) { return x },
    mapFunction: function(map) { return function(x) { return map[x] } },
    revMapFunction: function(map) {
        return function(x) {
            for (let key in map) {
                if (map[key] == x) return key;
            }
            throw new RangeError(x + " is not a value in the map");
        };
    },
    valuesInMapFunction: function(map) {
        return function() {
            return Object.keys(map).map((k) => { return map[k] });
        };
    },
    mapObjectFunction: function(map, prop) {
        return function() {
            // return cached object if there is one
            if (prop && this['_'+prop]) return this['_'+prop];
            // otherwise create from scratch
            let value = {};
            let createMappedProp = (obj, key) => {
                Object.defineProperty(obj, key, {
                    enumerable: true,
                    configurable: true,
                    get: () => { return this.data[map[key]]; },
                    set: (x) => {
                        this.data[map[key]] = x;
                        if ('function' === typeof this.markModified) {
                            this.markModified(map[key]);
                            this.markModified((prop ? prop + '.' : '') + key);
                        }
                    }
                });
            };
            for (let key in map) {
                createMappedProp(value, key);
            }
            // prevent other properties from being added accidentally
            Object.seal(value);
            // cache for the future (as non-enumerable and read-only) and return
            if (prop) Object.defineProperty(this, '_'+prop, { value: value });
            return value;
        };
    },
    delegatingFunction: function(delegateTo, propName) {
        let xformFunc = function(value) {
            if (value === undefined) {
                value = this[propName];
            } else {
                this[propName] = value;
            }
            return value;
        };
        return xformFunc.bind(delegateTo);
    },
    degCtoF: function(x) { return x * 9/5.0 + 32 },
    degFtoC: function(x) { return (x-32) * 5/9.0 },
    rh2dewPoint: function(x) {
        let T = this.wirelessTag.data.temperature; // need native dC temperature
	let b = 17.67, c = 243.5;
	let u = Math.log(x / 100.0) + b * T / (c + T);
	return c * u / (b - u);
    },
};

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) {
                       return this.wirelessTag.isHTU() ?
                           u.round(x,2) : u.round(x,1);
                   }],
        eventState : ["tempEventState", xforms.mapFunction(tempEventStates)],
        eventStateValues : [undefined, xforms.valuesInMapFunction(tempEventStates)],
    },
    'humidity' : {
        reading : ["cap", xforms.noop],
        eventState : ["capEventState", xforms.mapFunction(humidityEventStates)],
        eventStateValues : [undefined, xforms.valuesInMapFunction(humidityEventStates)],
    },
    'moisture' : {
        reading : ["cap", xforms.noop],
        eventState : ["capEventState", xforms.mapFunction(moistureEventStates)],
        eventStateValues : [undefined, xforms.valuesInMapFunction(moistureEventStates)],
    },
    'water' : {
        reading : ["shorted", xforms.noop],
        eventState : ["shorted",
                      function(x) { return x ? "Water Detected" : "Normal" }],
        eventStateValues: [undefined,
                           function(s) { return ["Normal","Water Detected"] }],
    },
    'current' : {
        reading: ["ampData", xforms.noop],
        eventState: ["ampData", function(x) { return x.eventState }],
    },
    '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(s) { return ["Not Monitoring",
                                                 "Normal",
                                                 "Battery Low"] }],
    },
    'outofrange': {
        reading: ["OutOfRange", xforms.noop ],
        eventState: ["OutOfRange",
                     function(x) {
                         return x ? "Out Of Range" : "Normal";
                     }],
        eventStateValues: [undefined,
                           function(s) { return ["Normal","Out Of Range"] }],
        gracePeriod: ["oorGrace",                 // we map this to seconds
                      xforms.mapFunction(outOfRangeGracePeriods),
                      xforms.revMapFunction(outOfRangeGracePeriods),],
    },
    'signal': {
        reading: ["signaldBm", xforms.noop ]
    },
};

const objectMaps = {
    notifyMap: {
        email: "email",
        sound: "apnsSound",                       // string
        pausePeriod: "apns_pause",                // minutes
        useEmail: "send_email",                   // boolean
        useTwitter: "send_tweet",                 // boolean
        usePush: "beep_pc",                       // boolean
        useSpeech: "beep_pc_tts",                 // boolean
        noSound: "beep_pc_vibrate",               // boolean
        // not supported for every sensor
        repeatUntilReset: "beep_pc_loop",         // boolean
        // only supported for battery sensor config
        repeatEvery: "notify_every",              // seconds
        // only supported for water sensor config
        onBecomeDry: "notify_open",               // boolean
    },
    notifyOutOfRangeMap: {
        email: "email_oor",                       // sigh!
        sound: "apnsSound",                       // string
        useEmail: "send_email_oor",               // sigh!
        usePush: "beep_pc_oor",                   // sigh!
        useSpeech: "beep_pc_tts_oor",             // sigh!
        noSound: "beep_pc_vibrate_oor",           // sigh!
    },
    thresholdMap: {
        lowValue: "th_low",
        minLowReadings: "th_low_delay",
        highValue: "th_high",
        minHighReadings: "th_high_delay",
        hysteresis: "th_window",
    },
    lightThresholdMap: {
        lowValue: "lux_th_low",                   // sigh!
        minLowReadings: "th_low_delay",
        highValue: "lux_th_high",                 // sigh!
        minHighReadings: "th_high_delay",
        hysteresis: "lux_th_window",              // sigh!
    },
    batteryThresholdMap: {
        lowValue: "threshold",                    // sigh!
    },
    capacitanceCalibMap: {
        lowValue: "cal1",
        lowCapacitance: "calRaw1",
        highValue: "cal2",
        highCapacitance: "calRaw2",
    },
    doorModeMap: {
        angle: "door_mode_angle",                 // degrees
        notifyWhenOpenFor: "door_mode_delay",     // seconds
        notifyOnClosed: "send_email_on_close",    // boolean
    },
    motionModeMap: {
        timeoutOrResetAfter: "auto_reset_delay",  // seconds
        timeoutMode: "hmc_timeout_mode",          // boolean
    },
    orientationMap1: {
        x: "az_x", y: "az_y", z: "az_z",
    },
    orientationMap2: {
        x: "az2_x", y: "az2_y", z: "az2_z",
    },
};

const monitoringPropertiesMap = {
    'motion' : {
        notifySettings: [
            undefined,
            xforms.mapObjectFunction(objectMaps.notifyMap, 'notifySettings')],
        sensitivity: ["sensitivity", xforms.noop, xforms.noop],
        responsiveness: ["interval",
                         xforms.mapFunction(motionResponsivenessStates)],
        isDoorMode: ["door_mode", xforms.noop, xforms.noop],
        doorMode: [undefined,
                   xforms.mapObjectFunction(objectMaps.doorModeMap,
                                            'doorMode')],
        motionMode: [undefined,
                     xforms.mapObjectFunction(objectMaps.motionModeMap,
                                              'motionMode')],
        orientation1: [undefined,
                       xforms.mapObjectFunction(objectMaps.orientationMap1,
                                                'orientation1')],
        orientation2: [undefined,
                       xforms.mapObjectFunction(objectMaps.orientationMap2,
                                                'orientation2')],
        armSilently: ["silent_arming", xforms.noop, xforms.noop],
    },
    // accelerometer properties get overlaid over motion
    'accelerometer' : {
        sensitivity: ["sensitivity2", xforms.noop, xforms.noop],
    },
    'event' : 'motion',
    'light' : {
        notifySettings: [
            undefined,
            xforms.mapObjectFunction(objectMaps.notifyMap, 'notifySettings')],
        thresholds: [
            undefined,
            xforms.mapObjectFunction(objectMaps.lightThresholdMap,
                                     'thresholds')],
        monitoringInterval: ["th_monitor_interval",
                             xforms.noop, xforms.noop],           // seconds
        beepTag: ["beep_tag", xforms.noop, xforms.noop],          // boolean
    },
    'temp' : {
        notifySettings: [
            undefined,
            xforms.mapObjectFunction(objectMaps.notifyMap, 'notifySettings')],
        thresholds: [
            undefined,
            xforms.mapObjectFunction(objectMaps.thresholdMap, 'thresholds')],
        monitoringInterval: ["interval", xforms.noop, xforms.noop], // seconds
        unit: ["temp_unit",
               function(x) { return x === 0 ? "degC" : "degF"; }],
        thresholdQuantization: ["threshold_q", xforms.noop, xforms.noop],
    },
    'humidity' : {
        notifySettings: [
            undefined,
            xforms.mapObjectFunction(objectMaps.notifyMap, 'notifySettings')],
        thresholds: [
            undefined,
            xforms.mapObjectFunction(objectMaps.thresholdMap, 'thresholds')],
        responsiveness: ["interval",
                         xforms.mapFunction(capResponsivenessStates),
                         xforms.revMapFunction(capResponsivenessStates)],
        calibration: [
            undefined,
            xforms.mapObjectFunction(objectMaps.capacitanceCalibMap,
                                     'calibration')],
    },
    'moisture' : 'humidity',
    'water' : {
        notifySettings: [
            undefined,
            xforms.mapObjectFunction(objectMaps.notifyMap, 'notifySettings')],
    },
    'current' : {
        notifySettings: [
            undefined,
            xforms.mapObjectFunction(objectMaps.notifyMap, 'notifySettings')],
        thresholds: [
            undefined,
            xforms.mapObjectFunction(objectMaps.thresholdMap, 'thresholds')],
        samplingPeriod: ["sampling_period", xforms.noop, xforms.noop],
        responsiveness: ["interval",
                         xforms.mapFunction(capResponsivenessStates),
                         xforms.revMapFunction(capResponsivenessStates)],
    },
    'battery' : {
        notifySettings: [
            undefined,
            xforms.mapObjectFunction(objectMaps.notifyMap, 'notifySettings')],
        thresholds: [
            undefined,
            xforms.mapObjectFunction(objectMaps.batteryThresholdMap,
                                     'thresholds')],
        monitoringEnabled: ["enabled", xforms.noop, xforms.noop],  // boolean
    },
    'outofrange': {
        notifySettings: [
            undefined,
            xforms.mapObjectFunction(objectMaps.notifyOutOfRangeMap,
                                     'notifySettings')],
    },
};

const sensorMonitorApiURIs = {
    'motion' : {
        load: "/ethClient.asmx/LoadMotionSensorConfig",
        save: "/ethClient.asmx/SaveMotionSensorConfig2"
    },
    'event' : {
        arm: "/ethClient.asmx/Arm",
        armData: { door_mode_set_closed: true },
        disarm: "/ethClient.asmx/Disarm",
        load: "/ethClient.asmx/LoadMotionSensorConfig",
        save: "/ethClient.asmx/SaveMotionSensorConfig2"
    },
    'light' : {
        arm: "/ethClient.asmx/ArmLightSensor",
        disarm: "/ethClient.asmx/DisarmLightSensor",
        load: "/ethClient.asmx/LoadLightSensorConfig",
        save: "/ethClient.asmx/SaveLightSensorConfig"
    },
    'temp' : {
        arm: "/ethClient.asmx/ArmTempSensor",
        disarm: "/ethClient.asmx/DisarmTempSensor",
        load: "/ethClient.asmx/LoadTempSensorConfig",
        save: "/ethClient.asmx/SaveTempSensorConfig2"
    },
    'humidity' : {
        arm: "/ethClient.asmx/ArmCapSensor",
        disarm: "/ethClient.asmx/DisarmCapSensor",
        load: "/ethClient.asmx/LoadCapSensorConfig2",
        payloadKey: "rhEvent",
        save: "/ethClient.asmx/SaveCapSensorConfig2"
    },
    'moisture' : {
        arm: "/ethClient.asmx/ArmCapSensor",
        disarm: "/ethClient.asmx/DisarmCapSensor",
        load: "/ethClient.asmx/LoadCapSensorConfig2",
        payloadKey: "rhEvent",
        save: "/ethClient.asmx/SaveCapSensorConfig2"
    },
    'water' : {
        load: "/ethClient.asmx/LoadCapSensorConfig2",
        payloadKey: "shortedEvent",
        save: "/ethClient.asmx/SaveWaterSensorConfig2"
    },
    'current' : {
        arm: "/ethClient.asmx/ArmCurrentSensor",
        disarm: "/ethClient.asmx/DisarmCurrentSensor",
        load: "/ethClient.asmx/LoadCurrentSensorConfig",
        save: "/ethClient.asmx/SaveCurrentSensorConfig2"
    },
    '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
 */
function WirelessTagSensor(tag, sensorType) {
    EventEmitter.call(this);
    this.wirelessTag = tag;
    this.callAPI = WirelessTagPlatform.callAPI;
    Object.defineProperty(this, "sensorType", {
        enumerable: true,
        value: sensorType,
    });
    this.errorHandler = tag.errorHandler || u.defaultHandler;
    Object.defineProperty(this, "data", {
        enumerable: true,
        get: () => { return this.wirelessTag.data; }
    });
    setPropertiesFromMap(this, sensorPropertiesMap, sensorType);
}
util.inherits(WirelessTagSensor, EventEmitter);

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);
};

WirelessTagSensor.prototype.isArmed = function() {
    if (this.eventState === undefined) return undefined;
    return ["Disarmed","Not Monitoring","N.A."].indexOf(this.eventState) < 0;
};

WirelessTagSensor.prototype.canArm = function() {
    let apiSpec = sensorMonitorApiURIs[this.sensorType];
    return apiSpec && apiSpec.arm;
};

WirelessTagSensor.prototype.canDisarm = function() {
    let apiSpec = sensorMonitorApiURIs[this.sensorType];
    return apiSpec && apiSpec.disarm;
};

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 WirelessTagSensor.changeArmedStatus(this, callback);
};

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 WirelessTagSensor.changeArmedStatus(this, callback);
};

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');
        }
    }
    return this._config || new MonitoringConfig(this.sensorType);
};

WirelessTagSensor.loadMonitoringConfig = function(sensor, callback) {
    var apiSpec = sensorMonitorApiURIs[sensor.sensorType];
    var uri = apiSpec ? apiSpec.load : undefined;
    if (! uri) return Promise.resolve(new MonitoringConfig(sensor.sensorType));
    var req = sensor.callAPI(uri, { id: sensor.wirelessTag.slaveId }, callback);
    return req.then(
        (result) => {
            if (apiSpec.payloadKey) result = result[apiSpec.payloadKey];
            let config = new MonitoringConfig(sensor.sensorType, result);
            // tweak if motion or event or outOfRange config
            if (sensor.sensorType === 'motion'
                || sensor.sensorType === 'event') {
                if (sensor.wirelessTag.hasAccelerometer()) {
                    setPropertiesFromMap(config,
                                         monitoringPropertiesMap,
                                         'accelerometer');
                }
            } else if (sensor.sensorType === 'outofrange') {
                let getSetFunc = xforms.delegatingFunction(sensor,
                                                           'gracePeriod');
                Object.defineProperty(config, 'gracePeriod', {
                    enumerable: true, configurable: true,
                    get: getSetFunc,
                    set: (newValue) => {
                        getSetFunc(newValue);
                        config.markModified('gracePeriod');
                    }
                });
            }
            // replace no-op method placeholders with real functions
            config.save = MonitoringConfig.saveFunc(sensor);
            config.update = MonitoringConfig.updateFunc(sensor);
            if (callback) callback(null, { object: sensor, value: config });
            return config;
        });
};

WirelessTagSensor.setMonitoringConfig = function(sensor, callback) {
    var apiSpec = sensorMonitorApiURIs[sensor.sensorType];
    var uri = apiSpec ? apiSpec.save : undefined;
    if (! uri) throw new OperationUnsupportedError(
        "undefined API for updating "
            + sensor.sensorType + " monitoring config for "
            + sensor.wirelessTag.name);
    var flagIndex = ('function' === typeof callback) ? 3 : 2;
    var confData = Object.assign({}, sensor.monitoringConfig().data);
    delete confData.__type;
    var reqBody = { id: sensor.wirelessTag.slaveId, config: confData };
    reqBody.applyAll = arguments[flagIndex] || false;
    reqBody.allMac = arguments[flagIndex + 1] || false;
    return sensor.callAPI(uri, reqBody, callback).then(() => {
        if (callback) callback(null, { object: sensor });
        return sensor;
    });
};

WirelessTagSensor.setOutOfRangeGracePeriod = function(sensor, callback) {
    var reqBody = { id: sensor.wirelessTag.slaveId,
                    // we can't use the accessor property ('gracePeriod') here
                    // becasuse that one will return the value mapped to seconds
                    oorGrace: sensor.data.oorGrace };
    var flagIndex = ('function' === typeof callback) ? 2 : 1;
    reqBody.applyAll = arguments[flagIndex] || false;
    var req = sensor.callAPI('/ethClient.asmx/SetOutOfRangeGrace',
                             reqBody,
                             callback);
    return req.then((result) => {
        sensor.wirelessTag.data = result;
        if (callback) callback(null, { object: sensor });
        return sensor;
    });
};

WirelessTagSensor.changeArmedStatus = function(sensor, callback) {
    var isArmed = sensor.isArmed();
    var action = isArmed ? "disarm" : "arm";
    var apiSpec = sensorMonitorApiURIs[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) => { return tag[sensor.sensorType + "Sensor"] });
        }
        if (callback) callback(null, { object: sensor });
        return sensor;
    });
};

/**
 * The monitoring configuration of a {@link WirelessTagSensor}.
 *
 * A user will not normally need to create instances directly; instead
 * they are returned from {@link WirelessTagSensor#monitoringConfig}.
 *
 * @param {string} sensorType - the type of the sensor
 * @param {Object} [data] - the object with the status properties and
 *                 values for this monitoring configuration, as
 *                 returned by the API endpoint
 *
 * @class
 * @alias MonitoringConfig
 */
function MonitoringConfig(sensorType, data) {
    this.data = data || {};
    Object.defineProperty(this, "_dirty", { value: {}, writable: true });
    setPropertiesFromMap(this, monitoringPropertiesMap, sensorType);
    // default save() and update() methods to no-ops
    this.save = this.update = function() {
        return Promise.resolve(this);
    };
}

MonitoringConfig.prototype.isModified = function(configProperty) {
    if (configProperty) return this._dirty[configProperty] || false;
    return Object.keys(this._dirty).length > 0;
};

MonitoringConfig.prototype.resetModified = function() {
    this._dirty = {};
    return this;
};

MonitoringConfig.prototype.markModified = function(configProperty) {
    if (configProperty) {
        this._dirty[configProperty] = true;
    } else {
        for (let key of Object.keys(this.data)) {
            this._dirty[key] = true;
        }
        for (let key of Object.keys(this)) {
            if (! (key.startsWith('_')
                   || key === 'data'
                   || 'function' === typeof this[key])) {
                this._dirty[key] = true;
            }
        }
        // if the above didn't mark anything because this is a dummy-config,
        // ensure this still complies with expected behavior
        if (Object.keys(this._dirty).length === 0) {
            this._dirty.__ALL__ = true;
        }
    }
    return this;
};

MonitoringConfig.prototype.asJSON = function() {
    let propsObj = {};
    // all properties except data and private ones
    for (let propName of Object.getOwnPropertyNames(this)) {
        if (! (propName.startsWith('_')
               || (propName === 'domain')
               || (propName === 'data')
               || ('function' === typeof this[propName]))) {
            propsObj[propName] = this[propName];
        }
    }
    return propsObj;
};

MonitoringConfig.prototype.toString = function() {
    return JSON.stringify(this.asJSON());
};

MonitoringConfig.saveFunc = function(sensor) {
    return function(callback) {
        if (this.isModified()) {
            // if this is an out of range config and grace period is one of the
            // properties changed, then start with saving that, which
            // unfortunately is a separate (and undocumented) API call.
            let setGrace;
            if (this.isModified('gracePeriod')) {
                let ecb = callback ?
                    (e) => { if (e) callback(e); } : undefined;
                setGrace = WirelessTagSensor.setOutOfRangeGracePeriod(sensor,
                                                                      ecb);
            } else {
                setGrace = Promise.resolve(sensor);
            }
            // then save the (possibly rest of) monitoring config
            let req = setGrace.then((s) => {
                let ecb = callback ?
                    (e) => { if (e) callback(e); } : undefined;
                return WirelessTagSensor.setMonitoringConfig(sensor, ecb);
            });
            // finally convert from sensor to config to simplify chaining
            return req.then((s) => {
                let mconfig = s.monitoringConfig().resetModified();
                s.emit('config', s, mconfig, 'save');
                if (callback) callback(null, { object: mconfig });
                return mconfig;
            });
        }
        return Promise.resolve(this);
    };
};

MonitoringConfig.updateFunc = function(sensor) {
    return function(callback) {
        if (! this.isModified()) {
            let ecb = callback ? (e) => { if (e) callback(e); } : undefined;
            let req = WirelessTagSensor.loadMonitoringConfig(sensor, ecb);
            return req.then(
                (config) => {
                    this.data = config.data;
                    sensor.emit('config', sensor, this, 'update');
                    if (callback) callback(null, { object: this });
                    return this;
                });
        }
        return Promise.resolve(this);
    };
};

function setPropertiesFromMap(obj, propMapDict, dictKey) {
    let propMap = propMapDict[dictKey];
    // key aliased to another entry?
    if ('string' === typeof propMap) propMap = propMapDict[propMap];
    for (let propName in propMap) {
        let propSpec = propMap[propName];
        let dataKey = propSpec[0];
        let transform = propSpec[1] ? propSpec[1].bind(obj) : undefined;
        let revTransform = propSpec[2] ? propSpec[2].bind(obj) : undefined;
        let descriptor = {
            enumerable: true,
            configurable: true,
        };
        if (transform !== undefined) {
            /* jshint loopfunc: true */
            // this works because every variable used is declared with let
            descriptor.get = () => {
                if (dataKey === undefined) {
                    return transform();
                }
                return transform(obj.data[dataKey]);
            };
            /* jshint loopfunc: false */
        }
        if (revTransform !== undefined) {
            /* jshint loopfunc: true */
            // this works because every variable used is declared with let
            descriptor.set = (newValue) => {
                let val = revTransform(newValue);
                if (dataKey !== undefined) obj.data[dataKey] = val;
                if ('function' === typeof obj.markModified) {
                    obj.markModified(propName);
                    if (dataKey !== undefined) obj.markModified(dataKey);
                }
                if (obj instanceof EventEmitter) {
                    obj.emit('update', obj, propName, newValue, val);
                }
                return newValue;
            };
            /* jshint loopfunc: false */
        }
        Object.defineProperty(obj, propName, descriptor);
    }
}