UNPKG

signalk-server

Version:

An implementation of a [Signal K](http://signalk.org) server for boats.

1,023 lines (1,022 loc) 71.2 kB
"use strict"; // N2K Device Discovery — sends ISO Request (PGN 59904) asking each device // for Product Information (PGN 126996) which contains modelId, software // version, etc. Runs automatically 5s after the N2K bus becomes available, // and can be triggered manually via POST /skServer/n2kDiscoverDevices. // // NOTE: Requires a TCP connection to the YDWG02 gateway (ydwg02-canboatjs). // UDP connections (ydwg02-udp-canboatjs) are receive-only in practice — // the Node.js UDP socket sends to the gateway but the YDWG02 does not // forward those messages onto the N2K bus. var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const debug_1 = require("../debug"); const types_1 = require("../types"); const constants_1 = require("../constants"); const config_1 = require("../config/config"); const atomicWrite_1 = require("../atomicWrite"); const ts_pgns_1 = require("@canboat/ts-pgns"); const n2k_discovery_instances_1 = require("../n2k-discovery-instances"); const n2k_discovery_staleness_1 = require("../n2k-discovery-staleness"); const debug = (0, debug_1.createDebug)('signalk-server:interfaces:n2k-discovery'); const REQUEST_INTERVAL_MS = 500; const STATUS_TICK_MS = 5_000; // Delay before re-asking a device for identity after we observe a frame // from it without a known manufacturer in the live tree. Long enough // that the boot sweep has time to fire first; short enough that a // post-Reset Stale device repopulates within ~10s of the next data // frame instead of staying nameless until the user clicks Discover. const REDISCOVERY_DEBOUNCE_MS = 10_000; // Minimum gap between consecutive auto re-discovery requests for the // same address. A device that genuinely refuses to answer // 60928/126996/126998 (some Maretron/older units) shouldn't be // hammered every 10s forever. const REDISCOVERY_COOLDOWN_MS = 5 * 60_000; // Derive temp/humidity PGN set from canboat.json — used by the // instance-discovery endpoint to decide which frames to listen for. const DATA_INSTANCE_PGNS = new Set(); for (const def of (0, ts_pgns_1.getAllPGNs)()) { const hasInstanceKey = def.Fields.some((f) => f.Id === 'instance' && f.PartOfPrimaryKey); if (!hasInstanceKey) continue; const hasSourceKey = def.Fields.some((f) => f.Id === 'source' && f.PartOfPrimaryKey && (f.LookupEnumeration === 'TEMPERATURE_SOURCE' || f.LookupEnumeration === 'HUMIDITY_SOURCE')); if (hasSourceKey) DATA_INSTANCE_PGNS.add(def.PGN); } const LISTEN_DURATION_MS = 6000; const LABELS_FILENAME = 'n2k-channel-labels.json'; function labelsFilePath(app) { return path.join(app.config.configPath, LABELS_FILENAME); } function loadLabels(app) { const filePath = labelsFilePath(app); let content; try { content = fs.readFileSync(filePath, 'utf-8'); } catch (err) { if (err.code === 'ENOENT') { return {}; } debug('Failed to read %s: %s', filePath, err.message); return {}; } try { return JSON.parse(content); } catch (err) { debug('Malformed JSON in %s: %s', filePath, err.message); return {}; } } async function saveLabels(app, labels) { await (0, atomicWrite_1.atomicWriteFile)(labelsFilePath(app), JSON.stringify(labels, null, 2)); } function sendISORequest(app, dst, requestedPgn = 126996) { app.emit('nmea2000JsonOut', { pgn: 59904, prio: 6, dst, fields: { pgn: requestedPgn } }); } // Request PGN 126996 from each known device individually, spaced 500ms // apart. Gateways like the YDEN-02 have limited TCP throughput — a // broadcast request causes all devices to respond simultaneously, // overflowing the TCP buffer and dropping most responses. Individual // requests produce one response at a time, which fits within the // available bandwidth. // // Returns the number of devices being queried so the caller can // estimate how long the full sweep will take. // The set of PGNs we ask every device about during discovery. // 60928: ISO Address Claim — manufacturerCode, canName, deviceFunction // 126996: Product Information — modelId, softwareVersionCode, modelSerialCode // 126998: Configuration Information — installationDescription1/2, manufacturerInformation const DISCOVERY_PGNS = [60928, 126996, 126998]; function requestProductInfo(app, knownAddresses, pendingTimers) { // Cancel any in-flight requests from a previous sweep for (const timer of pendingTimers) { clearTimeout(timer); } pendingTimers.clear(); const addrs = Array.from(knownAddresses); debug('Sending ISO Requests for PGN %s to %d devices (one at a time)', DISCOVERY_PGNS.join(' + '), addrs.length); // Space each request by REQUEST_INTERVAL_MS so slower gateways like the // YDEN-02 do not overflow their TCP output buffer when every device // answers at once. addrs.forEach((addr, i) => { DISCOVERY_PGNS.forEach((pgn, j) => { const offset = (i * DISCOVERY_PGNS.length + j) * REQUEST_INTERVAL_MS; const timer = setTimeout(() => { pendingTimers.delete(timer); sendISORequest(app, addr, pgn); }, offset); pendingTimers.add(timer); }); }); return addrs.length; } module.exports = (app) => { let n2kOutAvailable = false; const knownAddresses = new Set(); const discoveredAddresses = new Set(); // Timers for in-flight per-address ISO Requests inside a sweep. // requestProductInfo() clears this set at the start of every sweep so // a fresh sweep doesn't pile up on top of a previous incomplete one. const pendingTimers = new Set(); // Timers that schedule the auto-discovery sweeps themselves // (5 s / 3 min / 10 min after nmea2000OutAvailable). Kept separate // from pendingTimers so the first sweep firing does not silently // cancel the later retry sweeps that were scheduled alongside it. const sweepTimers = new Set(); // One-shot follow-up requests posted by /n2kConfigDevice handlers // (re-request 60928 or 126998 after a configuration command) so the // sources tree picks up the new instance/description. Tracked here // so api.stop() can cancel them; kept separate from pendingTimers // so an in-flight sweep doesn't clear them. const requestTimers = new Set(); const schedulePendingRequest = (fn, delayMs) => { const timer = setTimeout(() => { requestTimers.delete(timer); fn(); }, delayMs); requestTimers.add(timer); }; // Last reported online state per "providerId.src" so we only emit on transitions. const onlineStates = new Map(); // Last time *any* parsed N2K frame was seen for a given bus address. // sourceMeta only records lastSeen when a value-bearing delta lands; // a device sending Address Claim, Heartbeat, Product Info etc. but // whose data PGNs don't map (or cycle slower than the 90s threshold) // would age out of sourceMeta and badge Offline despite still being // on the bus. Folding this map into buildSourceStatuses means // "online" reflects "alive on the bus", not "currently producing // mappable Signal K values". const frameLastSeenBySrc = new Map(); // Per-address bookkeeping for auto-rediscovery: when we observe a // frame from an address that has no manufacturer/model populated in // app.signalk.sources we schedule one debounced ISO Request burst. // `rediscoveryTimers` holds the pending debounce timer per address // (so a flurry of frames doesn't queue many bursts — only the most // recent debounce fires). `rediscoveryLastAtBySrc` holds the // timestamp of the last burst we actually fired, so we honour the // cooldown for devices that genuinely won't answer. const rediscoveryTimers = new Map(); const rediscoveryLastAtBySrc = new Map(); let statusTickInterval; // First tick always emits a full snapshot so admin-ui clients get // initial state via lastServerEvents bootstrap on connect. let firstStatusEmit = true; // Same idea for the N2K device-status payload (pgnDataInstances / // pgnSourceKeys). Fingerprint of the last emitted payload so we // re-emit only when the tree-derived inputs actually changed — // otherwise every tick would push ~10 KB to every admin client for // no UI benefit. let lastDeviceStatusFingerprint; let firstDeviceStatusEmit = true; const api = new types_1.Interface(); // Look up the numeric bus address that currently corresponds to the // given sourceRef. The sources summary tree (populated by fullsignalk // on every N2K delta) maps `<label>[<src>].n2k.canName` — walk it once // to find the entry that matches the suffix. Returns undefined when // the suffix isn't a numeric src and no canName lookup hits. function resolveSrcAddress(sourceRef) { const dotIdx = sourceRef.indexOf('.'); if (dotIdx === -1) return undefined; const label = sourceRef.slice(0, dotIdx); const suffix = sourceRef.slice(dotIdx + 1); // suffix may be a numeric src directly const numeric = Number(suffix); if (!Number.isNaN(numeric) && Number.isInteger(numeric)) return numeric; // eslint-disable-next-line @typescript-eslint/no-explicit-any const sources = app.signalk?.sources; if (!sources || typeof sources !== 'object') return undefined; const conn = sources[label]; if (!conn || typeof conn !== 'object') return undefined; for (const [key, dev] of Object.entries(conn)) { if (key === 'type' || key === 'label') continue; // eslint-disable-next-line @typescript-eslint/no-explicit-any const canName = dev?.n2k?.canName; if (canName === suffix) { const asNumber = Number(key); if (!Number.isNaN(asNumber) && Number.isInteger(asNumber)) { return asNumber; } } } return undefined; } // Find the providerId (connection label) that owns a given N2K bus // address by scanning app.signalk.sources for a connection whose // numeric sub-key matches. Returns undefined when no connection // claims the address (e.g. address only seen at frame level, never // mapped through the N2K → SK pipeline yet). function findProviderIdForAddress(addr) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const sources = app.signalk?.sources; if (!sources || typeof sources !== 'object') return undefined; const addrStr = String(addr); for (const providerId of Object.keys(sources)) { const conn = sources[providerId]; if (!conn || typeof conn !== 'object') continue; if (Object.prototype.hasOwnProperty.call(conn, addrStr)) { return providerId; } } return undefined; } // Has the device at this bus address been identified? "Identified" // means manufacturerCode is populated in the live sources tree, // which only happens once we've received and parsed PGN 60928 // (Address Claim) for the device. Used by the auto-rediscovery // path to decide whether to ask the device for its identity. function hasDeviceIdentity(addr) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const sources = app.signalk?.sources; if (!sources || typeof sources !== 'object') return false; const addrStr = String(addr); for (const providerId of Object.keys(sources)) { const conn = sources[providerId]; if (!conn || typeof conn !== 'object') continue; const sub = conn[addrStr]; if (!sub || typeof sub !== 'object') continue; const n2k = sub.n2k; if (n2k && n2k.manufacturerCode !== undefined) return true; } return false; } // Schedule one ISO Request burst (60928 + 126996 + 126998) for the // given bus address. Debounced per address: a flurry of frames // collapses into a single burst, and bursts are gated by a // cooldown so a device that refuses to answer isn't asked // continuously. No-op when the gateway hasn't signalled it can // send yet. function scheduleRediscovery(addr) { if (!n2kOutAvailable) return; if (rediscoveryTimers.has(addr)) return; const now = Date.now(); const lastAt = rediscoveryLastAtBySrc.get(addr); if (lastAt !== undefined && now - lastAt < REDISCOVERY_COOLDOWN_MS) { return; } const timer = setTimeout(() => { rediscoveryTimers.delete(addr); // Re-check identity at fire time — the boot sweep or another // path may have populated it during the debounce window. if (hasDeviceIdentity(addr)) return; rediscoveryLastAtBySrc.set(addr, Date.now()); DISCOVERY_PGNS.forEach((pgn, j) => { schedulePendingRequest(() => sendISORequest(app, addr, pgn), j * REQUEST_INTERVAL_MS); }); debug('Auto-rediscovery: requesting identity from src %d', addr); }, REDISCOVERY_DEBOUNCE_MS); rediscoveryTimers.set(addr, timer); } function buildSourceStatuses() { const now = Date.now(); const statuses = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const sourceMeta = app.signalk?.sourceMeta; // sourceMeta is the canonical per-source freshness map. Its keys are // already in `sourceRef` form (e.g. "can0.44", "0183-1.II", // "derived-data") matching getSourceId(). Walk it directly so we // cover N2K, NMEA0183 and $source-only (plugin) sources alike. const seenAddresses = new Set(); if (sourceMeta) { for (const sourceRef of Object.keys(sourceMeta)) { const metaLastSeen = sourceMeta[sourceRef]?.lastSeen; // For N2K sources, also check when we last saw any parsed frame // for this device's bus address. Devices that emit only meta // PGNs (Address Claim, Product Info, Heartbeat) — or whose data // PGNs aren't mapped to Signal K — would otherwise age out of // sourceMeta and look Offline despite being on the bus. const srcAddr = resolveSrcAddress(sourceRef); if (srcAddr !== undefined) seenAddresses.add(srcAddr); const frameLastSeen = srcAddr !== undefined ? frameLastSeenBySrc.get(srcAddr) : undefined; const lastSeen = metaLastSeen !== undefined && frameLastSeen !== undefined ? Math.max(metaLastSeen, frameLastSeen) : (metaLastSeen ?? frameLastSeen); const online = lastSeen !== undefined && now - lastSeen < n2k_discovery_staleness_1.ONLINE_THRESHOLD_MS; const dotIdx = sourceRef.indexOf('.'); const providerId = dotIdx === -1 ? sourceRef : sourceRef.slice(0, dotIdx); const src = dotIdx === -1 ? '' : sourceRef.slice(dotIdx + 1); statuses.push({ sourceRef, providerId, src, online, lastSeen }); } } // Pick up devices we have seen frames for but that never landed in // sourceMeta (e.g. only Address Claim / Product Info, or data PGNs // not yet mapped to Signal K paths). Without this pass, the bus- // freshness tracking added above produces no externally visible // status for those devices. for (const [srcAddr, frameLastSeen] of frameLastSeenBySrc) { if (seenAddresses.has(srcAddr)) continue; const providerId = findProviderIdForAddress(srcAddr); if (providerId === undefined) continue; const src = String(srcAddr); const sourceRef = `${providerId}.${src}`; const online = now - frameLastSeen < n2k_discovery_staleness_1.ONLINE_THRESHOLD_MS; statuses.push({ sourceRef, providerId, src, online, lastSeen: frameLastSeen }); } return statuses; } function checkSourceStatus() { const statuses = buildSourceStatuses(); if (debug.enabled) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const sourceMeta = app.signalk?.sourceMeta; debug('tick: %d statuses, %d sourceMeta entries, %d source providers', statuses.length, sourceMeta ? Object.keys(sourceMeta).length : 0, app.signalk?.sources ? Object.keys(app.signalk.sources).length : 0); if (statuses.length === 0 && sourceMeta) { debug('sourceMeta keys: %j', Object.keys(sourceMeta).slice(0, 10)); debug('sources keys: %j', app.signalk?.sources ? Object.keys(app.signalk.sources) : []); } } const transitions = []; const seenKeys = new Set(); for (const status of statuses) { const key = `${status.providerId}.${status.src}`; seenKeys.add(key); const prev = onlineStates.get(key); if (prev !== status.online) { onlineStates.set(key, status.online); transitions.push(status); } } // Drop tracking for sources that no longer appear in the tree. for (const key of onlineStates.keys()) { if (!seenKeys.has(key)) { onlineStates.delete(key); } } if (transitions.length > 0 || firstStatusEmit) { firstStatusEmit = false; // Emitted as serverevent (not serverAdminEvent) so it reaches every // admin-ui WS client even when the dummy security strategy is in // use (no hasAdminAccess). Source-status data is already exposed // via the unauthenticated /signalk/v1/api/sources tree, so this // doesn't widen the security surface. app.emit('serverevent', { type: 'SOURCESTATUS', data: statuses }); } } // Push pgnDataInstances + pgnSourceKeys to admin-ui clients when the // tree-derived inputs change. Without this the conflict-detection // badge in SourceDiscovery only updates on full page reload, even // when a device starts or stops publishing a path that overlaps // another device's path on the same (connection, deviceInstance). // Same data and security model as the GET /n2kDeviceStatus bootstrap // — already-public via that endpoint, so emitting on serverevent // doesn't widen the surface. function checkDeviceStatus() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const selfTree = app.signalk?.self; const pgnDataInstances = (0, n2k_discovery_instances_1.buildPgnDataInstancesFromTree)(selfTree); const pgnSourceKeys = (0, n2k_discovery_instances_1.buildPgnSourceKeysFromTree)(selfTree); // Stable JSON: tree walkers already produce sorted maps/arrays, // so JSON.stringify is a deterministic fingerprint and we don't // need to canonicalise further. const fingerprint = JSON.stringify({ pgnDataInstances, pgnSourceKeys, n2kOutAvailable }); if (!firstDeviceStatusEmit && fingerprint === lastDeviceStatusFingerprint) { return; } firstDeviceStatusEmit = false; lastDeviceStatusFingerprint = fingerprint; // serverAdminEvent (not serverevent): N2K device status exposes // bus-topology metadata (manufacturer/model identities, observed // PGN instance values) that the existing /skServer/n2kDeviceStatus // REST handler also gates with addAdminMiddleware. Using the // admin-only WS channel keeps the two paths consistent — without // this, anonymous read users with serverevents=all received the // same payload the REST endpoint denies them. app.emit('serverAdminEvent', { type: 'N2KDEVICESTATUS', data: { pgnDataInstances, pgnSourceKeys, discoveredAddresses: Array.from(discoveredAddresses), n2kOutAvailable } }); } const n2kListener = (pgn) => { const n2k = pgn; if (typeof n2k.src === 'number' && n2k.src >= 0 && n2k.src < 254) { knownAddresses.add(n2k.src); frameLastSeenBySrc.set(n2k.src, Date.now()); // Track discovery responses. Any of the three discovery PGNs // (Address Claim, Product Information, Configuration Information) // counts as proof we identified the device — the sweep requests // all three and a device that answers any of them has revealed // itself. if (n2k.pgn === 60928 || n2k.pgn === 126996 || n2k.pgn === 126998) { discoveredAddresses.add(n2k.src); } else if (!hasDeviceIdentity(n2k.src)) { // Frame from a device whose manufacturer/model never landed. // Happens after Reset Stale wiped the leaf, or for devices // (typical of battery monitors) whose only-on-request // 60928/126996/126998 reply was missed by the boot sweep. // Debounced + cooldown-gated so we don't spam the bus. scheduleRediscovery(n2k.src); } } }; // sourceRefChanged fires when n2k-signalk detects that a bus address // now belongs to a different physical device (different CAN Name on // the same src). Drop our frame-freshness entry for that src so the // departing device can age out cleanly — without this, a single // observation timestamp would carry across the reclaim and keep the // old sourceRef looking briefly Online after it should have gone // Offline. const sourceRefChangedListener = (...args) => { const payload = args[0]; if (payload && typeof payload.src === 'number') { frameLastSeenBySrc.delete(payload.src); } }; const n2kOutListener = () => { n2kOutAvailable = true; // discoveredAddresses is initialised empty at module scope and // then accumulates for the lifetime of the gateway connection. // We don't clear it here because nmea2000OutAvailable can fire // again after a gateway reconnect; wiping the set would briefly // un-identify every device on the bus until the next sweep // re-populates it. Explicit user actions (Reset Stale, // n2kRemoveSource) and sourceRefChanged are the only places // that drop entries. // // Multiple sweeps because some gateways — notably Yacht Devices // YDEN / YDWG — silently drop discovery requests (PGN 60928 / // 126996 / 126998) under bus load. A 36-device bus takes ~54s per // sweep with 500 ms pacing, so we schedule 5 s, 3 min and 10 min // for drop-prone setups. Users can still trigger a manual sweep // from the Discovery page. const sweepAfter = (delayMs) => { const timer = setTimeout(() => { sweepTimers.delete(timer); debug('Auto-requesting identity from %d N2K devices', knownAddresses.size); requestProductInfo(app, knownAddresses, pendingTimers); }, delayMs); sweepTimers.add(timer); }; sweepAfter(5_000); sweepAfter(180_000); sweepAfter(600_000); }; api.start = () => { app.on('N2KAnalyzerOut', n2kListener); app.on('nmea2000OutAvailable', n2kOutListener); app.on('sourceRefChanged', sourceRefChangedListener); statusTickInterval = setInterval(() => { checkSourceStatus(); checkDeviceStatus(); }, STATUS_TICK_MS); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/n2kConfigDevice`); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/n2kDiscoverDevices`); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/n2kDeviceStatus`); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/n2kDiscoverInstances`); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/n2kChannelLabel`); app.post(`${constants_1.SERVERROUTESPREFIX}/n2kDiscoverDevices`, (_req, res) => { if (!n2kOutAvailable) { res.status(503).json({ state: 'FAILED', statusCode: 503, message: 'N2K output not available' }); return; } // Manual re-discovery accumulates into discoveredAddresses; // we don't clear, because a device that answered the first // sweep but is silent during this one is still on the bus. const deviceCount = requestProductInfo(app, knownAddresses, pendingTimers); const estimatedMs = deviceCount * REQUEST_INTERVAL_MS * DISCOVERY_PGNS.length; res.json({ state: 'COMPLETED', statusCode: 200, message: `Discovery request sent to ${deviceCount} devices (~${Math.ceil(estimatedMs / 1000)}s)` }); }); app.get(`${constants_1.SERVERROUTESPREFIX}/n2kDeviceStatus`, (_req, res) => { // Derive currently-published instance values from the SK tree // rather than from a passive listener. The tree is the // authoritative current state — paths only exist while a // device is actively publishing them — so a stale instance // emitted briefly during boot can't haunt conflict detection. // eslint-disable-next-line @typescript-eslint/no-explicit-any const selfTree = app.signalk?.self; const instanceData = (0, n2k_discovery_instances_1.buildPgnDataInstancesFromTree)(selfTree); const sourceKeyData = (0, n2k_discovery_instances_1.buildPgnSourceKeysFromTree)(selfTree); const sourceStatuses = buildSourceStatuses(); res.json({ knownAddresses: Array.from(knownAddresses), discoveredAddresses: Array.from(discoveredAddresses), n2kOutAvailable, pgnDataInstances: instanceData, pgnSourceKeys: sourceKeyData, sourceStatuses }); }); // Listen to N2K messages from a specific device for LISTEN_DURATION_MS // and collect all data instances (temperature, humidity, switch channels). // Returns the unique {pgn, instance, sourceLabel} tuples discovered. app.get(`${constants_1.SERVERROUTESPREFIX}/n2kDiscoverInstances`, (req, res) => { const src = Number(req.query.src); const sourceRef = req.query.sourceRef; if (isNaN(src) || src < 0 || src > 253) { res.status(400).json({ state: 'FAILED', statusCode: 400, message: 'src query parameter required (0-253)' }); return; } const localLabels = sourceRef ? loadLabels(app) : {}; const instances = []; const seen = new Set(); // PGN 130060 labels keyed by "instance" (dataSourceInstanceValue) const n2kLabels = new Map(); // Map instance to hardwareChannelId for write-back const channelIds = new Map(); // All PGN 130060 channel labels for the response const allChannelLabels = []; const listener = (pgn) => { const n2k = pgn; if (n2k.src !== src || !n2k.fields) return; // Collect PGN 130060 (Label) responses if (n2k.pgn === 130060) { const instVal = n2k.fields.dataSourceInstanceValue ?? n2k.fields['Data Source Instance Value']; const labelText = n2k.fields.label ?? n2k.fields.Label; const labelPgn = n2k.fields.pgn ?? n2k.fields.PGN; const hwCh = n2k.fields.hardwareChannelId ?? n2k.fields['Hardware Channel ID']; if (instVal !== undefined && typeof labelText === 'string' && labelText) { if (labelPgn) { n2kLabels.set(`${labelPgn}:${instVal}`, labelText); } // Also store by instance only for cross-PGN fallback n2kLabels.set(`*:${instVal}`, labelText); if (hwCh !== undefined) { channelIds.set(`${labelPgn ?? '*'}:${instVal}`, Number(hwCh)); } allChannelLabels.push({ hardwareChannelId: Number(hwCh ?? 0), pgn: labelPgn !== undefined ? Number(labelPgn) : undefined, instance: Number(instVal), label: labelText }); debug('PGN 130060 label from src %d: ch=%s PGN %s instance %s = %s', src, hwCh, labelPgn, instVal, labelText); } return; } if (!DATA_INSTANCE_PGNS.has(n2k.pgn)) return; const inst = Number(n2k.fields.instance ?? n2k.fields.Instance); if (isNaN(inst)) return; let sourceLabel = ''; let sourceEnum; if (n2k.pgn === 130313) { // Humidity const srcField = n2k.fields.source ?? n2k.fields.Source; if (typeof srcField === 'string') { sourceLabel = srcField; sourceEnum = (0, ts_pgns_1.getEnumerationValue)('HUMIDITY_SOURCE', srcField); } else if (typeof srcField === 'number') { sourceEnum = srcField; sourceLabel = (0, ts_pgns_1.getEnumerationName)('HUMIDITY_SOURCE', srcField) || `Humidity Source ${srcField}`; } } else { // Temperature PGNs (130312, 130316, 130823, etc.) const srcField = n2k.fields.source ?? n2k.fields.Source; if (typeof srcField === 'string') { sourceLabel = srcField; sourceEnum = (0, ts_pgns_1.getEnumerationValue)('TEMPERATURE_SOURCE', srcField); } else if (typeof srcField === 'number') { sourceEnum = srcField; sourceLabel = (0, ts_pgns_1.getEnumerationName)('TEMPERATURE_SOURCE', srcField) || `Temperature Source ${srcField}`; } } const key = `${n2k.pgn}:${inst}:${sourceLabel}`; if (!seen.has(key)) { seen.add(key); instances.push({ pgn: n2k.pgn, instance: inst, sourceLabel, sourceEnum }); } }; app.on('N2KAnalyzerOut', listener); // Request PGN 130060 (Label) from the device if (n2kOutAvailable) { sendISORequest(app, src, 130060); } // Long-poll: we keep the N2KAnalyzerOut listener attached for // LISTEN_DURATION_MS while the device replies. If the client // disconnects before that, the listener would otherwise stay // attached for the full window and the timeout callback would // try to res.json() on a closed response, which throws. Track // the timer + listener and clean up on req close as well. let aborted = false; const finishTimer = setTimeout(() => { if (aborted) return; aborted = true; app.removeListener('N2KAnalyzerOut', listener); instances.sort((a, b) => a.pgn - b.pgn || a.instance - b.instance); for (const inst of instances) { // Prefer N2K device labels (PGN 130060), fall back to local const deviceLabel = n2kLabels.get(`${inst.pgn}:${inst.instance}`) ?? n2kLabels.get(`*:${inst.instance}`); if (deviceLabel) { inst.label = deviceLabel; } else if (sourceRef) { const key = `${sourceRef}:${inst.pgn}:${inst.instance}`; const lbl = localLabels[key]; if (lbl) inst.label = lbl; } // Attach hardware channel ID for write-back const hwCh = channelIds.get(`${inst.pgn}:${inst.instance}`) ?? channelIds.get(`*:${inst.instance}`); if (hwCh !== undefined) inst.hardwareChannelId = hwCh; } allChannelLabels.sort((a, b) => a.hardwareChannelId - b.hardwareChannelId); if (!res.writableEnded) { res.json({ instances, channelLabels: allChannelLabels }); } }, LISTEN_DURATION_MS); req.on('close', () => { if (aborted) return; aborted = true; clearTimeout(finishTimer); app.removeListener('N2KAnalyzerOut', listener); }); }); // Save a per-channel label locally (fallback for devices without PGN 130060). // Labels from PGN 130060 (read from device) take priority over local labels. app.put(`${constants_1.SERVERROUTESPREFIX}/n2kChannelLabel`, async (req, res) => { const { sourceRef, pgn, instance, label } = req.body; if (typeof sourceRef !== 'string' || !sourceRef || typeof pgn !== 'number' || !Number.isInteger(pgn) || typeof instance !== 'number' || !Number.isInteger(instance) || (label !== undefined && typeof label !== 'string')) { res.status(400).json({ state: 'FAILED', statusCode: 400, message: 'sourceRef: string, pgn: integer, instance: integer required; label: string optional' }); return; } const labels = loadLabels(app); const key = `${sourceRef}:${pgn}:${instance}`; if (label && label.trim()) { labels[key] = label.trim(); } else { delete labels[key]; } try { await saveLabels(app, labels); } catch (err) { console.error('Failed to save channel labels:', err); res.status(500).json({ state: 'FAILED', statusCode: 500, message: 'Failed to write channel labels file' }); return; } debug('Channel label %s = %s', key, label || '(deleted)'); res.json({ state: 'COMPLETED', statusCode: 200 }); }); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/n2kRemoveSource`); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/resetN2kDevices`); app.post(`${constants_1.SERVERROUTESPREFIX}/resetN2kDevices`, (_req, res) => { const now = Date.now(); const sources = app.signalk?.sources; // eslint-disable-next-line @typescript-eslint/no-explicit-any const sourceMeta = app.signalk.sourceMeta; const removedRefs = []; if (sources && typeof sources === 'object') { for (const providerId of Object.keys(sources)) { const labelNode = sources[providerId]; if (!labelNode || typeof labelNode !== 'object') continue; if (labelNode.type && labelNode.type !== 'NMEA2000') continue; for (const subKey of Object.keys(labelNode)) { if (subKey === 'label' || subKey === 'type') continue; const sub = labelNode[subKey]; if (!sub || typeof sub !== 'object' || !sub.n2k) continue; const metaKey = `${providerId}.${subKey}`; // Fold in both freshness signals so the predicate matches // the Online badge in buildSourceStatuses. const srcAddr = resolveSrcAddress(metaKey); const stale = (0, n2k_discovery_staleness_1.isDeviceStale)(sourceMeta?.[metaKey]?.lastSeen, srcAddr !== undefined ? frameLastSeenBySrc.get(srcAddr) : undefined, now); if (!stale) continue; const ref = `${providerId}.${subKey}`; const cacheKeys = []; const allRefs = new Set([ref]); for (const [key, delta] of Object.entries(app.deltaCache.sourceDeltas)) { if (!key.startsWith(providerId + '.')) continue; // eslint-disable-next-line @typescript-eslint/no-explicit-any const d = delta; const src = d?.updates?.[0]?.source; if (!src) continue; const addr = String(src.src ?? ''); const canName = src.canName ?? ''; if (subKey === addr || subKey === canName || key === ref || key.slice(providerId.length + 1) === subKey) { cacheKeys.push(key); allRefs.add(key); if (canName) allRefs.add(`${providerId}.${canName}`); if (addr) allRefs.add(`${providerId}.${addr}`); } } for (const key of cacheKeys) { app.deltaCache.removeSourceDelta(key); } const addrNum = Number(subKey); if (!Number.isNaN(addrNum)) { knownAddresses.delete(addrNum); discoveredAddresses.delete(addrNum); // Drop the per-address last-seen so a different device // claiming this bus address later does not inherit a // stale online-since timestamp. frameLastSeenBySrc.delete(addrNum); } delete labelNode[subKey]; if (sourceMeta) { delete sourceMeta[metaKey]; } for (const r of allRefs) { onlineStates.delete(r); removedRefs.push(r); } } } } // Re-broadcast the fresh status set so the UI clears stale rows. app.emit('serverevent', { type: 'SOURCESTATUS', data: buildSourceStatuses() }); // Force the device-status payload to re-emit on the next tick // even if the fingerprint happens to match — the user just took // an action that they expect to see reflected immediately. firstDeviceStatusEmit = true; checkDeviceStatus(); debug('Reset N2K devices: removed %d stale entries', removedRefs.length); res.json({ state: 'COMPLETED', statusCode: 200, message: `Cleared ${removedRefs.length} stale device entries` }); }); app.delete(`${constants_1.SERVERROUTESPREFIX}/n2kRemoveSource`, async (req, res) => { const sourceRef = req.query.sourceRef; if (!sourceRef) { res.status(400).json({ state: 'FAILED', statusCode: 400, message: 'sourceRef query parameter required' }); return; } const dotIdx = sourceRef.indexOf('.'); if (dotIdx === -1) { res.status(400).json({ state: 'FAILED', statusCode: 400, message: 'Invalid sourceRef format' }); return; } const connection = sourceRef.slice(0, dotIdx); const srcPart = sourceRef.slice(dotIdx + 1); // Find matching sourceDeltas entries — the key uses numeric address // (e.g. "can0.44") but the UI sourceRef may use canName // (e.g. "can0.c1789101e7e0b32b"). Collect all ref variants for // alias/label cleanup before deleting. const keysToRemove = []; const addressesToRemove = []; const allRefs = new Set([sourceRef]); for (const [key, delta] of Object.entries(app.deltaCache.sourceDeltas)) { if (!key.startsWith(connection + '.')) continue; // eslint-disable-next-line @typescript-eslint/no-explicit-any const d = delta; const src = d?.updates?.[0]?.source; if (!src) continue; const addr = String(src.src ?? ''); const canName = src.canName ?? ''; if (key === sourceRef || srcPart === addr || srcPart === canName || key.slice(dotIdx + 1) === srcPart) { keysToRemove.push(key); allRefs.add(key); if (canName) allRefs.add(`${connection}.${canName}`); if (addr) allRefs.add(`${connection}.${addr}`); if (typeof src.src === 'number') { addressesToRemove.push(src.src); } else if (!isNaN(Number(addr))) { addressesToRemove.push(Number(addr)); } } } if (keysToRemove.length === 0) { res.status(404).json({ state: 'FAILED', statusCode: 404, message: `Source ${sourceRef} not found` }); return; } for (const key of keysToRemove) { app.deltaCache.removeSourceDelta(key); } // Remove from all address-keyed maps. Mirror the cleanup // resetN2kDevices does so a removed device cannot leave behind // stale online status or last-seen timestamps that a future // device claiming the same address would inherit. for (const addr of addressesToRemove) { knownAddresses.delete(addr); discoveredAddresses.delete(addr); frameLastSeenBySrc.delete(addr); } for (const ref of allRefs) { onlineStates.delete(ref); } // Prune the live Signal K tree too — without this the deleted // device keeps appearing in /signalk/v1/api/sources until the // server restarts (resetN2kDevices does the same prune). // eslint-disable-next-line @typescript-eslint/no-explicit-any const sources = app.signalk?.sources; // eslint-disable-next-line @typescript-eslint/no-explicit-any const sourceMeta = app.signalk?.sourceMeta; if (sources?.[connection]) { const labelNode = sources[connection]; for (const addr of addressesToRemove) { const subKey = String(addr); if (Object.prototype.hasOwnProperty.call(labelNode, subKey)) { delete labelNode[subKey]; } } // Drop the connection wrapper if no devices remain so the // sources tree doesn't accumulate empty providers. const remaining = Object.keys(labelNode).filter((k) => k !== 'type' && k !== 'label'); if (remaining.length === 0) { delete sources[connection]; } } if (sourceMeta) { for (const ref of allRefs) { if (Object.prototype.hasOwnProperty.call(sourceMeta, ref)) { delete sourceMeta[ref]; } } } // Clean up source aliases (in-memory; persisted below) const aliases = app.config.settings.sourceAliases; let aliasChanged = false; if (aliases) { for (const ref of allRefs) { if (ref in aliases) { delete aliases[ref]; aliasChanged = true; } } } // Clean up channel labels (best-effort: a write failure here // doesn't prevent the source-removal from completing — the // labels file just retains stale entries that won't match any // live source). const labels = loadLabels(app); const labelPrefixes = Array.from(allRefs).map((r) => r + ':'); let labelsChanged = false; for (const key of Object.keys(labels)) { if (labelPrefixes.some((p) => key.startsWith(p))) { delete labels[key]; labelsChanged = true; } } if (labelsChanged) { try { await saveLabels(app, labels); } catch (err) { debug('Failed to save channel labels during source removal: %s', err); } } // Force the device-status payload to re-emit on the next tick // so the admin-ui clears the removed device from conflict // detection / discovery views without waiting for a timing // accident. firstDeviceStatusEmit = true; checkDeviceStatus(); const respondOk = () => { debug('Removed source %s (%d entries)', sourceRef, keysToRemove.length);