signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
1,023 lines (1,022 loc) • 71.2 kB
JavaScript
"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);