signalk-barograph
Version:
SignalK plugin to influx environment data & Barograph to visualize atmospheric pressure
373 lines (349 loc) • 18.4 kB
JavaScript
/*
Copyright © 2025 Inspired Technologies. Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict'
const debug = require("debug")("signalk:signalk-barograph")
const { getSourceId } = require('@signalk/signalk-schema')
const { DateTime } = require('luxon');
const influx = require("./influx")
const barometer = require("./barometer")
const appconfig = require ("./appconfig")
const convert = require("./skunits")
const WAITING = 'waiting ...'
module.exports = function (app) {
var plugin = {};
var influxConfig = {
initialized: false
};
plugin.id = 'signalk-barograph'
plugin.name = 'Barograph powered by SignalK'
plugin.description = 'Plugin to provide data & graphics for aggregated environmental information using influxdb (pressure, temperature, humidity)'
var unsubscribes = []
let timerId
let metrics
let pathConfig = {}
let valueConfig = {}
function source(update) {
if (update['$source']) {
return update['$source']
} else if (update['source']) {
return getSourceId(update['source'])
}
return ''
}
function subscribe(influxDB, result) {
if (influxConfig.initialized && result.status === 'pass' ) {
// ready to subscribe
app.debug({
organization: influxConfig.organization,
buckets: {
write: influxConfig.bucket,
read: influxConfig.read
}
})
//configure paths
if (influxConfig.paths.length===0) {
// Reconfig subscriptions
let interval = 1000
influxConfig.paths = [
{ path: 'environment.outside.temperature', policy: "instant", minPeriod: interval, trend: "temperature" },
{ path: 'environment.outside.pressure', policy: "instant", minPeriod: interval, trend: "pressure" },
{ path: 'environment.outside.humidity', policy: "instant", minPeriod: interval },
{ path: 'environment.outside.relativeHumidity', policy: "instant", minPeriod: interval, config: 'relativeHumidity|>humidity' },
{ path: 'environment.wind.directionTrue', period: 10*interval, policy: "fixed", trend: "winddir", config: "wind.directionTrue|>outside.wind.direction" },
{ path: 'environment.outside.wind.direction', policy :"instant", minPeriod: interval, trend: "winddir" },
{ path: 'environment.outside.wind.speed', policy :"instant", minPeriod: interval },
{ path: 'environment.outside.wind.gust', policy :"instant", minPeriod: interval },
{ path: 'navigation.gnss.antennaAltitude', period: 60*interval, policy: 'fixed', trend:'altitude' },
{ path: 'navigation.position', period: 60*interval, policy: 'fixed', trend: 'position' }
]
var options = app.readPluginOptions();
influx.save(app.getDataDirPath(), options.configuration.pathConfig, influxConfig.paths)
}
if (influxConfig.paths.length>0)
{
influxConfig.paths.forEach(p => {
if (p.hasOwnProperty('config'))
pathConfig[p.path] = influx.reconfig(p.path, p.config)
if (p.hasOwnProperty('convert'))
valueConfig[p.path] = p.convert
if (p.hasOwnProperty('trend')) {
barometer.addSubscriptionHandler(p.trend, p.path)
appconfig.addSubscription(p.trend, p.path)
// hack: server version > 1.39, startup timing issue
let val = app.getSelfPath(p.path)
if (p.trend==='altitude' && val && val.value) {
barometer.onElevationUpdate(val.value)
}
}
});
}
// preload barometer
let preload = barometer.preLoad()
if (preload)
{
sendDelta(preload.update)
if (preload.meta!==null)
sendMeta(preload.meta)
}
app.setPluginStatus('Initialized');
// ready to push data
timerId = setInterval(() => {
app.debug(`Sending ${metrics.length} data points to be uploaded to influx`)
if (metrics.length !== 0) {
influx.post(influxDB, metrics, influxConfig)
influx.buffer(metrics)
metrics = []
}
let updates = barometer.getTrendAndPredictions(2*influxConfig.loadFrequency*1000)
if (updates.length>0)
sendDelta(updates)
}, influxConfig.loadFrequency*1000)
app.debug(`Interval started, upload frequency: ${influxConfig.loadFrequency}s`)
let localSubscription = {
context: 'vessels.self', // Get data only for self context
subscribe: influxConfig.paths
};
app.subscriptionmanager.subscribe (
localSubscription,
unsubscribes,
subscriptionError => {
app.error('Error:' + subscriptionError);
},
delta => {
if (!Array.isArray(delta.updates))
return
delta.updates.forEach(u => {
if (!u.values || u.values[0].path==='' || u.values[0].value===WAITING || u.values[0].value===null ||
(typeof u.values[0].value==="object" && Object.keys(u.values[0].value)===0))
return
const path = pathConfig[u.values[0].path] ? pathConfig[u.values[0].path] : u.values[0].path
const values = !valueConfig[u.values[0].path] ? u.values[0].value :
convert.toTarget(valueConfig[u.values[0].path].split('|>')[0], u.values[0].value, valueConfig[u.values[0].path].split('|>')[1]).value
var timestamp = DateTime.fromISO(u.timestamp).toUTC()
if (path==='environment.forecast.time')
// conversion not required due to dt format change in openweather plugin (v0.5)
influxConfig.currentForecast = DateTime.fromISO(u.values[0].value)
else {
if (path.includes('environment.forecast')) {
let fctime = app.getSelfPath('environment.forecast.time')
if (!fctime && !influxConfig.currentForecast)
; // do nothing
else if (!fctime || fctime.value===null || fctime.value===WAITING)
timestamp = influxConfig.currentForecast.toUTC()
else
timestamp = DateTime.fromISO(fctime.value).toUTC()
}
else if (barometer.isSubscribed(u.values[0].path))
{ // fix: subscription is on the original path
barometer.onDeltaUpdate(u)
}
if (path.includes('environment'))
{
const metric = influx.format(path, values, timestamp, source(u))
if (metric!==null)
metrics.push(metric)
}
}
})
}
);
app.setPluginStatus('Started');
app.debug('Plugin started');
return true
}
else
{
app.setPluginError('Failed to connect to Influx');
return false
}
}
plugin.start = function (options, restartPlugin) {
app.debug('Plugin starting ...');
app.setPluginStatus('Initializing');
metrics = []
influxConfig.cacheDir = app.getDataDirPath()
var configFile = options.pathConfig
if (!options.pathConfig)
{
options.pathConfig = 'pathconfig.json'
let interval = 1000
let hourly = Math.min(60*60*1000, 60*60*interval)
configFile = influx.save(app.getDataDirPath(), options.pathConfig, [
{ path: 'environment.forecast.time', period: 10*interval, policy: "fixed" },
{ path: 'environment.outside.temperature', policy: "instant", minPeriod: interval, trend: "temperature" },
{ path: 'environment.forecast.temperature', period: hourly, policy: "fixed" },
{ path: 'environment.forecast.temperature.minimum', period: hourly, policy: "fixed" },
{ path: 'environment.forecast.temperature.maximum', period: hourly, policy: "fixed" },
{ path: 'environment.forecast.today.temperature.minimum', period: hourly, policy: "fixed" },
{ path: 'environment.forecast.today.temperature.maximum', period: hourly, policy: "fixed" },
{ path: 'environment.forecast.temperature.feelslike', period: hourly, policy: "fixed" },
{ path: 'environment.outside.pressure', policy: "instant", minPeriod: interval, trend: "pressure" },
{ path: 'environment.forecast.pressure', period: hourly, policy: "fixed" },
{ path: 'environment.outside.humidity', policy: "instant", minPeriod: interval },
{ path: 'environment.outside.relativeHumidity', policy: "instant", minPeriod: interval, config: 'relativeHumidity|>humidity' },
{ path: 'environment.forecast.relativeHumidity', period: hourly, policy: "fixed", config: 'relativeHumidity|>humidity' },
{ path: 'environment.forecast.description', period: hourly, policy:"fixed" },
{ path: 'environment.forecast.wind.direction', period: hourly, policy:"fixed" },
{ path: 'environment.forecast.wind.speed', period: hourly, policy:"fixed" },
{ path: 'environment.forecast.wind.gust', period: hourly, policy:"fixed" },
{ path: 'environment.forecast.weather.visibility', period: hourly, policy:"fixed" },
{ path: 'environment.forecast.weather.clouds', period: hourly, policy:"fixed" },
{ path: 'environment.forecast.weather.uvindex', period: hourly, policy:"fixed" },
{ path: 'environment.forecast.weather.icon', period: hourly, policy:"fixed" },
{ path: 'environment.forecast.weather.code', period: hourly, policy:"fixed" },
{ path: 'environment.wind.directionTrue', period: 10*interval, policy: "fixed", trend: "winddir", config: "wind.directionTrue|>outside.wind.direction" },
{ path: 'environment.outside.wind.direction', policy :"instant", minPeriod: interval, trend: "winddir" },
{ path: 'environment.outside.wind.speed', policy :"instant", minPeriod: interval },
{ path: 'environment.outside.wind.gust', policy :"instant", minPeriod: interval },
{ path: 'navigation.gnss.antennaAltitude', period: 60*interval, policy: 'fixed', trend:'altitude' },
{ path: 'navigation.position', period: 60*interval, policy: 'fixed', trend: 'position' }
])
app.debug('No path configuration provided, using default configuration and saving to plugin data directory')
app.savePluginOptions(options, () => {app.debug('Plugin options saved')});
}
try {
influxConfig.paths = require(configFile.includes('/') ? configFile : require('path').join(app.getDataDirPath(), configFile))
} catch {
let paths = influx.config('environment', 10*1000)
influx.config('navigation', 0).forEach(p => paths.push(p))
configFile = influx.save(app.getDataDirPath(), options.pathConfig, paths)
influxConfig.paths = require(configFile.includes('/') ? configFile : require('path').join(app.getDataDirPath(), configFile))
}
influxConfig.organization = (options.influxOrg ? options.influxOrg : '')
influxConfig.bucket = (options.influxBucket ? options.influxBucket : '')
influxConfig.read = (options.influxRead ? options.influxRead : (options.influxBucket ? options.influxBucket : ''))
influxConfig.retention = (options.writeRetention ? options.writeRetention : 3)
influxConfig.id = app.getSelfPath('mmsi') ? app.getSelfPath('mmsi') : app.getSelfPath('uuid')
appconfig.addInflux('id', influxConfig.id)
const influxDB = influx.login({
url: options.influxUri, // get from options
token: options.influxToken, // get from options
timeout: 10 * 1000 // 10sec timeout for health check
}, influxConfig.cacheDir)
appconfig.addInflux('url', options.influxUri+(influxConfig.organization==='' ? '/api/v2/query' : ''))
appconfig.addInflux('token', options.influxToken)
appconfig.addInflux('org', influxConfig.organization)
appconfig.addInflux('write', influxConfig.bucket)
appconfig.addInflux('read', influxConfig.read)
appconfig.addInflux('retention', influxConfig.retention)
appconfig.addInflux('username', options.influxToken.includes(':') ? options.influxToken.split(':')[0] : '') // not relevant for >2.x
appconfig.addInflux('password', options.influxToken.includes(':') ? options.influxToken.split(':')[1] : '') // not relevant for >2.x
let connectionString = []
if (!options.selfRef) {
connectionString.push('http://localhost:3000'); // default local server
connectionString.push(''); // empty username
connectionString.push(''); // empty password
} else if (connectionString.length===1) { // server only
connectionString.push(''); // empty username
connectionString.push(''); // empty password
} else {
connectionString=options.selfRef.split('|')
}
appconfig.init(connectionString[0], connectionString[1], connectionString[2], app.debug)
influxConfig.initialized = influx.health(influxDB, subscribe)
influxConfig.loadFrequency = (options.loadFrequency ? options.loadFrequency : 30)
// TODO: if configured
barometer.init(options["barometer"], influxConfig.loadFrequency)
app.debug('Plugin initialized');
};
plugin.stop = function () {
unsubscribes.forEach(f => f());
unsubscribes = [];
clearInterval(timerId)
app.debug('Interval Timer Stopped')
app.debug('Plugin stopped');
};
plugin.schema = {
type: 'object',
required: [
'influxUri',
'influxToken',
'influxBucket',
'pathConfig',
'loadFrequency'
],
properties: {
"influxUri": {
type: 'string',
title: 'InfluxDB URI'
},
"influxToken": {
type: 'string',
title: 'InfluxDB Token',
description: 'v2.x: [token]; V1.11.x: [username:password]'
},
"influxOrg": {
type: 'string',
title: 'InfluxDB Organisation',
description: 'v2.x: [required]; V1.11.x: [empty]'
},
"influxBucket": {
type: 'string',
title: 'InfluxDB Write Bucket',
description: 'v2.x: [bucket]; v1.11.x: [database/retentionpolicy]'
},
"influxRead": {
type: 'string',
title: 'InfluxDB Read Bucket',
description: 'v2.x: [bucket]; v1.11.x: [database/retentionpolicy]',
},
"selfRef": {
type: 'string',
title: 'Connection String (server|username|password)',
default: 'http://localhost:3000|user|pwd',
description: 'SignalK Server credentials required to provide configuration to embedded web app'
},
"pathConfig": {
type: 'string',
title: 'Paths Configuration',
description: 'paths config file [plugin data directory]',
default: 'barograph.config.json'
},
"loadFrequency": {
type: 'number',
title: 'Write Interval',
description: 'frequency of batched write to InfluxDB in s',
default: 30
},
"writeRetention": {
type: 'number',
title: 'Write Bucket Retention',
description: 'if entered (hours), read bucket will only be used for queries greater than write retention period',
default: 3
},
}
};
/**
*
* @param {Array<[{path:path, value:value}]>} messages
*/
function sendDelta(messages) {
app.handleMessage('signalk-barograph', {
updates: [
{
values: messages
}
]
});
}
function sendMeta(units) {
app.handleMessage('signalk-barograph', {
updates: [
{
meta: units
}
]
})
}
return plugin;
};