"use strict";
/**
* Base class for creating a SignalK Node Server plugin. This class provides
* basic behaviors desireable to all plugins.
*/
class SignalKPlugin {
/**
* Constuctor for the base SignalK Plugin class. This may be called the normal way by using
* parameter positions to specify the plugin definition,
* or alternatively using a pseudo 'call by name' where
* the first parameter is an object, and each property name of the object matches
* the corresponding parameter name.
* @param {object} app The ExpressJS app object that was passed from the SignalK server to the plugin factory function
* @param {string} id Uniquely identifies this plugin. Usually in the form "signalk-some-plugin"
* @param {string} name Human readable name/title of this plugin
* @param {string} description A brief description of what this plugin does
*/
constructor(app, id, name, description) {
if (app.hasOwnProperty('app') && typeof id === 'undefined') {
// Destructure assignment doesn't work on Node on Rasperry Pi
// ({app, id, name, description} = app);
this.app = app.app;
this.id = app.id;
this.name = app.name;
this.description = app.description;
}
else {
this.app = app;
this.id = id;
this.name = name;
this.description = description;
}
this._schema = { type: "object", properties: {} };
this._optContainers = [ this._schema.properties ];
this.unsub = [];
}
/**
* Returns the current time in milliseconds. Call this
* whenever the current time is needed. External unit tests
* may monkey patch this method to return a simulated time of
* day while the tests are running.
*/
getTime() {
return new Date().getTime();
}
/**
* Returns the number of whole seconds that have elapsed between
* time t1 and time t2 (both assumed to be the UTC time retrieved via
* this.getTime()). If t2 is unspecified, the current time is assumed.
* If t1 is greater than t2, a negative number will be returned.
* @param {number} t1 A UTC millisecond value retrieved via this.getTime()
* @param {number} [t2=this.getTime()] A UTC millisecond value retrieved via this.getTime()
*/
elapsedSecs(t1, t2) {
if (_.isUndefined(t2)) {
t2 = this.getTime();
}
return Math.round((t2 - t1) / 1000);
}
/**
* Called when the plugin starts. Descendant classes can override, but
* should call super.start() if they do. Overriding is normally not necessary
* as extensions to this method are generally placed in onPluginStarted()
* @see onPluginStarted
*/
start(options, restartPlugin) {
this.startedOn = -1;
this.unsub = [];
this.running = true;
this.restartPlugin = restartPlugin;
// Here we put our plugin logic
this.debug(`${this.name} plugin starting...`);
this.setStatus("Starting...");
this._setDefaultOptions(options);
this.debug(`Options: ${JSON.stringify(options)}`);
this.options = options;
this.dataDir = this.app.getDataDirPath();
this.debug(`Data dir path is ${this.dataDir}`);
this.onPluginStarted();
this.debug(`${this.name} started`);
this.startedOn = this.getTime();
}
/**
* Called once the plugin has started and all options have been resolved. Normally, this
* creates the BaconJS data streams and properties used by this plugin and subscribes
* to them.
* @see start
*/
onPluginStarted() {
this.debug('WARNING: No data streams defined. onPluginStarted() should be overridden.');
}
/**
* Called when the plugin stops. Cleanup occurs here, including
* unsubscribing from any data paths. If you override this, be
* sure to call super.stop(). Extensions are usually instead
* placed in onPluginStopped().
* @see onPluginStopped
*/
stop() {
if (!this.running) {
// Ignore calls to stop() if we are already stopped();
return;
}
this.running = false;
this.debug(`${this.name} stopping`);
this.unsub.forEach(f => f());
this.unsub = [];
this.onPluginStopped();
this.setStatus("Stopped");
this.debug(`${this.name} stopped`);
this.startedOn = -1;
}
/**
* Restarts this plugin by calling the "restartPlugin" function that was
* passed to the start() method.
*/
restart() {
if (this.restartPlugin) {
this.restartPlugin();
}
}
/**
* Called when the plugin is to stop running.
* @see stop
*/
onPluginStopped() {
}
/**
* Sets the status of this plugin, placing msg on the server admin app.
* @param {string} msg
*/
setStatus(msg) {
this.app.setProviderStatus(msg);
}
/**
* Used to indicate an error has occurred in the pluging. The specified msg
* will appear on the server admin app
* @param {string} msg
*/
setError(msg) {
this.app.setProviderError(msg);
}
/**
* Returns a BaconJS stream that produces the deltas for the specified
* SignalK path. If allContexts is specified as TRUE, the bus will
* be for ALL contexts. Otherwise, that data will be restricted to
* "self" (i.e. data for the boat's "self" context)
* @param {string} skPath The SK path to receive, or unspecified for ALL data
* @param {boolean} [allContexts=false] TRUE to get ALL vessels data. unspecified or FALSE implies
* you want just the data for the current vessel.
*/
getSKBus(skPath, allContexts) {
if (allContexts) {
return this.app.streambundle.getBus(skPath);
}
else {
return this.app.streambundle.getSelfBus(skPath);
}
}
/**
* Similar to getSKBus() except the data producsed by the BaconJS stream
* will be limited to the "value" property of the data, vs. the entire delta.
* @param {string} skPath The SK path to receive, or unspecified for ALL data
*/
getSKValues(skPath) {
return this.app.streambundle.getSelfStream(skPath);
}
/**
* 'Subscribes' function f to the stream strm, adding its 'unsubscribe' function
* to the unsub list.
* @param {Bacon.Stream} strm
* @param {function} f Any function or method from an object. If f is a method
* from an object other than 'this', be sure to pass the self parameter.
* @param {object} [self=this] Option parameter to specify the 'this' object that
* f should be bound to. Self must be specified if f is a method of an object
* other than 'this' plugin object.
*/
subscribeVal(strm, f, self) {
if (!self) {
self = this;
}
this.unsub.push(strm.onValue(f.bind(self)));
}
/**
* Sends a single value SignalK delta thru the server and out to all external
* subscribers. To send more than one value at a time, use sendSKValues()
* @param {string} skPath The SignalK path that corresponds to the value
* @param {any} value The actual value to send out
* @see #sendSKValues
*/
sendSK(skPath, value) {
let values = [];
values.push({ path: skPath, value });
this.sendSKValues(values);
}
/**
* Sends the specified array of path value objects as a SignalK delta thru the
* server and out to all external subscribers.
* @param {array} values An array of one or more objects with each element
* being in the format { path: "signal.k.path", value: "someValue" }
* @see #sendSK
*/
sendSKValues(values) {
var delta = {
"updates": [
{
"source": {
"label": this.id,
},
"values": values
}
]
};
this.debug(`sending SignalK: ${JSON.stringify(delta, null, 2)}`);
this.app.handleMessage(this.id, delta);
}
/**
* Outputs a debug message for this plugin. The message will be visible on the
* console if DEBUG environment variable is set to this plugin's id.
* @param {string} msg The message to display on the console output
*/
debug(msg) {
this.app.debug(msg);
}
/**
* Returns a Json Schema that defines the user configurable options that this plugin utilizes.
* You can define the value of this schema by calling the various <code>optXXX()</code> methods in
* this class, or you can define the Json schema manually and assign it to the
* <code>this._schema</code> variable. A third alternative is to override this method in your
* derived class and have it return the Json Schema in any way you see fit.
*/
schema() {
return this._schema;
}
/**
* Defines a string configuration option that the user can set. The specified propName
* will appear as a property in this._schema, and will have a default value of defaultVal.
* <p/>This method may be called the normal way by using parameter positions to specify
* your option definition, or alternatively using a pseudo 'call by name' where
* the first parameter is an object, and each property name of the object matches
* the corresponding parameter name.
* @param {string} propName The name of the property variable used for this option
* @param {string} title A label that describes this option (short form)
* @param {string} [defaultVal=''] The default value to use for this option
* @param {boolean} [isArray=false] TRUE if this option is actually an array of strings
* @param {string} [longDescription] An optional long description of this option
* @param {boolean} [required=false] If TRUE, this property must be given a non-blank value
*/
optStr(propName, title, defaultVal, isArray, longDescription, required) {
this._defineOption('string', { minLength : 1}, propName, title, (typeof defaultVal !== 'undefined') ? defaultVal: '', isArray, longDescription, required);
}
/**
* Defines a numeric configuration option that the user can set. The specified propName
* will appear as a property in this._schema, and will have a default value of defaultVal.
* <p/>This method may be called the normal way by using parameter positions to specify
* your option definition, or alternatively using a pseudo 'call by name' where
* the first parameter is an object, and each property name of the object matches
* the corresponding parameter name.
* @param {string} propName The name of the property variable used for this option
* @param {string} title A label that describes this option (short form)
* @param {number} [defaultVal=0] The default value to use for this option
* @param {boolean} [isArray=false] TRUE if this option is actually an array of numbers
* @param {string} [longDescription] An optional long description of this option
* @param {boolean} [required=false] If TRUE, this property must be given a positive non-zero value
*/
optNum(propName, title, defaultVal, isArray, longDescription, required) {
this._defineOption('number', { minimum: 1 }, propName, title, (typeof defaultVal !== 'undefined') ? defaultVal: 0, isArray, longDescription, required);
}
/**
* Defines an interger configuration option that the user can set. The specified propName
* will appear as a property in this._schema, and will have a default value of defaultVal.
* <p/>This method may be called the normal way by using parameter positions to specify
* your option definition, or alternatively using a pseudo 'call by name' where
* the first parameter is an object, and each property name of the object matches
* the corresponding parameter name.
* @param {string} propName The name of the property variable used for this option
* @param {string} title A label that describes this option (short form)
* @param {integer} [defaultVal=0] The default value to use for this option
* @param {boolean} [isArray=false] TRUE if this option is actually an array of integers
* @param {string} [longDescription] An optional long description of this option
* @param {boolean} [required=false] If TRUE, this property must be given a positive non-zero value
*/
optInt(propName, title, defaultVal, isArray, longDescription, required) {
this._defineOption('integer', { minimum: 1 }, propName, title, (typeof defaultVal !== 'undefined') ? defaultVal: 0, isArray, longDescription, required);
}
/**
* Defines a boolean configuration option that the user can set. The specified propName
* will appear as a property in this._schema, and will have a default value of defaultVal.
* <p/>This method may be called the normal way by using parameter positions to specify
* your option definition, or alternatively using a pseudo 'call by name' where
* the first parameter is an object, and each property name of the object matches
* the corresponding parameter name.
* @param {string} propName The name of the property variable used for this option
* @param {string} title A label that describes this option (short form)
* @param {boolean} [defaultVal=false] The default value to use for this option
* @param {boolean} [isArray=false] TRUE if this option is actually an array of booleans
* @param {string} [longDescription] An optional long description of this option
*/
optBool(propName, title, defaultVal, isArray, longDescription) {
this._defineOption('boolean', {}, propName, title, (typeof defaultVal !== 'undefined') ? defaultVal: false, isArray, longDescription);
}
// General purpose worker method to set options. Used by the other
// optXXX() methods.
_defineOption(optionType, requiredSpec, propName, title, defaultVal, isArray, longDescription, required) {
// Support pseudo 'call by name'...
if (typeof propName === 'object' && typeof title === 'undefined') {
let oldDV = defaultVal;
// Destructure doesn't work on Node on Raspberry Pi
// ({ propName, title, defaultVal, isArray, longDescription, required } = propName);
title = propName.title;
isArray = propName.isArray;
longDescription = propName.longDescription;
required = propName.required;
defaultVal = propName.defaultVal;
propName = propName.propName;
if (typeof defaultVal === 'undefined') {
defaultVal = oldDV;
}
}
if (required) {
this.debug('Json validation not yet support in Plugin editor. Required field will be ignored');
required = false;
}
let opt = {
title,
default: defaultVal,
description: longDescription
};
if (isArray) {
opt.type = 'array';
opt.items = { type: optionType };
if (required) {
Object.assign(opt.items, requiredSpec);
}
}
else {
opt.type = optionType;
if (required) {
Object.assign(opt, requiredSpec);
}
}
let container = this._optContainers[this._optContainers.length-1];
container[propName] = opt;
}
/**
* Defines configuration option that is itself an object of other properties that the user can set.
* The specified propName will appear as a property in this._schema. Once this method is called,
* all other calls to the optXXX() definition methods will place those properties in this
* object. This will continue until optObjEnd() is called. You MUST call optObjEnd() when
* the object definition has been completed.
* <p/>This method may be called the normal way by using parameter positions to specify
* your option definition, or alternatively using a pseudo 'call by name' where
* the first parameter is an object, and each property name of the object matches
* the corresponding parameter name.
* @see optObjEnd
* @param {string} propName The name of the property variable used for this option
* @param {string} title A label that describes this option (short form)
* @param {boolean} [isArray=false] TRUE if this option is actually an array of the defined object
* @param {string} [longDescription] An optional long description of this option
* @param {string} [itemTitle] If the isArray param is TRUE, this is the optional title of an
* individual element in the array.
*/
optObj(propName, title, isArray, longDescription, itemTitle) {
// Support pseudo 'call by name'...
if (typeof propName === 'object') {
// Destructure doesn't work on Node on Raspberry Pi
// ({ propName, title, isArray, longDescription, itemTitle } = propName);
title = propName.title;
isArray = propName.isArray;
longDescription = propName.longDescription;
itemTitle = propName.itemTitle;
propName = propName.propName;
}
let container = this._optContainers[this._optContainers.length-1];
let opt = {
title,
description: longDescription
};
if (isArray) {
opt.type = 'array';
opt.items = { type: 'object', title: itemTitle, properties: {} };
this._optContainers.push(opt.items.properties);
}
else {
opt.type = 'object';
opt.properties = {};
this._optContainers.push(opt.properties);
}
container[propName] = opt;
}
/**
* Call this method to end the definition of an object property.
* @see optObj
*/
optObjEnd() {
this._optContainers.pop();
}
// Internal method used to ensure each option in the options list has
// a value that matches the data returned by schema(). If no value exists,
// it is added, using the default value specified in the schema.
_setDefaultOptions(options) {
for (var propName in this.schema().properties) {
this._checkOption(options, propName);
} //
};
// Check an individual option, creating it with the default value if it
// does not exist.
_checkOption(options, propName) {
if (typeof options[propName] === "undefined") {
options[propName] = this.schema().properties[propName].default;
}
};
/**
* Returns TRUE if the specified test value matches the specified matchVal. An
* empty matchVal is a wildcard that matches any value of testVal. This method
* is useful for matching optional configuration values to stream filters.
* @param {string} testVal The current value you are testing
* @param {string} matchVal The value testVal is to match. If matchVal is empty,
* testvVal ALWAYS matches
* @returns TRUE if testVal == matchVal, or if matchVal is empty/blank
*/
wildcardEq(testVal, matchVal) {
function strEmpty(str) {
return (!str || 0 === str.trim().length);
}
return (strEmpty(matchVal) || testVal == matchVal);
}
}
module.exports = SignalKPlugin;