signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
1,112 lines (1,110 loc) • 62.1 kB
JavaScript
"use strict";
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* Copyright 2017 Scott Bender <scott@scottbender.net>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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 });
exports.buildSrcToCanonicalMap = buildSrcToCanonicalMap;
exports.collectProviderIds = collectProviderIds;
exports.pruneSourcesByProvider = pruneSourcesByProvider;
const debug_1 = require("./debug");
const debug = (0, debug_1.createDebug)('signalk-server:deltacache');
const server_api_1 = require("@signalk/server-api");
const lodash_1 = __importStar(require("lodash"));
const fs_1 = require("fs");
const path_1 = require("path");
const atomicWrite_1 = require("./atomicWrite");
const streambundle_1 = require("./streambundle");
const deltaPriority_1 = require("./deltaPriority");
const SOURCES_CACHE_FILE = 'sources-cache.json';
/**
* Build a `<label>.<src>` → `<label>.<canName>` map from the Signal K
* sources summary tree.
*
* Providers with `useCanName: true` emit deltas tagged with the canName
* form once the device's PGN 60928 has been observed. Frames that
* arrived before that point — common with UDP gateways that miss the
* early ISO Address Claim — leak out tagged by numeric src and create
* stale leaves that survive in the cache. Returning a translation map
* here lets callers collapse those duplicates back onto the canonical
* canName form.
*
* Providers with `useCanName` off never populate `n2k.canName`; the
* map stays empty and callers see refs unchanged.
*/
function buildSrcToCanonicalMap(sources) {
const out = new Map();
if (!sources || typeof sources !== 'object')
return out;
try {
for (const [label, conn] of Object.entries(sources)) {
if (!conn || typeof conn !== 'object')
continue;
for (const [src, dev] of Object.entries(conn)) {
if (src === 'type' || src === 'label')
continue;
if (!dev || typeof dev !== 'object')
continue;
const canName = dev?.n2k?.canName;
if (typeof canName === 'string' && canName.length > 0) {
out.set(`${label}.${src}`, `${label}.${canName}`);
}
}
}
}
catch {
// best-effort — never block multi-source detection on a malformed
// sources tree
}
return out;
}
/**
* Collect the IDs of providers in `settings.pipedProviders` that are
* currently enabled. Skips entries with `enabled: false` (matching the
* convention in `pipedproviders.ts`: undefined is treated as enabled).
*
* Returns an empty Set when the settings tree is missing or empty —
* callers must skip the prune in that case so a fresh install (or a
* settings file we can't read) doesn't wipe out the entire cache.
*/
function collectProviderIds(pipedProviders) {
const out = new Set();
if (!Array.isArray(pipedProviders))
return out;
for (const p of pipedProviders) {
if (p && typeof p === 'object') {
const enabled = p.enabled;
if (enabled === false)
continue;
const id = p.id;
if (typeof id === 'string' && id.length > 0) {
out.add(id);
}
}
}
return out;
}
/**
* Remove cache entries whose `<providerId>.<rest>` key prefix is not in
* `knownProviderIds`. Mutates the input object and returns the number
* of entries removed.
*
* If `knownProviderIds` is empty, leaves the cache untouched so a
* misread settings file can't drop the user's whole history.
*/
function pruneSourcesByProvider(cached, knownProviderIds) {
if (knownProviderIds.size === 0)
return 0;
let dropped = 0;
for (const key of Object.keys(cached)) {
const dotIdx = key.indexOf('.');
const providerId = dotIdx > 0 ? key.slice(0, dotIdx) : key;
if (!knownProviderIds.has(providerId)) {
delete cached[key];
dropped++;
}
}
return dropped;
}
class DeltaCache {
cache = {};
lastModifieds = {};
app;
defaults;
sourceDeltas = {};
cachedContextPaths = {};
sourcesCachePath = null;
saveTimer = null;
preferredSources = new Map();
multiSourceTimer = null;
lastEmittedMultiSourceCount = 0;
livePreferredEmitTimer = null;
livePreferredDirtyPaths = new Set();
// Cached `<label>.<src> → <label>.<canName>` translation, refreshed
// whenever `app.signalk.sources` is replaced or sourceRefChanged
// fires. Without this, livePreferredSources reports raw refs that
// don't match the canName-form ranking saved in priorities.json,
// and the admin UI's wins/Preferred badge disagrees with the engine.
canonicalMap = null;
canonicalSnapshot = null;
subscribedPaths = new Set();
// Predicate the engine fills in via setRoutesPathPredicate. When set,
// onValue uses it to decide whether updating preferredSources for an
// incoming (path, sourceRef) means anything to the admin UI — for
// pass-through paths (no override, source not in any active group)
// we deliberately skip the write so the Data Browser's "Priority
// filtered" view doesn't suppress every other source on a path that
// the engine isn't actually filtering.
routesPath = null;
constructor(app, streambundle) {
this.app = app;
streambundle.keys.onValue((key) => {
if (this.subscribedPaths.has(key))
return;
this.subscribedPaths.add(key);
streambundle.getBus(key).onValue(this.onValue.bind(this));
});
app.on?.('sourceRefChanged', () => {
this.canonicalSnapshot = null;
});
this.loadSourcesCache();
// String.split() is heavy enough and called frequently enough
// to warrant caching the result. Has a noticeable effect
// on throughput of a server going full blast with the n2k
// sample data and the memory hit is negligible. The cache
// must be pruned, or AIS vessel data will stick forever.
// No fancy pruning, just clear & let it recreate.
setInterval(() => {
this.cachedContextPaths = {};
}, 5 * 60 * 1000);
}
getContextAndPathParts(msg) {
let result;
if (this.cachedContextPaths[msg.context] &&
(result = this.cachedContextPaths[msg.context][msg.path])) {
return result;
}
let contextAndPathParts = msg.context.split('.');
if (msg.path.length !== 0) {
contextAndPathParts = contextAndPathParts.concat(msg.path.split('.'));
}
if (!this.cachedContextPaths[msg.context]) {
this.cachedContextPaths[msg.context] = {};
}
this.cachedContextPaths[msg.context][msg.path] = contextAndPathParts;
return contextAndPathParts;
}
canonicaliseSourceRef(sourceRef) {
const sources = this.app.signalk?.sources;
if (sources !== this.canonicalSnapshot || this.canonicalMap === null) {
this.canonicalSnapshot = sources;
this.canonicalMap = buildSrcToCanonicalMap(sources);
}
return this.canonicalMap.get(sourceRef) ?? sourceRef;
}
onValue(msg) {
// debug(`onValue ${JSON.stringify(msg)}`)
if (msg.isMeta) {
// ignore meta data since it's getting managed by FullSignalK
return;
}
const sourceRef = ensureHasDollarSource(msg);
const leaf = getLeafObject(this.cache, this.getContextAndPathParts(msg), true);
if (msg.path.length !== 0) {
leaf[sourceRef] = msg;
const prefKey = msg.context + '\0' + msg.path;
// The priority engine matches by canonical (canName) form. Store
// the same canonical ref in preferredSources so the livePreferred
// stream and the wins/Preferred badge in the admin UI compare
// like-for-like with the saved priorities.json. Falls through to
// the raw ref when no canName is known (cold boot, non-N2K).
const canonicalRef = this.canonicaliseSourceRef(sourceRef);
// Skip the preferred-source bookkeeping when the engine isn't
// routing this path — e.g. no override, source not in any active
// group, or an override gone dormant because its parent group
// was deactivated. Otherwise onValue would record a "winner"
// for a pass-through path and the admin UI's Priority-filtered
// view would suppress the other sources on that path.
if (this.routesPath && !this.routesPath(msg.path, sourceRef)) {
const prevSource = this.preferredSources.get(prefKey);
if (prevSource !== undefined) {
this.preferredSources.delete(prefKey);
this.livePreferredDirtyPaths.add(prefKey);
this.scheduleLivePreferredEmit();
}
}
else {
const prevSource = this.preferredSources.get(prefKey);
this.preferredSources.set(prefKey, canonicalRef);
// Only mark the path dirty for the LIVEPREFERRED stream when
// the winning source actually changed — otherwise every
// accepted delta would queue a redundant emit.
if (prevSource !== canonicalRef) {
this.livePreferredDirtyPaths.add(prefKey);
this.scheduleLivePreferredEmit();
}
}
}
else if (msg.value) {
lodash_1.default.keys(msg.value).forEach((key) => {
if (!leaf[key]) {
leaf[key] = {};
}
leaf[key][sourceRef] = msg;
});
}
this.lastModifieds[msg.context] = Date.now();
}
scheduleMultiSourceEmit() {
if (this.multiSourceTimer)
return;
this.multiSourceTimer = setTimeout(() => {
this.multiSourceTimer = null;
this.emitMultiSourcePaths();
}, 2000);
}
scheduleLivePreferredEmit() {
if (this.livePreferredEmitTimer)
return;
// 1s debounce: priority transitions are user-visible but not
// latency-critical; coalescing many flips into one event keeps the
// admin-ui WS quiet during noisy startup or heavy reconnect.
this.livePreferredEmitTimer = setTimeout(() => {
this.livePreferredEmitTimer = null;
const dirty = this.livePreferredDirtyPaths;
this.livePreferredDirtyPaths = new Set();
// Empty string is a tombstone: a path whose preferred entry was
// dropped server-side (priority engine rebuilt with that path
// pass-through, source removed, etc). Without an explicit
// signal the client's mergeLivePreferredSources would keep the
// stale winner forever and the Data Browser's Priority-filtered
// view would suppress every other source on that path.
const data = {};
for (const key of dirty) {
const sourceRef = this.preferredSources.get(key);
data[key] = sourceRef ?? '';
}
if (Object.keys(data).length === 0)
return;
this.app.emit('serverevent', {
type: 'LIVEPREFERREDSOURCES',
data
});
}, 1000);
}
/**
* Snapshot of the current "winning" source per path, keyed as
* `${context}\0${path}`. Updated by onValue on every accepted delta;
* reflects what the priority engine is actually routing right now.
*/
getLivePreferredSources() {
const out = {};
for (const [key, ref] of this.preferredSources)
out[key] = ref;
return out;
}
emitMultiSourcePaths() {
const paths = this.getMultiSourcePaths();
const count = Object.keys(paths).length;
const countChanged = count !== this.lastEmittedMultiSourceCount;
this.lastEmittedMultiSourceCount = count;
this.app.emit('serverevent', {
type: 'MULTISOURCEPATHS',
data: paths
});
this.app.emit('serverevent', {
type: 'RECONCILEDGROUPS',
data: this.getReconciledGroups()
});
if (countChanged) {
this.scheduleMultiSourceEmit();
}
}
/**
* Remove all cached deltas for a given sourceRef and re-emit
* MULTISOURCEPATHS so the UI reflects the change. For every path whose
* preferred source pointed at the removed ref, pick a deterministic
* replacement from the remaining leaf entries so filterDeltasToPreferred
* doesn't fall back to whatever Object.keys iterates first.
*/
/**
* Inject the engine's "is this (path, sourceRef) routed?" predicate
* so onValue can avoid recording a preferred winner for paths the
* engine isn't filtering. Called from activateSourcePriorities each
* time the engine is rebuilt; pass null to drop the gate.
*/
setRoutesPathPredicate(fn) {
this.routesPath = fn;
// A previously-routed path may have just become pass-through. Drop
// every entry the new predicate no longer routes so the bootstrap
// snapshot and the LIVEPREFERRED stream stay in sync with what
// the engine will actually enforce going forward.
if (!fn)
return;
let preferredChanged = false;
for (const [key, ref] of this.preferredSources) {
const nullIdx = key.indexOf('\0');
const path = nullIdx === -1 ? '' : key.slice(nullIdx + 1);
if (fn(path, ref))
continue;
this.preferredSources.delete(key);
this.livePreferredDirtyPaths.add(key);
preferredChanged = true;
}
if (preferredChanged) {
this.scheduleLivePreferredEmit();
}
}
removeSource(sourceRef) {
const removeFromNode = (node) => {
for (const key of Object.keys(node)) {
if (key === 'meta')
continue;
const child = node[key];
if (!child || typeof child !== 'object')
continue;
if (child.path !== undefined && child.value !== undefined) {
if (key === sourceRef)
delete node[key];
}
else {
removeFromNode(child);
}
}
};
removeFromNode(this.cache);
// The FullSignalK tree (app.signalk.root) is a parallel
// representation written to by addDelta. The Data Browser, REST
// /signalk/v1/api, and the WS subscription replay all read from
// it. Without pruning here, evicted sources keep showing fresh
// timestamps in the UI even though deltacache is empty —
// because cached values keep being served from this tree.
pruneSourceFromFullSignalK(this.app.signalk?.root, sourceRef);
this.app.emit('serverevent', {
type: 'SOURCEEVICTED',
data: { sourceRef }
});
// preferredSources stores canonical (canName) refs since onValue
// canonicalised on write, so compare in the same form whether the
// caller passed a raw or canonical ref.
const canonicalTarget = this.canonicaliseSourceRef(sourceRef);
let preferredChanged = false;
for (const [key, ref] of this.preferredSources) {
if (ref !== canonicalTarget)
continue;
const nullIdx = key.indexOf('\0');
const context = nullIdx === -1 ? key : key.slice(0, nullIdx);
const path = nullIdx === -1 ? '' : key.slice(nullIdx + 1);
const replacement = this.pickReplacementSource(context, path);
if (replacement) {
this.preferredSources.set(key, this.canonicaliseSourceRef(replacement));
}
else {
this.preferredSources.delete(key);
}
// Without queuing the dirty key, the admin UI keeps showing the
// removed source as the winner until an unrelated delta happens
// to refresh that path.
this.livePreferredDirtyPaths.add(key);
preferredChanged = true;
}
if (preferredChanged) {
this.scheduleLivePreferredEmit();
}
this.emitMultiSourcePaths();
}
pickReplacementSource(context, path) {
const parts = context.split('.');
if (path.length !== 0)
parts.push(...path.split('.'));
let node = this.cache;
for (const p of parts) {
if (!node || typeof node !== 'object')
return undefined;
node = node[p];
}
if (!node || typeof node !== 'object')
return undefined;
for (const ref of Object.keys(node)) {
if (ref === 'meta')
continue;
const leaf = node[ref];
if (leaf && typeof leaf === 'object' && leaf.value !== undefined) {
return ref;
}
}
return undefined;
}
/**
* Remove a source delta entry by key, purge all cached data for the
* corresponding sourceRefs (numeric address and CAN Name forms), and
* persist the updated sources cache to disk.
*/
removeSourceDelta(key) {
const delta = this.sourceDeltas[key];
const refsToRemove = [key];
const dotIdx = key.indexOf('.');
const conn = dotIdx !== -1 ? key.slice(0, dotIdx) : '';
const addr = dotIdx !== -1 ? key.slice(dotIdx + 1) : '';
if (delta?.updates?.[0]?.source) {
const src = delta.updates[0].source;
if (src.canName)
refsToRemove.push(`${conn}.${src.canName}`);
if (src.src !== undefined)
refsToRemove.push(`${conn}.${src.src}`);
}
delete this.sourceDeltas[key];
for (const ref of new Set(refsToRemove)) {
this.removeSource(ref);
}
// Also remove from the FullSignalK sources tree so the REST API
// (which may serve app.signalk.retrieve() directly) reflects the change
const sources = this.app.signalk.retrieve().sources;
if (sources && conn && sources[conn]) {
delete sources[conn][addr];
const remaining = Object.keys(sources[conn]).filter((k) => k !== 'type' && k !== 'label');
if (remaining.length === 0) {
delete sources[conn];
}
}
this.scheduleSaveSourcesCache();
}
ingestDelta(delta) {
if (!delta.updates)
return;
// Stamp source freshness here, before the priority engine has a
// chance to drop non-winner deltas. fullsignalk.addUpdate also
// stamps sourceMeta, but only for the surviving (preferred)
// delta — so any source the engine filters out would otherwise
// age out and badge Offline despite the cache holding fresh data
// for it (admin Priority Groups view, see SOURCESTATUS).
const sourceMeta = this.app.signalk?.sourceMeta;
const now = Date.now();
for (const update of delta.updates) {
if (!('values' in update) || !update.values)
continue;
const sourceRef = update.$source;
if (sourceMeta && typeof sourceRef === 'string' && sourceRef.length > 0) {
let meta = sourceMeta[sourceRef];
if (!meta) {
meta = sourceMeta[sourceRef] = { lastSeen: 0 };
}
meta.lastSeen = now;
}
for (const pathValue of update.values) {
if (pathValue.path.length === 0)
continue;
const msg = {
context: delta.context,
source: update.source,
$source: sourceRef,
timestamp: update.timestamp,
path: pathValue.path,
value: pathValue.value,
isMeta: false
};
const leaf = getLeafObject(this.cache, this.getContextAndPathParts(msg), true);
const isNewSource = !leaf[sourceRef];
leaf[sourceRef] = msg;
// Populate preferredSources so getCachedDeltas('preferred')
// works correctly after restart/replay. Store the canonical
// (canName) form to match onValue's writes; otherwise winners
// would drift to numeric addresses across replays and stop
// matching saved group membership.
const prefKey = delta.context + '\0' + pathValue.path;
if (!this.preferredSources.has(prefKey)) {
this.preferredSources.set(prefKey, this.canonicaliseSourceRef(sourceRef));
}
if (isNewSource) {
const sourceCount = Object.keys(leaf).filter((k) => {
const v = leaf[k];
return (v &&
typeof v === 'object' &&
v.path !== undefined &&
v.value !== undefined);
}).length;
if (sourceCount >= 2) {
this.scheduleMultiSourceEmit();
}
}
}
}
if (delta.context) {
this.lastModifieds[delta.context] = Date.now();
}
}
setSourceDelta(key, delta) {
// Deduplicate by canName: if a different key already exists for the
// same canName (e.g. a device moved from address 254 to its final
// address), remove the old entry to prevent duplicates.
const canName = delta?.updates?.[0]?.source?.canName;
if (canName) {
const dotIdx = key.indexOf('.');
const conn = dotIdx !== -1 ? key.slice(0, dotIdx) : '';
// Collect first, mutate after — removeSourceDelta deletes from
// this.sourceDeltas, which would invalidate the entries iterator.
const keysToRemove = [];
for (const [existingKey, existingDelta] of Object.entries(this.sourceDeltas)) {
if (existingKey === key)
continue;
if (!existingKey.startsWith(conn + '.'))
continue;
const existingCanName = existingDelta?.updates?.[0]?.source
?.canName;
if (existingCanName === canName) {
keysToRemove.push(existingKey);
}
}
for (const existingKey of keysToRemove) {
this.removeSourceDelta(existingKey);
}
}
this.sourceDeltas[key] = delta;
this.app.signalk.addDelta(delta);
this.scheduleSaveSourcesCache();
}
getSourcesCachePath() {
if (this.sourcesCachePath === null) {
const configPath = this.app.config.configPath;
if (configPath) {
this.sourcesCachePath = (0, path_1.join)(configPath, SOURCES_CACHE_FILE);
}
}
return this.sourcesCachePath;
}
loadSourcesCache() {
const cachePath = this.getSourcesCachePath();
if (!cachePath) {
return;
}
try {
const data = (0, fs_1.readFileSync)(cachePath, 'utf-8');
const cached = JSON.parse(data);
if (cached && typeof cached === 'object') {
// Prune cached entries whose provider has been removed from (or
// disabled in) settings.pipedProviders. Without this, deleting,
// renaming, or disabling a connection leaves its devices stuck
// in the Data Browser and skServer/deviceIdentities forever —
// they get hydrated from sources-cache.json on every restart,
// never get a fresh `lastSeen` because the producer is gone,
// and survive because Reset Stale only runs on user click.
const knownProviderIds = collectProviderIds(this.app?.config?.settings?.pipedProviders);
const droppedCount = pruneSourcesByProvider(cached, knownProviderIds);
if (droppedCount > 0) {
debug('Pruned %d cache entries from removed or disabled providers', droppedCount);
}
// Deduplicate by canName: when the same device appears under
// multiple addresses (e.g. from address 254 claim cycling),
// keep only the entry with the lower non-254 address.
const byCanName = new Map();
for (const [key, delta] of Object.entries(cached)) {
const cn = delta?.updates?.[0]?.source?.canName;
if (!cn)
continue;
const dotIdx = key.indexOf('.');
const conn = dotIdx !== -1 ? key.slice(0, dotIdx) : '';
const fullCn = conn + '.' + cn;
const existing = byCanName.get(fullCn);
if (existing) {
const existingAddr = Number(existing.slice(existing.indexOf('.') + 1));
const thisAddr = Number(key.slice(dotIdx + 1));
// If addresses are not numeric (e.g. canName-based key), keep
// the existing entry to avoid NaN-driven nondeterministic choice
if (Number.isNaN(existingAddr) || Number.isNaN(thisAddr)) {
delete cached[key];
continue;
}
// Prefer non-254; if both non-254, prefer lower address
const keepExisting = thisAddr === 254 ||
(existingAddr !== 254 && existingAddr < thisAddr);
const toRemove = keepExisting ? key : existing;
delete cached[toRemove];
if (!keepExisting)
byCanName.set(fullCn, key);
}
else {
byCanName.set(fullCn, key);
}
}
this.sourceDeltas = cached;
const addDelta = this.app.signalk.addDelta.bind(this.app.signalk);
lodash_1.default.values(this.sourceDeltas).forEach(addDelta);
debug('Loaded %d cached sources from %s', Object.keys(cached).length, cachePath);
}
}
catch (e) {
if (e.code !== 'ENOENT') {
debug('Failed to load sources cache: %s', e.message);
}
}
}
scheduleSaveSourcesCache() {
if (this.saveTimer) {
return;
}
this.saveTimer = setTimeout(() => {
this.saveTimer = null;
this.saveSourcesCache();
}, 5000);
}
saveSourcesCache() {
const cachePath = this.getSourcesCachePath();
if (!cachePath) {
return;
}
const data = JSON.stringify(this.sourceDeltas, null, 2);
(0, atomicWrite_1.atomicWriteFile)(cachePath, data).then(() => debug('Saved %d sources to %s', Object.keys(this.sourceDeltas).length, cachePath), (err) => debug('Failed to save sources cache: %s', err.message));
}
deleteContext(contextKey) {
debug('Deleting context ' + contextKey);
const contextParts = contextKey.split('.');
if (contextParts.length === 2) {
delete this.cache[contextParts[0]][contextParts[1]];
}
}
pruneContexts(seconds) {
debug('pruning contexts...');
const threshold = Date.now() - seconds * 1000;
for (const contextKey in this.lastModifieds) {
if (this.lastModifieds[contextKey] < threshold) {
this.deleteContext(contextKey);
delete this.lastModifieds[contextKey];
}
}
}
buildFull(user, path) {
const leaf = getLeafObject(this.cache, pathToProcessForFull(path), false, true);
let deltas;
if (leaf) {
deltas = findDeltas(leaf).map(streambundle_1.toDelta);
}
return this.buildFullFromDeltas(user, deltas, path.length === 0 || path[0] === 'sources');
}
/**
* Return paths on the self vessel that have more than one source.
* Result: { path: sourceRef[] } for each multi-source path.
*
* Persisted source priorities are also unioned in. Without this, a path
* whose user-ranked sources are temporarily silent (e.g. an MFD that has
* been powered off) drops to one or zero live publishers and disappears
* from the multi-source view — the admin-ui then shows the path's
* persisted entries under "Ungrouped" and surprises the user. Keeping
* the persisted view in lets the priorities page render the user's
* intent stably across power cycles.
*/
/**
* Return every (path, publishers[]) pair the cache has seen for the
* self vessel, regardless of publisher count. Used to seed the
* priority engine's `seenPublishersByPath` so routesPath flips on
* for already-contended paths immediately at boot, instead of
* flickering "no priority configured" warnings until live deltas
* land. Refs are returned in canonical (canName) form to match the
* engine's identity rule.
*/
getSelfPathPublishers() {
const selfParts = this.app.selfContext.split('.');
let selfBranch = this.cache;
for (const part of selfParts) {
if (!selfBranch || !selfBranch[part])
return {};
selfBranch = selfBranch[part];
}
const canonical = this.reconcileCanonical();
const out = {};
const walk = (node, pathParts) => {
for (const key of Object.keys(node)) {
if (key === 'meta')
continue;
const child = node[key];
if (!child || typeof child !== 'object')
continue;
if (child.path !== undefined && child.value !== undefined) {
if (pathParts[0] === 'notifications')
return;
const sources = Object.keys(node).filter((k) => {
const v = node[k];
return (v &&
typeof v === 'object' &&
v.path !== undefined &&
v.value !== undefined);
});
if (sources.length > 0) {
out[pathParts.join('.')] = [...new Set(sources.map(canonical))];
}
return;
}
walk(child, [...pathParts, key]);
}
};
walk(selfBranch, []);
return out;
}
getMultiSourcePaths() {
const selfParts = this.app.selfContext.split('.');
let selfBranch = this.cache;
for (const part of selfParts) {
if (!selfBranch || !selfBranch[part]) {
selfBranch = null;
break;
}
selfBranch = selfBranch[part];
}
const canonical = this.reconcileCanonical();
// Per-path canonical publishers observed in the cache, regardless
// of publisher count. Multi-source paths drop in via the standard
// ≥2-source filter below; fan-out paths reuse this map when only
// one source is currently emitting so the path keeps its group
// affiliation in the priorities view.
const cachePublishers = {};
const result = {};
if (selfBranch) {
const walk = (node, pathParts) => {
for (const key of Object.keys(node)) {
if (key === 'meta')
continue;
const child = node[key];
if (!child || typeof child !== 'object')
continue;
if (child.path !== undefined && child.value !== undefined) {
// Notifications are events, not measurements — the priority
// engine never dedupes them (see deltaPriority.ts), so they
// must not surface as multi-source paths in the priorities UI.
if (pathParts[0] === 'notifications')
return;
const sources = Object.keys(node).filter((k) => {
const v = node[k];
return (v &&
typeof v === 'object' &&
v.path !== undefined &&
v.value !== undefined);
});
if (sources.length > 0) {
const canonicalSet = new Set(sources.map(canonical));
const path = pathParts.join('.');
cachePublishers[path] = canonicalSet;
if (canonicalSet.size > 1) {
const set = result[path] ?? (result[path] = new Set());
for (const s of canonicalSet)
set.add(s);
}
}
return;
}
walk(child, [...pathParts, key]);
}
};
walk(selfBranch, []);
}
const persisted = this.app.config.settings?.priorityOverrides;
if (persisted) {
for (const [path, entries] of Object.entries(persisted)) {
if (!Array.isArray(entries))
continue;
if (path === 'notifications' || path.startsWith('notifications.')) {
continue;
}
// Fan-out path (sentinel `*` entry): keep the path anchored to
// its group in the admin UI. Without this, when only one
// source is currently emitting, the path drops below the
// multi-source threshold and the group reconciliation pulls
// it out into "Ungrouped path overrides". Inject every source
// from whichever priority group already contains the live
// publisher(s) — that produces the connected-component edges
// computeGroups needs to keep the path in the group.
if ((0, deltaPriority_1.isFanOutPriorities)(entries)) {
const cached = cachePublishers[path];
if (!cached || cached.size === 0)
continue;
const set = result[path] ?? (result[path] = new Set());
for (const s of cached)
set.add(s);
const savedGroups = this.app.config.settings
?.priorityGroups;
if (Array.isArray(savedGroups)) {
for (const group of savedGroups) {
if (!Array.isArray(group?.sources))
continue;
const groupSet = new Set(group.sources);
const overlap = [...cached].some((s) => groupSet.has(s));
if (overlap) {
for (const s of group.sources)
set.add(canonical(s));
}
}
}
continue;
}
if (entries.length < 2)
continue;
const set = result[path] ?? (result[path] = new Set());
for (const entry of entries) {
if (entry && typeof entry.sourceRef === 'string') {
set.add(canonical(entry.sourceRef));
}
}
}
}
const out = {};
for (const [path, set] of Object.entries(result)) {
out[path] = [...set].sort();
}
return out;
}
/**
* Supplementary `<conn>.<src>` → `<conn>.<canName>` translation built
* from the per-source delta store. `buildSrcToCanonicalMap` goes blind
* when `app.signalk.sources` has no `n2k.canName` for a numeric src —
* the cold-boot / UDP-gateway late-join window the comment at the top
* of this file describes — but a metadata delta carrying the resolved
* canName may already sit in `sourceDeltas` keyed by the same numeric
* src. Recovering it here lets a device that left a stale numeric leaf
* AND a canName leaf in the cache collapse onto one ref, so the two
* forms of one physical device don't land in two reconciled groups and
* trip the "a source may belong to at most one active group" save
* validator.
*
* Each `sourceDeltas` entry carries exactly one canName, so the map is
* single-valued per key and never merges two physically-different
* devices. Keys are usually numeric-form (the `setSourceDelta` key,
* n2k-signalk.ts) but `loadSourcesCache` can hydrate canName-form keys
* from disk; those map onto themselves (a harmless identity), which
* leaves the ref unchanged.
*/
buildSourceDeltaCanonicalMap() {
const out = new Map();
for (const [key, delta] of Object.entries(this.sourceDeltas)) {
const source = delta?.updates?.[0]?.source;
const canName = source?.canName;
if (typeof canName !== 'string' || canName.length === 0)
continue;
const label = source?.label;
if (typeof label !== 'string' || label.length === 0)
continue;
out.set(key, `${label}.${canName}`);
}
return out;
}
/**
* Source-ref canonicaliser shared by the three cache-walk callers
* (getReconciledGroups, getSelfPathPublishers, getMultiSourcePaths).
* Prefers the live sources tree, then falls back to the canName
* recovered from the source-delta store for numeric refs the tree
* hasn't resolved yet. Routing all three through one closure keeps the
* reconciled groups, the engine's seenPublishersByPath seed and the
* multi-source UI from disagreeing about a device's ref during the
* late-join window.
*/
reconcileCanonical() {
const srcToCanonical = buildSrcToCanonicalMap(this.app.signalk?.sources);
const srcDeltaCanonical = this.buildSourceDeltaCanonicalMap();
return (ref) => srcToCanonical.get(ref) ?? srcDeltaCanonical.get(ref) ?? ref;
}
/**
* Compute reconciled priority groups for the admin UI: saved groups
* are authoritative (composition is fixed by priorityGroups, not by
* who is currently live), unsaved sources fall through to connected-
* components discovery on multiSourcePaths.
*
* For each saved group:
* - sources = saved order + newcomers (sources currently publishing a
* group path but not in the saved list)
* - paths = paths historically published by ≥2 of the saved sources,
* excluding paths owned by another active group (the group with
* more current live publishers wins a shared path)
*
* For unsaved residue: standard connected-components grouping with
* the ≥2-live-publisher edge rule.
*/
getReconciledGroups() {
const selfParts = this.app.selfContext.split('.');
let selfBranch = this.cache;
for (const part of selfParts) {
if (!selfBranch || !selfBranch[part])
return [];
selfBranch = selfBranch[part];
}
const canonical = this.reconcileCanonical();
// Walk once to populate two views: per-source path history, and
// per-path live publisher set. The latter is the same set of edges
// computeGroups would build from multiSourcePaths.
const bySource = {};
const livePublishers = {};
const walk = (node, pathParts) => {
for (const key of Object.keys(node)) {
if (key === 'meta')
continue;
const child = node[key];
if (!child || typeof child !== 'object')
continue;
if (child.path !== undefined && child.value !== undefined) {
if (pathParts[0] === 'notifications')
return;
const path = pathParts.join('.');
const pathLive = livePublishers[path] ?? (livePublishers[path] = new Set());
for (const sourceKey of Object.keys(node)) {
const v = node[sourceKey];
if (!v ||
typeof v !== 'object' ||
v.path === undefined ||
v.value === undefined) {
continue;
}
const ref = canonical(sourceKey);
const srcSet = bySource[ref] ?? (bySource[ref] = new Set());
srcSet.add(path);
pathLive.add(ref);
}
return;
}
walk(child, [...pathParts, key]);
}
};
walk(selfBranch, []);
// pathsBySource history: every path each source has cached.
// Inverted so per-path "all-time publishers" is O(1).
const pathPublishersAllTime = {};
for (const [src, paths] of Object.entries(bySource)) {
for (const p of paths) {
const set = pathPublishersAllTime[p] ??
(pathPublishersAllTime[p] = new Set());
set.add(src);
}
}
const savedGroups = this.app.config.settings?.priorityGroups;
// Membership: which saved group claims each source? Inactive groups
// still claim their sources for display purposes.
const sourceToSavedGroup = new Map();
if (Array.isArray(savedGroups)) {
for (const sg of savedGroups) {
if (sg.inactive)
continue;
for (const src of sg.sources) {
if (!sourceToSavedGroup.has(src))
sourceToSavedGroup.set(src, sg);
}
}
for (const sg of savedGroups) {
if (!sg.inactive)
continue;
for (const src of sg.sources) {
if (!sourceToSavedGroup.has(src))
sourceToSavedGroup.set(src, sg);
}
}
}
// Each multi-publisher path belongs to whichever active saved group
// has the most current live publishers — keeps cross-group fan-out
// attached to the dominant group.
const pathOwner = new Map();
for (const [path, refs] of Object.entries(livePublishers)) {
if (refs.size === 0)
continue;
const counts = new Map();
for (const ref of refs) {
const sg = sourceToSavedGroup.get(ref);
if (!sg || sg.inactive)
continue;
counts.set(sg.id, (counts.get(sg.id) ?? 0) + 1);
}
let bestId = null;
let bestCount = 0;
for (const [gid, n] of counts) {
if (n > bestCount) {
bestCount = n;
bestId = gid;
}
}
if (bestId)
pathOwner.set(path, bestId);
}
const out = [];
// Sources that are attached to a saved group — either as an
// original member (sg.sources) or as a newcomer added on this
// pass below. Used by the residue branch to avoid emitting an
// "Unranked Unsaved" card for a source that's already shown as
// a newcomer of some saved group; otherwise the same source ends
// up in two groups in the reconciled view, the validator on
// PUT /skServer/priorities (a source may belong to ≤1 active
// group) rejects the save, and the user sees "Saving priorities
// settings failed!" on a perfectly-good config.
const claimedByAnySavedGroup = new Set(sourceToSavedGroup.keys());
if (Array.isArray(savedGroups)) {
for (const sg of savedGroups) {
const savedSet = new Set(sg.sources);
const groupPaths = new Set();
for (const src of sg.sources) {
const paths = bySource[src];
if (!paths)
continue;
for (const p of paths) {
const owner = pathOwner.get(p);
// Path owned by another active group → skip.
if (owner && owner !== sg.id && !sg.inactive)
continue;
// Require ≥2 group sources to have published this path
// (now or ever). Otherwise a plugin source in the group
// drags every unrelated path it emits into the group card.
const allTime = pathPublishersAllTime[p];
if (!allTime)
continue;
let groupCount = 0;
for (const ref of allTime) {
if (savedSet.has(ref))
groupCount++;
if (groupCount >= 2)
break;
}
if (groupCount < 2)
continue;
groupPaths.add(p);
}
}
const newcomers = new Set();
for (const path of groupPaths) {
const live = livePublishers[path];
if (!live)
continue;
for (const ref of live) {
if (savedSet.has(ref))
continue;
const claimedBy = sourceToSavedGroup.get(ref);
if (claimedBy && claimedBy.id !== sg.id)
continue;
newcomers.add(ref);
claimedByAnySavedGroup.add(ref);
// Claim the newcomer for this group so a later active group that
// also has it as a live publisher skips it at the guard above;
// otherwise the source lands in two active groups and the PUT
// /skServer/priorities validator rejects the save. Only active
// groups claim: the validator ignores inactive groups, so an
// inactive claim gives no benefit and would wrongly suppress the
// newcomer on a later active group's card.
if (!sg.inactive)
sourceToSavedGroup.set(ref, sg);
}
}
const newcomerList = [...newcomers].sort();