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

202 lines (184 loc) 15.4 kB
'use strict' const $ = id => document.getElementById(id) const rows = $('events') const apiBase = '/plugins/signalk-to-stalk/api/' let rowCount = 0 let paused = false let filter = 'all' let refreshTimer function finite(value) { return Number.isFinite(value) } function fmt(value, digits = 1) { return finite(value) ? Number(value).toFixed(digits) : '—' } function enabledLabel(value) { return value ? 'Enabled' : 'Disabled' } function time(value) { return value ? new Date(value).toLocaleString() : '—' } function durationMs(value) { return Number.isFinite(value) ? `${value.toLocaleString()} ms` : '—' } function durationSeconds(value) { return Number.isFinite(value) ? (value === 0 ? 'Disabled' : `${value.toLocaleString()} s`) : '—' } function escapeHtml(value) { const node = document.createElement('div'); node.textContent = value == null ? '' : String(value); return node.innerHTML } function commandLabel(value) { return value ? String(value).toUpperCase() : '—' } function title(value) { return value ? String(value).replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase()) : '—' } function setPill(id, text, tone = '') { const node = $(id); node.textContent = text; node.className = `pill ${tone}`.trim() } function safeJson(value) { try { return JSON.parse(value) } catch (_) { return undefined } } function render(snapshot) { const config = snapshot.configuration || {} const navConfig = config.navigationToWaypoint || {} const unitConfig = config.instrumentUnits || {} const lightConfig = config.instrumentLights || {} const calConfig = config.calibrationAdvisor || {} const nav = snapshot.navigation || {} const units = snapshot.units || {} const lights = snapshot.lights || {} const calibration = snapshot.calibration || {} const headingCalibration = snapshot.headingCalibration || {} setPill('state', snapshot.running ? 'Plugin running' : 'Plugin stopped', snapshot.running ? 'ok' : 'bad') $('emitted').textContent = snapshot.totals?.emitted || 0 $('suppressed').textContent = snapshot.totals?.suppressed || 0 $('errors').textContent = snapshot.totals?.errors || 0 const target = nav.targetIdentity?.replace(/^\w+:/, '') || '—' $('target').textContent = target $('navtarget').textContent = target $('navdetail').textContent = navConfig.enabled ? (nav.active ? 'Active target' : 'Waiting for target') : 'Guidance disabled' const navState = !navConfig.enabled ? 'Disabled' : nav.active ? 'Active target' : 'Enabled, waiting for target' $('navstatus').textContent = navState setPill('navpill', !navConfig.enabled ? 'Disabled' : nav.active ? 'Active' : 'Waiting', !navConfig.enabled ? '' : nav.active ? 'ok' : 'warn') $('bearing').textContent = finite(nav.bearing) ? `${fmt(nav.bearing * 180 / Math.PI)}°` : '—' $('bearingref').textContent = nav.bearingTrue === true ? 'True' : nav.bearingTrue === false && finite(nav.bearing) ? 'Magnetic' : title(navConfig.bearingReference) $('distance').textContent = finite(nav.distance) ? `${fmt(nav.distance)} m` : '—' $('xte').textContent = finite(nav.crossTrackError) ? `${fmt(nav.crossTrackError)} m` : '—' $('navrefresh').textContent = navConfig.enabled ? durationMs(navConfig.updateIntervalMs) : '—' $('navmaxage').textContent = navConfig.enabled ? durationMs(navConfig.maximumAgeMs) : '—' $('needle').style.transform = `rotate(${finite(nav.bearing) ? nav.bearing * 180 / Math.PI : 0}deg)` $('compass').classList.toggle('inactive', !nav.active) $('units').textContent = units.system ? title(units.system) : (unitConfig.enabled ? 'Waiting' : '—') $('unitsdetail').textContent = unitConfig.enabled ? `${unitConfig.source === 'configuration' ? `Fixed ${title(unitConfig.speedAndDistance)}` : 'Signal K preferences'}${units.lastSentAt ? ` · sent ${new Date(units.lastSentAt).toLocaleTimeString()}` : ''}` : 'Synchronization disabled' $('lights').textContent = Number.isInteger(lights.level) ? `L${lights.level}` : (lightConfig.enabled ? 'Waiting' : '—') $('lightsdetail').textContent = lightConfig.enabled ? `${lightConfig.source === 'configuration' ? `Fixed L${lightConfig.configuredLevel}` : lightConfig.signalKPath}${lights.lastSentAt ? ` · sent ${new Date(lights.lastSentAt).toLocaleTimeString()}` : ''}` : 'Synchronization disabled' const calTone = !calConfig.enabled ? '' : calibration.stable ? 'ok' : 'warn' const calState = !calConfig.enabled ? 'Disabled' : calibration.stable ? 'Suggestion ready' : calibration.sampleCount ? 'Collecting' : 'Waiting for samples' setPill('calstate', calState, calTone) $('calmeasuredpath').textContent = calConfig.enabled ? calConfig.measuredPath : '—' $('calreferencepath').textContent = calConfig.enabled ? calConfig.referencePath : '—' $('calcurrent').textContent = finite(calibration.currentCalibrationFactor) ? fmt(calibration.currentCalibrationFactor, 3) : '—' $('calmultiplier').textContent = finite(calibration.multiplier) ? ${fmt(calibration.multiplier, 3)}` : '—' $('calsuggested').textContent = finite(calibration.suggestedCalibrationFactor) ? fmt(calibration.suggestedCalibrationFactor, 3) : '—' $('calspeeds').textContent = finite(calibration.medianMeasuredMps) && finite(calibration.medianReferenceMps) ? `${fmt(calibration.medianMeasuredMps * 1.943844, 2)} / ${fmt(calibration.medianReferenceMps * 1.943844, 2)} kn` : '—' $('calsamples').textContent = calConfig.enabled ? `${calibration.sampleCount || 0} of ${calibration.minimumSamples || calConfig.minimumSamples || 0} required · window ${calConfig.windowSize || '—'}` : '—' $('calstability').textContent = finite(calibration.relativeSpread) ? `${fmt(calibration.relativeSpread * 100, 1)}% / ${fmt((calConfig.maximumRelativeSpread || 0) * 100, 1)}%` : '—' $('calminspeed').textContent = calConfig.enabled ? `${fmt(calConfig.minimumSpeedMps, 2)} m/s · ${fmt(calConfig.minimumSpeedMps * 1.943844, 2)} kn` : '—' $('caladvisory').textContent = calibration.advisory || 'Enable the advisor to collect observations.' $('headingpaths').textContent = calConfig.enabled && calConfig.headingEnabled !== false ? `${calConfig.headingMeasuredPath} / ${calConfig.headingReferencePath}` : '—' $('headingcurrent').textContent = finite(headingCalibration.currentHeadingOffsetDegrees) ? `${fmt(headingCalibration.currentHeadingOffsetDegrees, 1)}°` : '—' $('headingcorrection').textContent = finite(headingCalibration.correctionDegrees) ? `${fmt(headingCalibration.correctionDegrees, 1)}°` : '—' $('headingsuggested').textContent = finite(headingCalibration.suggestedHeadingOffsetDegrees) ? `${fmt(headingCalibration.suggestedHeadingOffsetDegrees, 1)}°` : '—' $('headingsamples').textContent = calConfig.enabled && calConfig.headingEnabled !== false ? `${headingCalibration.sampleCount || 0} of ${headingCalibration.minimumSamples || calConfig.headingMinimumSamples || 0} required` : '—' $('headingstability').textContent = finite(headingCalibration.spreadDegrees) ? `${fmt(headingCalibration.spreadDegrees, 1)}° / ${fmt(headingCalibration.maximumSpreadDegrees, 1)}°` : '—' $('headingadvisory').textContent = headingCalibration.advisory || 'Enable the advisor to collect observations.' renderFeatures(config) renderIndicators(config, snapshot.perDatagram || {}, snapshot.lastDatagrams || {}) renderCounters(snapshot.perDatagram || {}) $('statusText').textContent = snapshot.status || '—' $('outputEvent').textContent = snapshot.outputEvent || 'stalkout' $('startedAt').textContent = time(snapshot.startedAt) $('history').textContent = `${snapshot.recentCount || 0} of ${snapshot.capacity || 0} records` $('lastUpdate').textContent = time(snapshot.now) } function renderFeatures(config) { const direct = Array.isArray(config.direct) ? config.direct : [] const managed = [ feature('0x82 / 0x85', 'Waypoint guidance', config.navigationToWaypoint?.enabled, config.navigationToWaypoint?.enabled ? `${title(config.navigationToWaypoint.bearingReference)} bearing · ${durationMs(config.navigationToWaypoint.updateIntervalMs)} refresh · ${durationMs(config.navigationToWaypoint.maximumAgeMs)} max age` : 'Passive target guidance'), feature('0x24', 'Display units', config.instrumentUnits?.enabled, config.instrumentUnits?.enabled ? (config.instrumentUnits.source === 'configuration' ? `Fixed ${title(config.instrumentUnits.speedAndDistance)} · startup ${enabledLabel(config.instrumentUnits.sendOnStartup)}` : `Signal K preferences · poll ${durationMs(config.instrumentUnits.pollIntervalMs)} · refresh ${durationSeconds(config.instrumentUnits.periodicRefreshSeconds)}`) : 'Network-wide speed and distance units'), feature('0x30', 'Display lighting', config.instrumentLights?.enabled, config.instrumentLights?.enabled ? (config.instrumentLights.source === 'configuration' ? `Fixed L${config.instrumentLights.configuredLevel} · startup ${enabledLabel(config.instrumentLights.sendOnStartup)}` : `${config.instrumentLights.signalKPath} · ${title(config.instrumentLights.valueFormat)} · min ${durationMs(config.instrumentLights.minimumIntervalMs)}`) : 'Network-wide L0–L3 illumination'), feature('CAL', 'Speed and heading calibration advisor', config.calibrationAdvisor?.enabled, config.calibrationAdvisor?.enabled ? `${config.calibrationAdvisor.minimumSamples} speed / ${config.calibrationAdvisor.headingMinimumSamples} heading samples` : 'Read-only ST60 calibration suggestions') ] const all = direct.map(item => feature(item.datagram, item.title, item.enabled, `${durationMs(item.minimumIntervalMs || 0)} minimum interval`)).concat(managed) $('features').innerHTML = all.map(item => `<article class="feature ${item.enabled ? 'enabled' : 'disabled'}"><div class="feature-head"><div class="feature-title"><strong>${escapeHtml(item.command)}</strong><span>${escapeHtml(item.name)}</span></div><b class="feature-state">${enabledLabel(item.enabled)}</b></div><small>${escapeHtml(item.detail || '')}</small></article>`).join('') } function feature(command, name, enabled, detail) { return { command, name, enabled: Boolean(enabled), detail } } function renderIndicators(config, counters, latest) { const direct = Array.isArray(config.direct) ? config.direct : [] const indicators = direct.map(item => ({ command: item.datagram, name: item.title.replace(/^0x[0-9a-f]+\s*/i, ''), enabled: item.enabled })).concat([ { command: '0x24', name: 'Speed and distance display units', enabled: config.instrumentUnits?.enabled }, { command: '0x30', name: 'Display lamp intensity', enabled: config.instrumentLights?.enabled }, { command: '0x82', name: 'Target waypoint name', enabled: config.navigationToWaypoint?.enabled }, { command: '0x85', name: 'Navigation to waypoint', enabled: config.navigationToWaypoint?.enabled } ]).sort((a, b) => a.command.localeCompare(b.command)) const active = indicators.filter(item => item.enabled).length setPill('indicatorCoverage', `${active} of ${indicators.length} active`, active ? 'ok' : '') $('indicators').innerHTML = indicators.map(item => { const count = Number(counters[item.command]) || 0 const last = latest[item.command] || {} const state = !item.enabled ? 'Disabled' : count ? 'Transmitting' : 'Waiting' const tone = !item.enabled ? 'disabled' : count ? 'transmitting' : 'waiting' const bytes = Array.isArray(last.bytes) && last.bytes.length ? last.bytes.join(' ') : 'No datagram observed' return `<article class="indicator ${tone}"> <div class="indicator-head"><strong>${escapeHtml(item.command)}</strong><span>${escapeHtml(state)}</span></div> <h3>${escapeHtml(item.name)}</h3> <div class="indicator-count">${count.toLocaleString()}</div> <small>emitted this process</small> <code title="${escapeHtml(last.sentence || '')}">${escapeHtml(bytes)}</code> <time datetime="${escapeHtml(last.time || '')}">${last.time ? `Last ${escapeHtml(new Date(last.time).toLocaleTimeString())}` : 'Not sent yet'}</time> </article>` }).join('') } function renderCounters(counters) { const entries = Object.entries(counters).sort(([a], [b]) => a.localeCompare(b)) $('counters').innerHTML = entries.length ? entries.map(([command, count]) => `<div><strong>${escapeHtml(commandLabel(command))}</strong><span>${count}</span></div>`).join('') : '<p class="empty">No datagrams emitted yet.</p>' } function add(event) { if (!event || typeof event !== 'object') return if (paused || !['emitted', 'error', 'navigation', 'suppressed', 'lifecycle'].includes(event.type)) return const details = event.values || compact({ unitSystem: event.unitSystem, waypointName: event.waypointName, lightLevel: event.lightLevel, sourcePath: event.sourcePath, sourceValue: event.sourceValue, level: event.level, active: event.active }) const row = document.createElement('tr') row.className = event.type row.dataset.type = event.type row.hidden = filter !== 'all' && filter !== event.type row.innerHTML = `<td>${new Date(event.time).toLocaleTimeString()}</td><td>${escapeHtml(event.type)}</td><td>${escapeHtml(commandLabel(event.datagram))}</td><td>${escapeHtml(event.reason || event.action || '')}</td><td>${escapeHtml(Object.keys(details).length ? JSON.stringify(details) : '')}</td><td>${escapeHtml(event.sentence || event.message || '')}</td>` rows.prepend(row) if (++rowCount > 300) { rows.lastElementChild?.remove(); rowCount-- } } function compact(object) { return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined)) } function applyFilter() { for (const row of rows.children) row.hidden = filter !== 'all' && row.dataset.type !== filter } async function refresh() { const response = await apiGet('status') render(response) setPill('connectionState', 'Live connection', 'ok') } async function apiGet(url) { const response = await fetch(`${apiBase}${url}`, { cache: 'no-store', credentials: 'include' }) if (!response.ok) throw new Error(`${url} failed: ${response.status}`) return response.json() } function scheduleRefresh() { clearTimeout(refreshTimer) refreshTimer = setTimeout(() => refresh().catch(() => setPill('connectionState', 'API unavailable', 'bad')), 150) } Promise.all([ refresh(), apiGet('recent?limit=200').then(events => Array.isArray(events) && events.forEach(add)) ]).catch(() => setPill('connectionState', 'API unavailable', 'bad')) if ('EventSource' in window) { const stream = new EventSource(`${apiBase}stream`, { withCredentials: true }) stream.onopen = () => setPill('connectionState', 'Live connection', 'ok') stream.onmessage = event => { const value = safeJson(event.data) if (!value) return setPill('connectionState', 'Stream data error', 'warn') if (value.type === 'snapshot') render(value) else { add(value) scheduleRefresh() } } stream.onerror = () => setPill('connectionState', 'Stream reconnecting', 'warn') } else { setPill('connectionState', 'Polling', 'warn') setInterval(() => refresh().catch(() => setPill('connectionState', 'API unavailable', 'bad')), 5000) } $('clear').onclick = () => { rows.innerHTML = ''; rowCount = 0 } $('pause').onclick = () => { paused = !paused; $('pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)) } $('eventFilter').onchange = event => { filter = event.target.value; applyFilter() }