UNPKG

signalk-server

Version:

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

1,112 lines (1,110 loc) 62.1 kB
"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();