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
120 lines (105 loc) • 4.63 kB
JavaScript
module.exports = function createLightsManager(app, emitDatagram, encoder, options = {}, telemetry) {
let unsubscribe
let lastLevel
let lastSentAt = 0
const settings = {
source: options.source || 'signalKPath',
signalKPath: options.signalKPath || 'electrical.switches.seatalkDisplayLights.dimmingLevel',
valueFormat: options.valueFormat || 'auto',
configuredLevel: Number.isFinite(options.configuredLevel) ? options.configuredLevel : 3,
sendOnStartup: options.sendOnStartup !== false,
resendOnChange: options.resendOnChange !== false,
minimumIntervalMs: finiteOr(options.minimumIntervalMs, 250),
invert: options.invert === true
}
function start() {
if (settings.source === 'configuration') {
if (settings.sendOnStartup) processValue(settings.configuredLevel, 'lights-startup')
return
}
if (settings.sendOnStartup && typeof app.getSelfPath === 'function') {
const initial = app.getSelfPath(settings.signalKPath)
if (initial !== undefined && initial !== null) processValue(initial, 'lights-startup')
}
if (settings.resendOnChange && app.streambundle?.getSelfStream) {
let stream = app.streambundle.getSelfStream(settings.signalKPath)
if (typeof stream.changes === 'function') stream = stream.changes()
if (typeof stream.debounceImmediate === 'function') stream = stream.debounceImmediate(20)
if (typeof stream.onValue === 'function') unsubscribe = stream.onValue(value => processValue(value, 'lights-change'))
}
}
function stop() {
if (unsubscribe) {
try { unsubscribe() } catch (error) { app.error(`Display-light unsubscribe failed: ${error.stack || error}`) }
}
unsubscribe = undefined
lastLevel = undefined
lastSentAt = 0
}
function processValue(value, reason = 'lights-change') {
try {
let level = brightnessToLevel(value, settings.valueFormat)
if (settings.invert) level = 3 - level
const now = Date.now()
if (level === lastLevel) {
telemetry?.record({ type: 'suppressed', component: 'lights', reason: 'duplicate-light-level', level })
return false
}
if (lastSentAt && now - lastSentAt < settings.minimumIntervalMs) {
telemetry?.record({ type: 'suppressed', component: 'lights', reason: 'light-rate-limit', level })
return false
}
emitDatagram('0x30', encoder.f(level), {
reason,
lightLevel: level,
sourcePath: settings.source === 'signalKPath' ? settings.signalKPath : undefined,
sourceValue: value
})
lastLevel = level
lastSentAt = now
telemetry?.setLights({ level, source: settings.source, path: settings.signalKPath, lastSentAt: now })
return true
} catch (error) {
const message = `Failed to synchronize SeaTalk display lights: ${error.message}`
app.error(error.stack ? `${message}\n${error.stack}` : message)
telemetry?.record({ type: 'error', component: 'lights', message })
if (typeof app.setPluginError === 'function') app.setPluginError(message)
return false
}
}
return { start, stop, processValue, settings }
}
function brightnessToLevel(value, format = 'auto') {
if (value && typeof value === 'object' && Object.hasOwn(value, 'value')) value = value.value
if (!Number.isFinite(value)) throw new TypeError('brightness must be a finite number')
let selected = format
if (selected === 'auto') {
if (Number.isInteger(value) && value >= 0 && value <= 3) selected = 'level'
else if (value >= 0 && value <= 1) selected = 'ratio'
else if (value >= 0 && value <= 100) selected = 'percent'
else throw new RangeError('automatic brightness values must be 0..1, 0..3 integer, or 0..100')
}
if (selected === 'level') {
if (!Number.isInteger(value) || value < 0 || value > 3) throw new RangeError('level brightness must be an integer from 0 to 3')
return value
}
let ratio
if (selected === 'ratio') {
if (value < 0 || value > 1) throw new RangeError('ratio brightness must be between 0 and 1')
ratio = value
} else if (selected === 'percent') {
if (value < 0 || value > 100) throw new RangeError('percent brightness must be between 0 and 100')
ratio = value / 100
} else {
throw new RangeError('valueFormat must be auto, ratio, percent, or level')
}
if (ratio <= 0) return 0
if (ratio <= 1 / 3) return 1
if (ratio <= 2 / 3) return 2
return 3
}
function finiteOr(value, fallback) {
return Number.isFinite(value) ? value : fallback
}
module.exports.brightnessToLevel = brightnessToLevel