signalk-to-stalk
Version:
Signal K server plugin and read-only WebApp converting navigation data to SeaTalk1 with coherent settings, waypoint, units, lights, diagnostics, and calibration advice
426 lines (401 loc) • 21.7 kB
JavaScript
const Bacon = require('baconjs')
const fs = require('fs')
const path = require('path')
const createWaypointManager = require('./waypoint-manager')
const createUnitsManager = require('./units-manager')
const createLightsManager = require('./lights-manager')
const createTelemetry = require('./telemetry')
const createCalibrationManager = require('./calibration-manager')
module.exports = function createPlugin(app) {
const plugin = {
id: 'signalk-to-stalk',
name: 'Convert Signal K to STALK',
description: 'Convert Signal K navigation data to SeaTalk1 datagrams wrapped as STALK NMEA 0183 sentences.',
unsubscribes: [],
waypointManager: undefined,
unitsManager: undefined,
lightsManager: undefined,
calibrationManager: undefined,
telemetry: createTelemetry({ capacity: 1000 }),
schema: {
type: 'object',
title: 'Signal K to SeaTalk1',
description: 'Configure direct Signal K-to-SeaTalk conversions and managed services. Settings are read-only from the WebApp; changes are made here in the Signal K plugin settings.',
additionalProperties: false,
properties: {},
propertyOrder: []
}
}
plugin.registerWithRouter = function registerWithRouter(router) {
router.get('/api/status', (_req, res) => res.json(plugin.telemetry.snapshot()))
router.get('/api/recent', (req, res) => res.json(plugin.telemetry.recent(req.query?.limit)))
router.get('/api/stream', (req, res) => plugin.telemetry.attachSse(req, res))
}
plugin.stop = function stop() {
plugin.unsubscribes.splice(0).forEach(unsubscribe => {
try { unsubscribe() } catch (error) { app.error(`Unsubscribe failed: ${error.stack || error}`) }
})
if (plugin.waypointManager) plugin.waypointManager.stop()
if (plugin.unitsManager) plugin.unitsManager.stop()
if (plugin.lightsManager) plugin.lightsManager.stop()
if (plugin.calibrationManager) plugin.calibrationManager.stop()
plugin.waypointManager = undefined
plugin.unitsManager = undefined
plugin.lightsManager = undefined
plugin.calibrationManager = undefined
plugin.telemetry.stop()
if (typeof app.setPluginStatus === 'function') app.setPluginStatus('Stopped')
}
plugin.start = function start(options = {}) {
plugin.stop()
validateCurrentConfiguration(options, plugin.schema)
plugin.telemetry.setConfiguration(configurationSummary(options, plugin.datagrams))
let active = 0
Object.keys(plugin.datagrams).forEach(name => {
if (plugin.datagrams[name].managed || !options[name]) return
subscribe(plugin.datagrams[name], options[getThrottleProperty(name)])
active += 1
})
if (options.instrumentUnits?.enabled && plugin.datagrams['0x24']) {
plugin.unitsManager = createUnitsManager(app, emitDatagram, plugin.datagrams['0x24'], options.instrumentUnits, plugin.telemetry)
plugin.unitsManager.start()
active += 1
}
if (options.instrumentLights?.enabled && plugin.datagrams['0x30']) {
plugin.lightsManager = createLightsManager(app, emitDatagram, plugin.datagrams['0x30'], options.instrumentLights, plugin.telemetry)
plugin.lightsManager.start()
active += 1
}
if (options.calibrationAdvisor?.enabled) {
plugin.calibrationManager = createCalibrationManager(app, options.calibrationAdvisor, plugin.telemetry)
plugin.calibrationManager.start()
active += 1
}
if (options.navigationToWaypoint?.enabled) {
plugin.waypointManager = createWaypointManager(app, emitDatagram, options.navigationToWaypoint, plugin.telemetry)
plugin.waypointManager.start()
active += 2
}
const status = `Running with ${active} datagram${active === 1 ? '' : 's'} enabled`
plugin.telemetry.setRunning(true, status)
plugin.telemetry.record({ type: 'lifecycle', action: 'start', active })
if (typeof app.setPluginStatus === 'function') app.setPluginStatus(status)
}
function subscribe(encoder, throttleMs) {
const streams = encoder.keys.map((key, index) => {
let stream = app.streambundle.getSelfStream(key)
if (encoder.defaults && encoder.defaults[index] !== undefined) stream = stream.merge(Bacon.once(encoder.defaults[index]))
return stream
})
let stream = Bacon.combineWith((...values) => {
try {
return encoder.f(...values)
} catch (error) {
const message = `Failed to encode ${encoder.datagram}: ${error.message}`
app.error(error.stack ? `${message}\n${error.stack}` : message)
plugin.telemetry.record({ type: 'error', datagram: encoder.datagram, message })
if (typeof app.setPluginError === 'function') app.setPluginError(message)
return undefined
}
}, streams).filter(value => value !== undefined).changes().debounceImmediate(20)
if (Number.isFinite(throttleMs) && throttleMs > 0) stream = stream.throttle(throttleMs)
const unsubscribe = stream.onValue(sentence => emitDatagram(encoder.datagram, sentence))
plugin.unsubscribes.push(unsubscribe)
}
function emitDatagram(datagram, sentence, details = {}) {
plugin.telemetry.record({ type: 'emitted', datagram, sentence: sentence.trimEnd(), bytes: sentenceBytes(sentence), ...details })
app.emit('stalkout', sentence)
app.emit(`stalkout:${datagram.slice(2).toUpperCase()}`, sentence)
app.debug(sentence.trimEnd())
}
plugin.datagrams = loadDatagrams(app, plugin)
buildSchema(plugin)
return plugin
}
function loadDatagrams(app, plugin) {
const directory = path.join(__dirname, 'datagrams')
return fs.readdirSync(directory).filter(name => /^0x[0-9a-f]+\.js$/i.test(name)).sort().reduce((result, filename) => {
const key = path.basename(filename, '.js')
result[key] = require(path.join(directory, filename))(app, plugin)
return result
}, {})
}
function buildSchema(plugin) {
const properties = plugin.schema.properties
Object.keys(plugin.datagrams).forEach(key => {
const encoder = plugin.datagrams[key]
if (encoder.managed) return
const paths = Array.isArray(encoder.keys) ? encoder.keys.join(', ') : 'the required Signal K paths'
properties[key] = {
title: `${encoder.title} (${key})`,
description: `Enable conversion from ${paths}. Output is emitted as a $STALK sentence on the stalkout event.`,
type: 'boolean',
default: false
}
properties[getThrottleProperty(key)] = {
title: `${encoder.title}: minimum interval`,
description: `Minimum interval between emitted ${key} datagrams, in milliseconds. Set to 0 to emit every accepted value change.`,
type: 'integer',
minimum: 0,
default: 0
}
plugin.schema.propertyOrder.push(key, getThrottleProperty(key))
})
properties.navigationToWaypoint = {
type: 'object',
title: 'Waypoint guidance (0x82 / 0x85)',
description: 'Broadcast passive navigation guidance for the active Signal K target. This does not engage or control the autopilot.',
additionalProperties: false,
default: {},
properties: {
enabled: {
type: 'boolean', default: false,
title: 'Enable target waypoint guidance',
description: 'Send 0x85 navigation data and announce target changes with 0x82.'
},
updateIntervalMs: {
type: 'integer', minimum: 100, default: 1000,
title: 'Navigation refresh interval',
description: 'How often to repeat valid 0x85 navigation data while a target is active, in milliseconds.'
},
maximumAgeMs: {
type: 'integer', minimum: 100, default: 5000,
title: 'Maximum navigation-data age',
description: 'Suppress navigation output when required Signal K values are older than this limit, in milliseconds.'
},
bearingReference: {
type: 'string', enum: ['magnetic', 'true', 'auto'], default: 'magnetic',
enumNames: ['Magnetic', 'True', 'Automatic'],
title: 'Bearing reference',
description: 'Automatic prefers magnetic bearing and falls back to true bearing.'
},
waypointNameFallback: {
type: 'string', minLength: 1, maxLength: 32, default: 'WP',
title: 'Fallback waypoint name',
description: 'Used when Signal K provides no target name. SeaTalk transmits the final four representable characters.'
},
sendInvalidOnClear: {
type: 'boolean', enum: [false], default: false,
title: 'Send invalid navigation on clear (retired)',
description: 'Must remain disabled. SeaTalk 0x85 has no safe passive invalid-data mode; older releases could generate instrument data errors.'
},
sendWaypointNameOnClear: {
type: 'boolean', default: false,
title: 'Also send fallback name when target is cleared',
description: 'Normally disabled because older instruments may treat a name-only update as a new target.'
}
}
}
properties.instrumentUnits = {
type: 'object',
additionalProperties: false,
title: 'Display units (0x24)',
description: 'Best-effort network-wide synchronization of the SeaTalk speed/log unit system. Signal K values remain in SI units.',
default: {},
properties: {
enabled: {
type: 'boolean', default: false,
title: 'Enable display-unit synchronization'
},
source: {
type: 'string', enum: ['signalKPreferences', 'configuration'], default: 'signalKPreferences',
enumNames: ['Signal K unit preferences', 'Fixed plugin setting'],
title: 'Unit preference source'
},
speedAndDistance: {
type: 'string', enum: ['nautical', 'statute', 'metric'], default: 'nautical',
enumNames: ['Nautical miles and knots', 'Statute miles and mph', 'Kilometres and km/h'],
title: 'SeaTalk unit system',
description: 'Used only when the source is Fixed plugin setting. Signal K preferences are resolved automatically when that source is selected.'
},
sendOnStartup: {
type: 'boolean', default: true,
title: 'Send current units at startup'
},
resendOnChange: {
type: 'boolean', default: true,
title: 'Detect and send preference changes',
description: 'Poll Signal K preferences and emit 0x24 only when the resolved system changes.'
},
pollIntervalMs: {
type: 'integer', minimum: 1000, default: 5000,
title: 'Preference polling interval',
description: 'How often Signal K unit preferences are checked, in milliseconds. Used only for the Signal K preferences source.'
},
periodicRefreshSeconds: {
type: 'integer', minimum: 0, default: 0,
title: 'Periodic unit refresh',
description: 'Repeat the resolved 0x24 setting after this many seconds. Set to 0 to disable periodic refresh.'
}
}
}
properties.calibrationAdvisor = {
type: 'object',
title: 'Speed and heading calibration advisor',
description: 'Read-only estimates for instrument calibration. Speed uses water speed versus GPS speed; heading uses magnetic heading versus variation-corrected GPS course. Validate in slack water and with reciprocal runs.',
additionalProperties: false,
default: {},
properties: {
enabled: { type: 'boolean', default: false, title: 'Enable calibration advisor' },
measuredPath: { type: 'string', minLength: 1, default: 'navigation.speedThroughWater', title: 'Measured speed path' },
referencePath: { type: 'string', minLength: 1, default: 'navigation.speedOverGround', title: 'Reference speed path' },
currentCalibrationFactor: { type: 'number', exclusiveMinimum: 0, default: 1, title: 'Current ST60 calibration factor', description: 'Enter the factor currently shown by the instrument. The advisor multiplies it by the observed correction ratio.' },
minimumSpeedMps: { type: 'number', minimum: 0.5, default: 1.5, title: 'Minimum accepted speed', description: 'Samples below this speed are ignored. Value is metres per second (1.5 m/s is about 2.9 kn).' },
minimumSamples: { type: 'integer', minimum: 10, default: 30, title: 'Minimum samples for a suggestion' },
windowSize: { type: 'integer', minimum: 20, maximum: 1000, default: 120, title: 'Rolling sample-window size' },
maximumRelativeSpread: { type: 'number', minimum: 0.01, maximum: 0.5, default: 0.08, title: 'Maximum relative spread', description: 'Lower values require more stable observations before marking the suggestion ready.' },
headingEnabled: { type: 'boolean', default: true, title: 'Enable heading calibration suggestion' },
headingMeasuredPath: { type: 'string', minLength: 1, default: 'navigation.headingMagnetic', title: 'Measured magnetic heading path' },
headingReferencePath: { type: 'string', minLength: 1, default: 'navigation.courseOverGroundTrue', title: 'GPS course reference path' },
headingVariationPath: { type: 'string', minLength: 1, default: 'navigation.magneticVariation', title: 'Magnetic variation path' },
headingSpeedPath: { type: 'string', minLength: 1, default: 'navigation.speedOverGround', title: 'Heading-advisor speed path' },
currentHeadingOffsetDegrees: { type: 'number', minimum: -180, maximum: 180, default: 0, title: 'Current heading alignment offset (degrees)' },
headingMinimumSpeedMps: { type: 'number', minimum: 0.5, default: 2, title: 'Minimum speed for heading samples (m/s)' },
headingMinimumSamples: { type: 'integer', minimum: 10, default: 30, title: 'Minimum heading samples' },
headingWindowSize: { type: 'integer', minimum: 20, maximum: 1000, default: 120, title: 'Heading sample-window size' },
headingMaximumSpreadDegrees: { type: 'number', minimum: 0.5, maximum: 45, default: 5, title: 'Maximum heading spread (degrees)' }
}
}
properties.instrumentLights = {
type: 'object',
title: 'Display lighting (0x30)',
description: 'Synchronize all compatible SeaTalk display lamps to one of four broadcast levels, L0 through L3.',
additionalProperties: false,
default: {},
properties: {
enabled: {
type: 'boolean', default: false,
title: 'Enable display-light synchronization'
},
source: {
type: 'string', enum: ['signalKPath', 'configuration'], default: 'signalKPath',
enumNames: ['Signal K path', 'Fixed plugin setting'],
title: 'Brightness source'
},
signalKPath: {
type: 'string', minLength: 1,
default: 'electrical.switches.seatalkDisplayLights.dimmingLevel',
title: 'Signal K brightness path',
description: 'Used only when the brightness source is Signal K path.'
},
valueFormat: {
type: 'string', enum: ['auto', 'ratio', 'percent', 'level'], default: 'auto',
enumNames: ['Automatic', 'Ratio (0–1)', 'Percentage (0–100)', 'SeaTalk level (0–3)'],
title: 'Source value format',
description: 'Used only for Signal K path values.'
},
configuredLevel: {
type: 'integer', minimum: 0, maximum: 3, default: 3,
title: 'Fixed SeaTalk lamp level',
description: 'Used only when the brightness source is Fixed plugin setting.'
},
sendOnStartup: {
type: 'boolean', default: true,
title: 'Send current light level at startup'
},
resendOnChange: {
type: 'boolean', default: true,
title: 'Send changes from the Signal K path',
description: 'Has no effect when using a fixed plugin setting.'
},
minimumIntervalMs: {
type: 'integer', minimum: 0, default: 250,
title: 'Minimum lighting-command interval',
description: 'Minimum time between 0x30 broadcasts, in milliseconds. Duplicate levels are always suppressed.'
},
invert: {
type: 'boolean', default: false,
title: 'Invert brightness mapping',
description: 'Maps the lowest source value to L3 and the highest to L0.'
}
}
}
plugin.schema.propertyOrder.push('navigationToWaypoint', 'instrumentUnits', 'instrumentLights', 'calibrationAdvisor')
}
function sentenceBytes(sentence) {
const body = String(sentence).trim().split('*')[0]
return body.split(',').slice(1).map(value => value.toUpperCase())
}
const getThrottleProperty = key => `${key}_throttle`
function configurationSummary(options, datagrams) {
const direct = Object.keys(datagrams).filter(key => !datagrams[key].managed).map(key => ({
datagram: key,
title: datagrams[key].title,
enabled: options[key] === true,
minimumIntervalMs: Number.isInteger(options[getThrottleProperty(key)]) ? options[getThrottleProperty(key)] : 0
}))
return {
direct,
navigationToWaypoint: {
enabled: options.navigationToWaypoint?.enabled === true,
updateIntervalMs: options.navigationToWaypoint?.updateIntervalMs ?? 1000,
maximumAgeMs: options.navigationToWaypoint?.maximumAgeMs ?? 5000,
bearingReference: options.navigationToWaypoint?.bearingReference ?? 'magnetic',
sendInvalidOnClear: false,
sendWaypointNameOnClear: options.navigationToWaypoint?.sendWaypointNameOnClear === true
},
instrumentUnits: {
enabled: options.instrumentUnits?.enabled === true,
source: options.instrumentUnits?.source ?? 'signalKPreferences',
speedAndDistance: options.instrumentUnits?.speedAndDistance ?? 'nautical',
sendOnStartup: options.instrumentUnits?.sendOnStartup !== false,
resendOnChange: options.instrumentUnits?.resendOnChange !== false,
pollIntervalMs: options.instrumentUnits?.pollIntervalMs ?? 5000,
periodicRefreshSeconds: options.instrumentUnits?.periodicRefreshSeconds ?? 0
},
calibrationAdvisor: {
enabled: options.calibrationAdvisor?.enabled === true,
measuredPath: options.calibrationAdvisor?.measuredPath ?? 'navigation.speedThroughWater',
referencePath: options.calibrationAdvisor?.referencePath ?? 'navigation.speedOverGround',
currentCalibrationFactor: options.calibrationAdvisor?.currentCalibrationFactor ?? 1,
minimumSpeedMps: options.calibrationAdvisor?.minimumSpeedMps ?? 1.5,
minimumSamples: options.calibrationAdvisor?.minimumSamples ?? 30,
windowSize: options.calibrationAdvisor?.windowSize ?? 120,
maximumRelativeSpread: options.calibrationAdvisor?.maximumRelativeSpread ?? 0.08,
headingEnabled: options.calibrationAdvisor?.headingEnabled !== false,
headingMeasuredPath: options.calibrationAdvisor?.headingMeasuredPath ?? 'navigation.headingMagnetic',
headingReferencePath: options.calibrationAdvisor?.headingReferencePath ?? 'navigation.courseOverGroundTrue',
headingVariationPath: options.calibrationAdvisor?.headingVariationPath ?? 'navigation.magneticVariation',
headingSpeedPath: options.calibrationAdvisor?.headingSpeedPath ?? 'navigation.speedOverGround',
currentHeadingOffsetDegrees: options.calibrationAdvisor?.currentHeadingOffsetDegrees ?? 0,
headingMinimumSpeedMps: options.calibrationAdvisor?.headingMinimumSpeedMps ?? 2,
headingMinimumSamples: options.calibrationAdvisor?.headingMinimumSamples ?? 30,
headingWindowSize: options.calibrationAdvisor?.headingWindowSize ?? 120,
headingMaximumSpreadDegrees: options.calibrationAdvisor?.headingMaximumSpreadDegrees ?? 5
},
instrumentLights: {
enabled: options.instrumentLights?.enabled === true,
source: options.instrumentLights?.source ?? 'signalKPath',
signalKPath: options.instrumentLights?.signalKPath ?? 'electrical.switches.seatalkDisplayLights.dimmingLevel',
valueFormat: options.instrumentLights?.valueFormat ?? 'auto',
configuredLevel: options.instrumentLights?.configuredLevel ?? 3,
sendOnStartup: options.instrumentLights?.sendOnStartup !== false,
resendOnChange: options.instrumentLights?.resendOnChange !== false,
minimumIntervalMs: options.instrumentLights?.minimumIntervalMs ?? 250,
invert: options.instrumentLights?.invert === true
}
}
}
function validateCurrentConfiguration(options, schema) {
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Plugin configuration must be an object')
const allowed = new Set(Object.keys(schema.properties))
const unknown = Object.keys(options).filter(key => !allowed.has(key))
if (unknown.length) throw new Error(`Unsupported configuration properties: ${unknown.join(', ')}`)
for (const section of ['navigationToWaypoint', 'instrumentUnits', 'calibrationAdvisor', 'instrumentLights']) {
const value = options[section]
if (value === undefined) continue
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${section} must be an object`)
const sectionAllowed = new Set(Object.keys(schema.properties[section].properties))
const sectionUnknown = Object.keys(value).filter(key => !sectionAllowed.has(key))
if (sectionUnknown.length) throw new Error(`Unsupported ${section} properties: ${sectionUnknown.join(', ')}`)
}
if (options.instrumentUnits?.source === 'configuration' && options.instrumentUnits?.speedAndDistance === undefined) {
throw new Error('instrumentUnits.speedAndDistance is required when source is configuration')
}
if (options.navigationToWaypoint?.sendInvalidOnClear === true) {
throw new Error('navigationToWaypoint.sendInvalidOnClear must be false because partial 0x85 frames cause SeaTalk data errors')
}
}
module.exports.configurationSummary = configurationSummary
module.exports.validateCurrentConfiguration = validateCurrentConfiguration