UNPKG

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

254 lines (230 loc) 11.5 kB
'use strict' const create82 = require('./datagrams/0x82') const create85 = require('./datagrams/0x85') const PATHS = { nextPoint: ['navigation.course.nextPoint', 'navigation.courseGreatCircle.nextPoint', 'navigation.courseRhumbline.nextPoint'], activeRoute: ['navigation.course.activeRoute'], distance: ['navigation.course.calcValues.distance', 'navigation.courseGreatCircle.nextPoint.distance', 'navigation.courseRhumbline.nextPoint.distance'], bearingTrue: ['navigation.course.calcValues.bearingTrue', 'navigation.courseGreatCircle.nextPoint.bearingTrue', 'navigation.courseRhumbline.nextPoint.bearingToDestinationTrue'], bearingMagnetic: ['navigation.course.calcValues.bearingMagnetic', 'navigation.courseGreatCircle.nextPoint.bearingMagnetic', 'navigation.courseRhumbline.nextPoint.bearingToDestinationMagnetic'], crossTrackError: ['navigation.course.calcValues.crossTrackError', 'navigation.courseGreatCircle.crossTrackError', 'navigation.courseRhumbline.crossTrackError'], magneticVariation: ['navigation.magneticVariation'] } module.exports = function createWaypointManager(app, emit, options = {}, telemetry) { const encode82 = create82().f const encode85 = create85().f const state = Object.fromEntries(Object.keys(PATHS).map(key => [key, new Map()])) const unsubscribes = [] const now = typeof options.now === 'function' ? options.now : Date.now let timer let targetIdentity let announcedIdentity let resolvedWaypointName let resolvedWaypointQuality = 0 let announcedName let announcedNameQuality = -1 let active = false let navigationEstablished = false let calculationsAvailable = false let lastWaypointAt = 0 let lastNavigationAt = 0 let lastSuppressionReason const config = { updateIntervalMs: numberOption(options.updateIntervalMs, 1000, 100), maximumAgeMs: numberOption(options.maximumAgeMs, 5000, 100), bearingReference: ['true', 'magnetic', 'auto'].includes(options.bearingReference) ? options.bearingReference : 'magnetic', waypointNameFallback: options.waypointNameFallback || 'WP', sendWaypointNameOnClear: options.sendWaypointNameOnClear === true } function start() { for (const [field, paths] of Object.entries(PATHS)) { paths.forEach((path, priority) => { const stream = app.streambundle.getSelfStream(path) unsubscribes.push(stream.onValue(value => update(field, path, priority, value))) }) } timer = setInterval(refreshGuidance, config.updateIntervalMs) if (typeof timer.unref === 'function') timer.unref() } function stop() { if (timer) clearInterval(timer) timer = undefined unsubscribes.splice(0).forEach(unsubscribe => { try { unsubscribe() } catch (error) { app.error(`Waypoint unsubscribe failed: ${error.stack || error}`) } }) } function update(field, path, priority, value) { state[field].set(path, { value, priority, timestamp: now() }) if (field === 'nextPoint' || (field === 'activeRoute' && active)) handleTargetChange() else if (active) publishNavigation(false) } function current(field) { const entries = Array.from(state[field].values()).sort((a, b) => a.priority - b.priority) if (entries[0]?.priority === 0) return entries[0].value == null ? undefined : entries[0] return entries.find(entry => entry.value != null) } function handleTargetChange() { const target = current('nextPoint')?.value const identity = targetIdentityOf(target) if (!identity) { clearTarget() return } const resolved = resolveWaypoint(target, current('activeRoute')?.value, app, config.waypointNameFallback) const name = resolved.name const targetChanged = !active || identity !== targetIdentity active = true targetIdentity = identity resolvedWaypointName = name resolvedWaypointQuality = resolved.quality if (targetChanged) navigationEstablished = false if (targetChanged) telemetry?.record({ type: 'navigation', action: 'target-selected', targetIdentity }) const navigationSent = publishNavigation(targetChanged) if (!navigationSent && (targetChanged || announcedIdentity !== identity || (announcedName !== name && resolved.quality >= announcedNameQuality))) { announceWaypoint(name, resolved.quality, targetChanged ? 'target-change' : 'waypoint-name-change') } } function announceWaypoint(name, quality, reason) { emit('0x82', encode82(name, config.waypointNameFallback), { reason, waypointName: name, targetIdentity }) announcedIdentity = targetIdentity announcedName = name announcedNameQuality = quality lastWaypointAt = now() telemetry?.record({ type: 'navigation', action: reason === 'waypoint-name-change' ? 'waypoint-name-updated' : 'waypoint-announced', targetIdentity, waypointName: name }) updateTelemetry() } function clearTarget() { if (!active) return if (config.sendWaypointNameOnClear) emit('0x82', encode82('', config.waypointNameFallback), { reason: 'target-cleared' }) active = false navigationEstablished = false targetIdentity = undefined announcedIdentity = undefined resolvedWaypointName = undefined resolvedWaypointQuality = 0 announcedName = undefined announcedNameQuality = -1 calculationsAvailable = false lastWaypointAt = 0 lastNavigationAt = 0 lastSuppressionReason = undefined updateTelemetry() telemetry?.record({ type: 'navigation', action: 'target-cleared' }) } function publishNavigation(targetChanged) { if (!active) return false const snapshot = navigationSnapshot() if (!snapshot) return suppressNavigation('navigation-unavailable') const emittedAt = now() if (!navigationEstablished && emittedAt - snapshot.oldest > config.maximumAgeMs) return suppressNavigation('navigation-stale', { ageMs: emittedAt - snapshot.oldest }) if (!targetChanged && lastNavigationAt && emittedAt - lastNavigationAt < config.updateIntervalMs) return false const invalidReason = validateNavigation(snapshot.values) if (invalidReason) return suppressNavigation(invalidReason) calculationsAvailable = true lastSuppressionReason = undefined emit('0x85', encode85(snapshot.values), { reason: targetChanged ? 'target-change' : 'navigation-refresh', values: snapshot.values, targetIdentity }) lastNavigationAt = emittedAt telemetry?.record({ type: 'navigation', action: 'navigation-emitted', targetIdentity }) updateTelemetry(snapshot.values) announceWaypoint(resolvedWaypointName, resolvedWaypointQuality, !navigationEstablished ? 'target-change' : announcedName !== resolvedWaypointName ? 'waypoint-name-change' : 'guidance-refresh') navigationEstablished = true return true } function refreshGuidance() { if (!active) return if (!publishNavigation(false) && now() - lastWaypointAt >= config.updateIntervalMs) { announceWaypoint(resolvedWaypointName, resolvedWaypointQuality, 'guidance-refresh') } } function suppressNavigation(reason, details = {}) { calculationsAvailable = false lastSuppressionReason = reason telemetry?.record({ type: 'navigation', action: 'navigation-suppressed', reason, targetIdentity, ...details }) updateTelemetry() return false } function updateTelemetry(values = {}) { telemetry?.setNavigation({ active, targetIdentity, resolvedWaypointName, announcedIdentity, announcedName, calculationsAvailable, lastWaypointAt, lastNavigationAt, lastSuppressionReason, ...values }) } function navigationSnapshot() { const distance = current('distance') const xte = current('crossTrackError') const trueBearing = current('bearingTrue') const magneticBearing = current('bearingMagnetic') const variation = current('magneticVariation') let bearing let bearingTrue = false if (config.bearingReference === 'true') { bearing = trueBearing bearingTrue = true } else if (config.bearingReference === 'magnetic') { bearing = magneticBearing || (trueBearing && variation ? { value: trueBearing.value - variation.value, timestamp: Math.max(trueBearing.timestamp, variation.timestamp) } : trueBearing) bearingTrue = !magneticBearing && !variation && Boolean(trueBearing) } else { bearing = magneticBearing || trueBearing bearingTrue = !magneticBearing && Boolean(trueBearing) } if (!distance || !xte || !bearing) return undefined const timestamps = [distance, xte, bearing].filter(Boolean).map(entry => entry.timestamp) return { oldest: Math.min(...timestamps), values: { distance: distance?.value, crossTrackError: xte?.value, bearing: bearing?.value, bearingTrue } } } return { start, stop, getState: () => ({ active, targetIdentity, resolvedWaypointName, announcedIdentity, announcedName, calculationsAvailable, lastWaypointAt, lastNavigationAt, lastSuppressionReason }) } } function validateNavigation({ distance, crossTrackError, bearing }) { if (![distance, crossTrackError, bearing].every(Number.isFinite)) return 'navigation-invalid' if (distance < 0 || distance / 1852 * 10 > 0x0fff) return 'distance-out-of-range' if (Math.abs(crossTrackError) / 1852 * 100 > 0x0fff) return 'cross-track-error-out-of-range' return undefined } function targetIdentityOf(target) { if (!target || typeof target !== 'object') return undefined if (typeof target.href === 'string' && target.href.trim()) return `href:${target.href.trim()}` const explicitId = target.ID ?? target.id if (explicitId != null && String(explicitId).trim()) return `id:${String(explicitId).trim()}` const position = target.position || target if (Number.isFinite(position.latitude) && Number.isFinite(position.longitude)) { return `position:${position.latitude.toFixed(7)},${position.longitude.toFixed(7)}` } if (typeof target.name === 'string' && target.name.trim()) return `name:${target.name.trim()}` return undefined } function resolveWaypointName(target, activeRoute, app, fallback) { return resolveWaypoint(target, activeRoute, app, fallback).name } function resolveWaypoint(target, activeRoute, app, fallback) { if (!target) return { name: fallback, quality: 0 } for (const candidate of [target.name, target.ID, target.id]) if (candidate) return { name: candidate, quality: 4 } if (target.href && typeof app.getPath === 'function') { try { const resource = app.getPath(target.href.replace(/^\//, '').replace(/^resources\//, 'resources.').replaceAll('/', '.')) || app.getPath(target.href) if (resource?.name) return { name: resource.name, quality: 3 } } catch (error) { app.debug(`Waypoint resource lookup failed: ${error.message}`) } } const index = activeRoute?.pointIndex const point = Number.isInteger(index) ? activeRoute?.waypoints?.[index] : undefined if (point?.name || point?.ID || point?.id) return { name: point.name || point.ID || point.id, quality: 2 } if (target.href) { const segment = target.href.split('/').filter(Boolean).at(-1) if (segment) return { name: segment, quality: 1 } } return { name: fallback, quality: 0 } } function numberOption(value, fallback, minimum) { return Number.isFinite(value) && value >= minimum ? value : fallback } module.exports.targetIdentityOf = targetIdentityOf module.exports.resolveWaypointName = resolveWaypointName