UNPKG

signalk-dashboard

Version:
566 lines (549 loc) 23.8 kB
/* Copyright © 2025 Inspired Technologies GmbH (www.inspiredtechnologies.eu) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. */ const { DateTime } = require('luxon'); const { getSourceId } = require('@signalk/signalk-schema') const path = require('path') const influx = require('./influx'); const convert = require("./skunits") const grafana = require('./grafana'); const NAVSTATE = 'navigation.state' const SUNRISE = 'environment.sunlight.times.sunrise' const SUNSET = 'environment.sunlight.times.sunset' let initialized = false; let kiosk = false; let overwriteTimeWithNow = false; let currentState let metrics = [] let descriptions = [] let unsubscribes = [ influx.flush ] influx.buffer(metrics) let config = [ { metric: "label.vessel.description.name", path: "name" }, { metric: "label.vessel.description.mmsi", path: "mmsi" }, { metric: "label.vessel.description.callsign", path: "communication", key: "callsignVhf" } ] let hosts = [ { metric: "label.host.description.signalk", path: "signalk" }, { metric: "label.host.description.influx", path: "influx" } ] let buckets = [ { metric: "label.bucket.description.signalk", path: "signalk" }, { metric: "label.bucket.description.telegraf", path: "telegraf" } ] let influxConfig = {} let screenConfig = {} let updates = {} let timer function updateVal(path, time, interval) { return updates[path] && updates[path]!==null ? (time-updates[path])>interval : true; } function source(update) { if (update['$source']) { return update['$source'] } else if (update['source']) { return getSourceId(update['source']) } return '' } function addnewpath (path, dynamicpath, log) { if (influxConfig.pathConfig[dynamicpath] || influxConfig.valueConfig[dynamicpath]) { let item = path.replace(dynamicpath.split('*')[0], '').replace(dynamicpath.split('*')[1], '') if (influxConfig.pathConfig[dynamicpath]) influxConfig.pathConfig[path] = influxConfig.pathConfig[dynamicpath].replace('*', item) if (influxConfig.valueConfig[dynamicpath]) influxConfig.valueConfig[path] = influxConfig.valueConfig[dynamicpath].replace('*', item) log(`New '${item}' for path '${dynamicpath}' identified and subscribed!`) } } module.exports = (app) => { const plugin = { id: 'signalk-dashboard', name: 'SignalK to Grafana Dashboard', start: (settings, restartPlugin) => { // startup -> update InfluxDB debug("Plugin starting"); app.setPluginStatus("starting"); // init Host & Bucket Metrics from settings hosts.forEach(h => { const metric = influx.format(h.metric, settings.hosts[h.path], DateTime.utc(), '') if (metric!==null) metrics.push(metric) }) buckets.forEach(h => { const metric = influx.format(h.metric, settings.influx[h.path], DateTime.utc(), '') if (metric!==null) metrics.push(metric) }) settings.descriptions.forEach(d => { let path = `label.${d.label}.description.${d.measurement}` let metric = influx.format(`${path}`, d.field, DateTime.utc(), '') if (metric!==null) metrics.push(metric) path = `type.${d.measurement}.label.${d.label}.description` metric = influx.format(`${path}.min`, d.min, DateTime.utc(), '') if (typeof d.min==='number' && metric!==null) metrics.push(metric) metric = influx.format(`${path}.norm`, d.norm, DateTime.utc(), '') if (typeof d.norm==='number' && metric!==null) metrics.push(metric) metric = influx.format(`${path}.max`, d.max, DateTime.utc(), '') if (typeof d.max==='number' && metric!==null) metrics.push(metric) }) // init Influx connection influxConfig = { id: app.getSelfPath('mmsi') ? app.getSelfPath('mmsi') : app.getSelfPath('uuid'), organization: settings.influx.org, bucket: settings.influx.signalk, cacheDir: app.getDataDirPath(), frequency: settings.upload.frequency, retention: settings.influx.retention } // init Screen configuration screenConfig = { brightness: settings.grafana && settings.grafana.screen && settings.grafana.screen.brightness ? 0 : undefined, daytime: settings.grafana && settings.grafana.screen && settings.grafana.screen.daytime || 225, nighttime: settings.grafana && settings.grafana.screen && settings.grafana.screen.nighttime || 15, } const influxDB = influx.login({ url: settings.influx.uri, // get from options token: settings.influx.token, // get from options timeout: 10 * 1000, // 10sec timeout for health check }, influxConfig.cacheDir, debug) initialized = influx.health(influxDB, debug, (influxDB, result) => { if (initialized && result.status === 'pass' ) { app.debug({ influx: settings.influx.uri, organization: settings.influx.org, buckets: { signalk: settings.influx.signalk, telegraf: settings.influx.telegraf } }) app.setPluginStatus('Initialized'); app.debug('Plugin initialized - sending metrics'); config.forEach(c => { let value = !c.hasOwnProperty("key") ? app.getSelfPath(c.path) : app.getSelfPath(c.path)[c.key] if (value===undefined || value===null) { app.debug(`No value for ${c.path} found, skipping config metric`) return } const metric = influx.format(c.metric, value, DateTime.utc(), '') if (metric!==null) metrics.push(metric) }) timer = setInterval( (debug) => { app.debug (`Uploading ${metrics.length} data points to Influx`) if (metrics.length !== 0) { influx.post(influxDB, metrics, influxConfig, debug) metrics.forEach(m => descriptions.push(m)) } updates["descriptions"] = DateTime.utc().toMillis() metrics = [] clearInterval(timer); // Restart time for uploading to influx timer = setInterval(() => { app.debug (`Sending ${metrics.length} data points to be uploaded to influx`) // write to influx if (metrics.length !== 0) { influx.post(influxDB, metrics, influxConfig, app.debug) influx.buffer(metrics) metrics = [] } // trigger board switch let state = app.getSelfPath(NAVSTATE) if (state && currentState && currentState !== state.value) { let current = currentState grafana.next(state.value, () => { app.debug(`Switched board from ${current} to ${state.value}`) }) currentState = state.value } let sunrise = app.getSelfPath(SUNRISE) let sunset = app.getSelfPath(SUNSET) // change screen configuration if (screenConfig.hasOwnProperty('brightness') && screenConfig.brightness>=0 && screenConfig.brightness<=255) { const now = DateTime.utc().toMillis() const value = !sunrise && !sunset ? screenConfig.daytime : sunset && now > DateTime.fromISO(sunset.value).toMillis() ? screenConfig.nighttime : sunrise && now > DateTime.fromISO(sunrise.value).toMillis() ? screenConfig.daytime : 128 if (value!==screenConfig.brightness) { grafana.set('brightness', value, (val) => { app.debug(`Screen brightness set to ${val}`) screenConfig.brightness = val || 0 }) } } }, influxConfig.frequency*1000) app.debug (`Interval started, upload frequency: ${influxConfig.frequency}s`) // Get data only for self context app.subscriptionmanager.subscribe ( { context: 'vessels.self', subscribe: influxConfig.paths }, unsubscribes, subscriptionError => { app.error('Error:' + subscriptionError); }, delta => { if (!delta.updates) return; delta.updates.forEach(u => { // ignore undefined or null value if (!u.values || !u.values[0].path || u.values[0].value===null || (typeof u.values[0].value==="object" && Object.keys(u.values[0].value)===0)) { return; } // copy new paths configs if (!influxConfig.pathConfig[u.values[0].path]) { let dynpath = influx.match(influxConfig.paths.map(p => p.path), u.values[0].path) addnewpath(u.values[0].path, dynpath, app.debug) } // read delta const path = influxConfig.pathConfig[u.values[0].path] ? influxConfig.pathConfig[u.values[0].path] : u.values[0].path const values = !influxConfig.valueConfig[u.values[0].path] ? u.values[0].value : convert.toTarget(influxConfig.valueConfig[u.values[0].path].split('|>')[0], u.values[0].value, influxConfig.valueConfig[u.values[0].path].split('|>')[1]).value let timestamp = overwriteTimeWithNow ? DateTime.utc() : DateTime.fromISO(u.timestamp).toUTC() // decide on descriptions to be updated if (initialized && descriptions.length>0 && updateVal("descriptions", timestamp.toMillis(), influxConfig.retention*3600*1000-2*influxConfig.frequency*1000)) { descriptions.forEach(d => metrics.push(d)) updates["descriptions"] = DateTime.utc().toMillis() } // decide on metrics to be recorded if (initialized && values!==null && updateVal(path, timestamp.toMillis(), 1000) && influxConfig.pathConfig[path]!=='ignore') { updates[path] = timestamp.toMillis() if (influxConfig.valueConfig[u.values[0].path] && influxConfig.valueConfig[u.values[0].path].split('|>')[1]==='latLng') { const metric = influx.format(path, JSON.stringify(values), timestamp, source(u)) const latitude = influx.format(path+'.lat', values[0], timestamp, source(u)) const longitude = influx.format(path+'.lon', values[1], timestamp, source(u)) if (metric!==null && latitude!==null && longitude!==null) { metrics.push(metric) metrics.push(latitude) metrics.push(longitude) } } else { const metric = influx.format(path, values, timestamp, source(u)) if (metric!==null) metrics.push(metric) } } }) } ); // launch grafana dashboard if (kiosk) { grafana.check("launching grafana dashboard...", (status) => { grafana.launch(status, (pid) => { currentState = 'default' app.debug(`Grafana dashboard launched with status: ${pid}`) }) }) } // set plugin status to started app.setPluginStatus('Started'); app.debug('Plugin started'); }, 5000, debug); return true } else { app.setPluginError('Failed to connect to Influx'); return false } }) kiosk = grafana.init(settings.grafana, debug, app.setPluginStatus, app.setPluginError) && settings.grafana.autostart // init path configuration influxConfig.pathConfig = {} influxConfig.valueConfig = {} if (!settings.upload.pathConfig || !influx.check(app.getDataDirPath(), settings.upload.pathConfig)) { settings.upload.pathConfig = 'pathconfig.json' configFile = influx.save(app.getDataDirPath(), settings.upload.pathConfig, []) app.savePluginOptions(settings, () => {app.debug('Plugin options saved')}); } influxConfig.paths = influx.config( settings.upload.pathConfig.includes('/') ? settings.upload.pathConfig : path.join(app.getDataDirPath(), settings.upload.pathConfig), settings.upload.frequency) influxConfig.paths.forEach(p => { if (p.hasOwnProperty('config')) influxConfig.pathConfig[p.path] = influx.reconfig(p.path, p.config) if (p.hasOwnProperty('convert')) influxConfig.valueConfig[p.path] = p.convert }) }, stop: () => { unsubscribes.forEach(f => f()); unsubscribes = []; // shutdown debug("stopping"); timer && clearInterval(timer); debug("stopped"); }, schema: { type: 'object', title: 'Export to InfluxDB & Grafana', description: "Provide configuration information", properties: { hosts: { type: "object", title: "Host Configuration", required: ['signalk', 'influx'], properties: { signalk: { type: 'string', title: 'SignalK Server', description: 'Hostname as captured by Telegraf' }, influx: { type: 'string', title: 'InfluxDB Server', description: 'Hostname as captured by Telegraf' } }, }, upload: { type: "object", title: "Upload Configuration", required: ['pathConfig', 'frequency'], properties: { pathConfig: { type: 'string', title: 'Path Configuration', description: 'config file in plugin data directory', default: 'pathconfig.json' }, frequency: { type: 'number', title: 'Write Interval', description: 'frequency of batched write to InfluxDB in s', default: 30 }, }, }, influx: { type: "object", title: "InfluxDB Connection", required: ['uri', 'token', 'org', 'signalk', 'telegraf'], properties: { uri: { type: 'string', title: 'InfluxDB URI' }, token: { type: 'string', title: 'InfluxDB Token', description: 'v2.x: [token]; V1.11.x: [username:password]' }, org: { type: 'string', title: 'InfluxDB Organisation', description: 'v2.x: [required]; V1.11.x: [empty]' }, signalk: { type: 'string', title: 'SignalK Metrics Bucket', description: 'v2.x: [bucket]; v1.11.x: [database/retentionpolicy]', default: 'signalk_metrics' }, telegraf: { type: 'string', title: 'Telegraf Metrics Bucket', description: 'v2.x: [bucket]; v1.11.x: [database/retentionpolicy]', default: 'infra_metrics' }, retention: { type: 'number', title: 'SignalK Bucket Retention', description: 'Configured retention period in hours', default: 24 } }, }, grafana: { type: "object", title: "Grafana Configuration", required: ['autostart'], properties: { autostart: { type: 'boolean', title: 'Grafana Kiosk', description: 'launch grafana kiosk with plugin start', default: false }, launch: { type: "object", title: "Launch Configuration", properties: { command: { type: 'string', title: 'Launch command', description: 'base command: chromium-browser or grafana-kiosk', enum: ["chromium-browser", "grafana-kiosk"], default: '' }, remote: { type: 'string', title: 'Remote command', description: 'remote execution, pwd-free login required', default: '' }, params: { type: 'string', title: 'Configuration params', description: 'command params resp. full path to config-file', default: '' }, } }, dashboard: { type: "object", title: "Dashboard Configuration", required: ['idle'], properties: { server: { type: 'string', title: 'Grafana Server', description: 'URI of the Grafana-server', default: '' }, idle: { type: 'string', title: 'Idle dashboard', description: 'url id of the default dashboard, eg. signalk-dashboard/idle', default: '' }, params: { type: 'string', title: 'Dashboard params', description: 'configuration params on dashboard launch', default: '' }, } }, screen: { type: "object", title: "Screen Configuration", properties: { brightness: { type: 'boolean', title: 'Adapt screen brightness', description: 'change brightness according to time of day', default: false }, daytime: { type: 'number', title: 'Daytime brightness', description: 'value between 0 and 255', default: 225 }, nighttime: { type: 'number', title: 'Nighttime brightness', description: 'value between 0 and 255', default: 15 }, } }, boards: { type: 'array', default: [], title: 'Switchboards according to navigation state', items: { type: 'object', required: ['state', 'uid'], properties: { state: { type: 'string', title: 'State', description: 'according to @meri-imperiumi/signalk-autostate plugin', enum: ["moored", "sailing", "motoring", "anchored"], default: '' }, uid: { type: 'string', title: 'Dashboard ID', description: 'url id of the dashboard, eg. signalk-dashboard/moored', default: '' }, params: { type: 'string', title: 'Dashboard params', description: 'configuration params for dashboard launch', default: '' } } } } }, }, descriptions: { type: 'array', default: [], title: 'Meta data for measurements', items: { type: 'object', required: ['measurement', 'label', 'field'], properties: { measurement: { title: 'Measurement', type: 'string', enum: [ 'engines', 'sails', 'batteries', 'tanks', 'solars', 'anchors' ] }, label: { title: 'Label', type: 'string', }, field: { title: 'Field', type: 'string', default: 'value' }, min: { title: 'Minimum Value', type: 'number', default: 0 }, norm: { title: 'Target Value', type: 'number', }, max: { title: 'Max Value', type: 'number', }, } } }, } } }; const debug = require("debug")(plugin.id); return plugin; };