UNPKG

signalk-server

Version:

An implementation of a [Signal K](http://signalk.org) server for boats.

532 lines (531 loc) 21.9 kB
"use strict"; 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'];