signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
532 lines (531 loc) • 21.9 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const logging_1 = require("@signalk/streams/logging");
const express_1 = __importDefault(require("express"));
const fs_1 = __importDefault(require("fs"));
const util_1 = require("util");
const lodash_1 = __importDefault(require("lodash"));
const path_1 = __importDefault(require("path"));
const constants_1 = require("../constants");
const debug_1 = require("../debug");
const serialports_1 = require("../serialports");
const debug = (0, debug_1.createDebug)('signalk-server:interfaces:plugins');
const deltastats_1 = require("../deltastats");
const modules_1 = require("../modules");
const put = require('../put');
const _putPath = put.putPath;
const getModulePublic = require('../config/get').getModulePublic;
const queryRequest = require('../requestResponse').queryRequest;
const signalk_schema_1 = require("@signalk/signalk-schema");
// #521 Returns path to load plugin-config assets.
const getPluginConfigPublic = getModulePublic('@signalk/plugin-config');
const DEFAULT_ENABLED_PLUGINS = process.env.DEFAULTENABLEDPLUGINS
? process.env.DEFAULTENABLEDPLUGINS.split(',')
: [];
function backwardsCompat(url) {
return [`${constants_1.SERVERROUTESPREFIX}${url}`, url];
}
module.exports = (theApp) => {
const onStopHandlers = {};
return {
async start() {
ensureExists(path_1.default.join(theApp.config.configPath, 'plugin-config-data'));
theApp.getPluginsList = async (enabled) => {
return await getPluginsList(enabled);
};
theApp.use(backwardsCompat('/plugins/configure'), express_1.default.static(getPluginConfigPublic(theApp)));
theApp.get(backwardsCompat('/plugins'), (req, res) => {
getPluginResponseInfos()
.then((json) => res.json(json))
.catch((err) => {
console.error(err);
res.status(500);
res.json(err);
});
});
await startPlugins(theApp);
}
};
function getPluginResponseInfos() {
const providerStatus = theApp.getProviderStatus();
return Promise.all(lodash_1.default.sortBy(theApp.plugins, [
(plugin) => {
return plugin.name;
}
]).map((plugin) => getPluginResponseInfo(plugin, providerStatus)));
}
function getPluginsList(enabled) {
return getPluginResponseInfos().then((pa) => {
const res = pa.map((p) => {
return {
id: p.id,
name: p.name,
version: p.version,
enabled: p.data.enabled ?? false
};
});
if (typeof enabled === 'undefined') {
return res;
}
else {
return res.filter((p) => {
return p.enabled === enabled;
});
}
});
}
function getPluginResponseInfo(plugin, providerStatus) {
return new Promise((resolve, reject) => {
let data = null;
try {
data = getPluginOptions(plugin.id);
}
catch (e) {
console.error(e.code + ' ' + e.path);
}
if (data && lodash_1.default.isUndefined(data.enabled) && plugin.enabledByDefault) {
data.enabled = true;
}
Promise.all([
Promise.resolve(typeof plugin.schema === 'function'
? (() => {
try {
return plugin.schema();
}
catch (e) {
console.error(e);
// return a fake schema to inform the user
// downside is that saving this may overwrite an existing configuration
return {
type: 'object',
required: ['error'],
properties: {
error: {
title: 'Error loading plugin configuration schema, check server log',
type: 'string'
}
}
};
}
})()
: plugin.schema),
Promise.resolve(typeof plugin.uiSchema === 'function'
? plugin.uiSchema()
: plugin.uiSchema)
])
.then(([schema, uiSchema]) => {
const status = providerStatus.find((p) => p.id === plugin.name);
const statusMessage = status ? status.message : '';
if (schema === undefined) {
console.error(`Error: plugin ${plugin.id} is missing configuration schema`);
}
resolve({
id: plugin.id,
name: plugin.name,
packageName: plugin.packageName,
keywords: plugin.keywords,
version: plugin.version,
description: plugin.description,
schema: schema || {},
statusMessage,
uiSchema,
state: plugin.state,
data
});
})
.catch((err) => {
reject(err);
});
});
}
function ensureExists(dir) {
if (!fs_1.default.existsSync(dir)) {
fs_1.default.mkdirSync(dir);
}
}
function pathForPluginId(id) {
return path_1.default.join(theApp.config.configPath, 'plugin-config-data', id + '.json');
}
function dirForPluginId(id) {
const dirName = path_1.default.join(theApp.config.configPath, 'plugin-config-data', id);
ensureExists(dirName);
return dirName;
}
function savePluginOptions(pluginId, data, callback) {
try {
fs_1.default.writeFileSync(pathForPluginId(pluginId), JSON.stringify(data, null, 2));
callback(null);
}
catch (err) {
callback(err);
}
}
function getPluginOptions(id) {
let optionsAsString = '{}';
try {
optionsAsString = fs_1.default.readFileSync(pathForPluginId(id), 'utf8');
}
catch (_e) {
debug('Could not find options for plugin ' +
id +
', returning empty options: ');
}
try {
const options = JSON.parse(optionsAsString);
if (optionsAsString === '{}' && DEFAULT_ENABLED_PLUGINS.includes(id)) {
debug('Override enable for plugin ' + id);
options.enabled = true;
}
if (process.env.DISABLEPLUGINS) {
debug('Plugins disabled by configuration');
options.enabled = false;
}
debug(optionsAsString);
return options;
}
catch (e) {
console.error('Could not parse JSON options:' + e.message + ' ' + optionsAsString);
return {};
}
}
async function startPlugins(app) {
app.plugins = [];
app.pluginsMap = {};
const modules = (0, modules_1.modulesWithKeyword)(app.config, 'signalk-node-server-plugin');
await Promise.all(modules.map((moduleData) => {
return registerPlugin(app, moduleData.module, moduleData.metadata, moduleData.location);
}));
}
function handleMessageWrapper(app, id) {
const pluginsLoggingEnabled = lodash_1.default.isUndefined(app.config.settings.enablePluginLogging) ||
app.config.settings.enablePluginLogging;
return (providerId, data) => {
const plugin = app.pluginsMap[id];
if (!lodash_1.default.isUndefined(plugin) &&
pluginsLoggingEnabled &&
plugin.enableLogging) {
if (!plugin.logger) {
plugin.logger = (0, logging_1.getLogger)(app, providerId);
}
plugin.logger(data);
}
app.handleMessage(id, data);
};
}
function getSelfPath(aPath) {
return lodash_1.default.get(theApp.signalk.self, aPath);
}
function getPath(aPath) {
if (aPath === '/sources') {
return {
...theApp.signalk.retrieve().sources,
...theApp.deltaCache.getSources()
};
}
else {
return lodash_1.default.get(theApp.signalk.retrieve(), aPath);
}
}
function putSelfPath(aPath, value, updateCb, source) {
return _putPath(theApp, 'vessels.self', aPath, { value, source }, null, null, updateCb);
}
function putPath(aPath, value, updateCb, source) {
const parts = aPath.length > 0 ? aPath.split('.') : [];
if (parts.length <= 2) {
updateCb(new Error(`Put path begin with a two part context:${aPath}`));
return;
}
const context = `${parts[0]}.${parts[1]}`;
const skpath = parts.slice(2).join('.');
return _putPath(theApp, context, skpath, { value, source }, null, null, updateCb);
}
function getSerialPorts() {
return (0, serialports_1.listAllSerialPorts)();
}
async function registerPlugin(app, pluginName, metadata, location) {
debug('Registering plugin ' + pluginName);
try {
await doRegisterPlugin(app, pluginName, metadata, location);
}
catch (e) {
console.error(e);
}
}
function stopPlugin(plugin) {
debug('Stopping plugin ' + plugin.name);
onStopHandlers[plugin.id].forEach((f) => {
try {
f();
}
catch (err) {
console.error(err);
}
});
onStopHandlers[plugin.id] = [];
const result = Promise.resolve(plugin.stop());
result.then(() => {
theApp.setPluginStatus(plugin.id, 'Stopped');
debug('Stopped plugin ' + plugin.name);
});
return result;
}
function setPluginStartedMessage(plugin) {
const statusMessage = typeof plugin.statusMessage === 'function'
? plugin.statusMessage()
: undefined;
if (lodash_1.default.isUndefined(statusMessage) &&
lodash_1.default.isUndefined(theApp.providerStatus[plugin.id]) &&
lodash_1.default.isUndefined(plugin.statusMessage)) {
theApp.setPluginStatus(plugin.id, 'Started');
}
}
function doPluginStart(app, plugin, location, configuration, restart) {
debug('Starting plugin %s from %s', plugin.name, location);
try {
app.setPluginStatus(plugin.id, null);
if (plugin.enableDebug) {
app.logging.addDebug(plugin.packageName);
}
else {
app.logging.removeDebug(plugin.packageName);
}
let safeConfiguration = configuration;
if (!safeConfiguration) {
console.error(`${plugin.id}:no configuration data`);
safeConfiguration = {};
}
onStopHandlers[plugin.id].push(() => {
app.resourcesApi.unRegister(plugin.id);
app.autopilotApi.unRegister(plugin.id);
app.weatherApi.unRegister(plugin.id);
});
plugin.start(safeConfiguration, restart);
debug('Started plugin ' + plugin.name);
setPluginStartedMessage(plugin);
}
catch (e) {
console.error('error starting plugin: ' + e);
console.error(e.stack);
app.setProviderError(plugin.id, `Failed to start: ${e.message}`);
}
}
async function doRegisterPlugin(app, packageName, metadata, location) {
let plugin;
const appCopy = lodash_1.default.assign({}, app, {
getSelfPath,
getPath,
putSelfPath,
queryRequest,
error: (msg) => {
console.error(`${packageName}:${msg}`);
if (msg instanceof Error) {
console.error(msg.stack);
}
},
debug: (0, debug_1.createDebug)(packageName),
registerDeltaInputHandler: (handler) => {
onStopHandlers[plugin.id].push(app.registerDeltaInputHandler(handler));
},
setProviderStatus: (0, util_1.deprecate)((msg) => {
app.setPluginStatus(plugin.id, msg);
}, `[${packageName}] setProviderStatus() is deprecated, use setPluginStatus() instead`),
setProviderError: (0, util_1.deprecate)((msg) => {
app.setPluginError(plugin.id, msg);
}, `[${packageName}] setProviderError() is deprecated, use setPluginError() instead`),
setPluginStatus: (msg) => {
app.setPluginStatus(plugin.id, msg);
},
setPluginError: (msg) => {
app.setPluginError(plugin.id, msg);
},
emitPropertyValue(name, value) {
const propValues = app.propertyValues; // just for typechecking
propValues.emitPropertyValue({
timestamp: Date.now(),
setter: plugin.id,
name,
value
});
},
onPropertyValues(name, cb) {
return app.propertyValues.onPropertyValues(name, cb);
},
getSerialPorts,
supportsMetaDeltas: true,
getMetadata: signalk_schema_1.getMetadata,
reportOutputMessages: (count) => {
app.emit(deltastats_1.CONNECTION_WRITE_EVENT_NAME, {
providerId: plugin.id,
count
});
}
});
appCopy.putPath = putPath;
const weatherApi = app.weatherApi;
lodash_1.default.omit(appCopy, 'weatherApi'); // don't expose the actual weather api manager
appCopy.registerWeatherProvider = (provider) => {
weatherApi.register(plugin.id, provider);
};
const resourcesApi = app.resourcesApi;
lodash_1.default.omit(appCopy, 'resourcesApi'); // don't expose the actual resource api manager
appCopy.registerResourceProvider = (provider) => {
resourcesApi.register(plugin.id, provider);
};
const autopilotApi = app.autopilotApi;
lodash_1.default.omit(appCopy, 'autopilotApi'); // don't expose the actual autopilot api manager
appCopy.registerAutopilotProvider = (provider, devices) => {
autopilotApi.register(plugin.id, provider, devices);
};
appCopy.autopilotUpdate = (deviceId, apInfo) => {
autopilotApi.apUpdate(plugin.id, deviceId, apInfo);
};
lodash_1.default.omit(appCopy, 'apiList'); // don't expose the actual apiList
const courseApi = app.courseApi;
lodash_1.default.omit(appCopy, 'courseApi'); // don't expose the actual course api manager
appCopy.getCourse = () => {
return courseApi.getCourse();
};
appCopy.clearDestination = () => {
return courseApi.clearDestination();
};
appCopy.setDestination = (dest) => {
return courseApi.destination(dest);
};
appCopy.activateRoute = (dest) => {
return courseApi.activeRoute(dest);
};
try {
const moduleDir = path_1.default.join(location, packageName);
const pluginConstructor = await (0, modules_1.importOrRequire)(moduleDir);
plugin = pluginConstructor(appCopy);
}
catch (e) {
console.error(`${packageName} failed to start: ${e.message}`);
console.error(e);
app.setProviderError(packageName, `Failed to start: ${e.message}`);
return;
}
onStopHandlers[plugin.id] = [];
if (app.pluginsMap[plugin.id]) {
console.log(`WARNING: found multiple copies of plugin with id ${plugin.id} at ${location} and ${app.pluginsMap[plugin.id].packageLocation}`);
return;
}
appCopy.handleMessage = handleMessageWrapper(app, plugin.id);
const boundEventMethods = app.wrappedEmitter.bindMethodsById(`plugin:${plugin.id}`);
lodash_1.default.assign(appCopy, boundEventMethods);
appCopy.savePluginOptions = (configuration, cb) => {
savePluginOptions(plugin.id, { ...getPluginOptions(plugin.id), configuration }, cb);
};
appCopy.readPluginOptions = () => {
return getPluginOptions(plugin.id);
};
appCopy.getDataDirPath = () => dirForPluginId(plugin.id);
appCopy.registerPutHandler = (context, aPath, callback, source) => {
appCopy.handleMessage(plugin.id, {
updates: [
{
meta: [
{
path: aPath,
value: {
supportsPut: true
}
}
]
}
]
});
onStopHandlers[plugin.id].push(app.registerActionHandler(context, aPath, source || plugin.id, callback));
};
appCopy.registerActionHandler = appCopy.registerPutHandler;
appCopy.registerHistoryProvider = (provider) => {
app.registerHistoryProvider(provider);
const apiList = app.apis;
apiList.push('historyplayback');
apiList.push('historysnapshot');
onStopHandlers[plugin.id].push(() => {
app.unregisterHistoryProvider(provider);
});
};
const startupOptions = getPluginOptions(plugin.id);
const restart = (newConfiguration) => {
const pluginOptions = getPluginOptions(plugin.id);
pluginOptions.configuration = newConfiguration;
savePluginOptions(plugin.id, pluginOptions, (err) => {
if (err) {
console.error(err);
}
else {
stopPlugin(plugin).then(() => {
return Promise.resolve(doPluginStart(app, plugin, location, newConfiguration, restart));
});
}
});
};
if (isEnabledByPackageEnableDefault(startupOptions, metadata)) {
startupOptions.enabled = true;
startupOptions.configuration = {};
plugin.enabledByDefault = true;
}
plugin.enableDebug = startupOptions.enableDebug;
plugin.version = metadata.version;
plugin.packageName = metadata.name;
plugin.keywords = metadata.keywords;
plugin.packageLocation = location;
if (startupOptions && startupOptions.enabled) {
doPluginStart(app, plugin, location, startupOptions.configuration, restart);
}
plugin.enableLogging = startupOptions.enableLogging;
app.plugins.push(plugin);
app.pluginsMap[plugin.id] = plugin;
const router = express_1.default.Router();
router.get('/', (req, res) => {
const currentOptions = getPluginOptions(plugin.id);
const enabledByDefault = isEnabledByPackageEnableDefault(currentOptions, metadata);
res.json({
enabled: enabledByDefault || currentOptions.enabled,
enabledByDefault,
id: plugin.id,
name: plugin.name,
version: plugin.version
});
});
router.post('/config', (req, res) => {
savePluginOptions(plugin.id, req.body, (err) => {
if (err) {
console.error(err);
res.status(500);
res.json(err);
return;
}
res.json('Saved configuration for plugin ' + plugin.id);
stopPlugin(plugin).then(() => {
const options = getPluginOptions(plugin.id);
plugin.enableLogging = options.enableLogging;
plugin.enableDebug = options.enableDebug;
if (options.enabled) {
doPluginStart(app, plugin, location, options.configuration, restart);
}
});
});
});
router.get('/config', (req, res) => {
res.json(getPluginOptions(plugin.id));
});
if (typeof plugin.registerWithRouter == 'function') {
plugin.registerWithRouter(router);
if (typeof plugin.getOpenApi == 'function') {
app.setPluginOpenApi(plugin.id, plugin.getOpenApi());
}
}
app.use(backwardsCompat('/plugins/' + plugin.id), router);
if (typeof plugin.signalKApiRoutes === 'function') {
app.use('/signalk/v1/api', plugin.signalKApiRoutes(express_1.default.Router()));
}
}
};
const isEnabledByPackageEnableDefault = (options, metadata) => lodash_1.default.isUndefined(options.enabled) &&
metadata['signalk-plugin-enabled-by-default'];