signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
447 lines • 21.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getToPreferredDelta = exports.FANOUT_SOURCEREF = void 0;
exports.isFanOutPriorities = isFanOutPriorities;
exports.isOverrideDormantUnderGroups = isOverrideDormantUnderGroups;
exports.sourceRefIdentity = sourceRefIdentity;
const debug_1 = require("./debug");
const debug = (0, debug_1.createDebug)('signalk-server:sourcepriorities');
// An NMEA 2000 CAN Name is a 64-bit identifier rendered as 16 hex
// digits. Matching $source refs by CAN Name suffix lets a user
// rank a physical device once (e.g. "YDEN02.c0788c00e7e04312") and
// have it count whenever the same device is seen through a different
// transport (e.g. "canhat.c0788c00e7e04312" forwarded from a remote
// Signal K server). Hex digits are case-insensitive so a hand-edited
// or imported priorities.json carrying uppercase letters still
// matches the lowercase form our pipeline emits. Shorter suffixes —
// NMEA 0183 talker IDs, N2K addresses like "43" — are not globally
// unique and stay exact-match.
const CAN_NAME_SUFFIX = /\.([0-9a-fA-F]{16})$/;
/**
* Sentinel sourceRef that turns a path-level override into a
* "fan out" rule: every source's value is delivered unchanged for that
* path, regardless of ranking. Stored in priorities.json as a single
* entry of the form `[{ sourceRef: '*', timeout: 0 }]` so older code
* paths see a sourceRef that doesn't match any real device and fall
* through their existing "no match" branches without crashing.
*/
exports.FANOUT_SOURCEREF = '*';
function isFanOutPriorities(entries) {
return (Array.isArray(entries) &&
entries.length === 1 &&
entries[0]?.sourceRef === exports.FANOUT_SOURCEREF);
}
/**
* An override is dormant when every one of its ranked sources belongs
* to a group that the user has marked inactive. The principle: an
* override only filters if at least one of its sources can win, and a
* source from an inactive group is treated as ineligible. Sources that
* belong to no group, or to an active one, keep the override alive.
*
* Used by both the server engine (to bypass dormant overrides during
* delta routing) and by the admin-UI (to render dormant overrides as
* visually disabled and exclude them from the attention-count badge),
* so the truth derived from `(overrides, groups)` stays consistent on
* both sides without an explicit flag being pushed across the wire.
*
* The fan-out sentinel never goes dormant — it has no real source to
* map onto a group.
*/
function isOverrideDormantUnderGroups(override, groups) {
if (!Array.isArray(override) || override.length === 0)
return false;
if (isFanOutPriorities(override))
return false;
if (!Array.isArray(groups) || groups.length === 0)
return false;
// Build a quick lookup of source-identity → "the group that owns it
// is inactive". Active groups (or "no group at all") leave the
// identity off the map and keep the override eligible.
const inactiveSourceIdentities = new Set();
const allClaimedIdentities = new Set();
for (const g of groups) {
if (!Array.isArray(g?.sources))
continue;
for (const s of g.sources) {
const id = sourceRefIdentity(s);
allClaimedIdentities.add(id);
if (g.inactive) {
inactiveSourceIdentities.add(id);
}
else {
// An active group's claim wins on overlap — drop any prior
// inactive claim for the same identity. Mirrors the
// first-active-wins rule the engine enforces elsewhere.
inactiveSourceIdentities.delete(id);
}
}
}
for (const entry of override) {
if (!entry?.sourceRef)
continue;
const id = sourceRefIdentity(entry.sourceRef);
// Source belongs to no group at all → keeps the override alive.
if (!allClaimedIdentities.has(id))
return false;
// Source belongs to an active group → keeps it alive.
if (!inactiveSourceIdentities.has(id))
return false;
}
return true;
}
/**
* Device identity for transport-agnostic matching. Returns the CAN Name
* if the sourceRef encodes one; otherwise the sourceRef itself is
* returned so exact-match semantics are preserved for non-N2K sources.
*/
function sourceRefIdentity(sourceRef) {
const m = CAN_NAME_SUFFIX.exec(sourceRef);
// Normalise to lowercase: the canonical wire format is lowercase
// (Uint64LE().toString(16)) but a hand-edited priorities.json may
// carry uppercase letters. Without lowercasing, identity comparison
// would silently fail to match the same device.
return m ? m[1].toLowerCase() : sourceRef;
}
const DEFAULT_FALLBACK_MS = 15000;
const buildOverridePrecedences = (overrides) => {
const out = new Map();
for (const path of Object.keys(overrides)) {
const entries = overrides[path];
const precedences = new Map();
entries.forEach(({ sourceRef, timeout }, i) => {
precedences.set(sourceRefIdentity(sourceRef), {
precedence: i,
timeout
});
});
out.set(path, precedences);
}
return out;
};
const buildGroupPrecedences = (groups, fallbackMs) => {
const sourceToGroupId = new Map();
const groupPrecedences = new Map();
for (const group of groups) {
if (group.inactive)
continue;
const precedences = new Map();
group.sources.forEach((sourceRef, i) => {
const identity = sourceRefIdentity(sourceRef);
// First group wins on overlap. The PUT validator on the server
// and the connected-component derivation on the client both
// prevent a source from belonging to two groups; this is a
// defence against a hand-edited priorities.json.
if (!sourceToGroupId.has(identity)) {
sourceToGroupId.set(identity, group.id);
}
precedences.set(identity, {
precedence: i,
timeout: i === 0 ? 0 : fallbackMs
});
});
groupPrecedences.set(group.id, precedences);
}
return { sourceToGroupId, groupPrecedences };
};
const identityCanonicaliser = (s) => s;
const getToPreferredDelta = (config = {}) => {
const overrides = config.overrides ?? {};
const groups = config.groups ?? [];
const fallbackMs = config.fallbackMs ?? DEFAULT_FALLBACK_MS;
const unknownSourceTimeout = config.unknownSourceTimeout ?? 10000;
const canonicalise = config.canonicalise ?? identityCanonicaliser;
const excludeIdentities = config.excludeIdentities;
const hasExcludes = !!excludeIdentities && excludeIdentities.size > 0;
const hasOverrides = Object.keys(overrides).length > 0;
const hasActiveGroups = groups.some((g) => !g.inactive);
if (!hasOverrides && !hasActiveGroups && !hasExcludes) {
debug('No priorities data');
const passthrough = ((delta, _now, _selfContext) => delta);
passthrough.routesPath = () => false;
return passthrough;
}
// Drop overrides whose every ranked source belongs to a group the
// user has marked inactive — the override is dormant in that case
// and should not filter incoming deltas. Restoring the group flips
// the override back on without the user having to re-author it.
const activeOverrides = {};
for (const path of Object.keys(overrides)) {
if (isOverrideDormantUnderGroups(overrides[path], groups))
continue;
activeOverrides[path] = overrides[path];
}
// Paths with a single sentinel-source entry bypass priority filtering
// entirely — every source's value is delivered unchanged. Used when
// the user wants to compare or aggregate readings across sources
// (e.g. satellitesInView from multiple GPSes) while still honouring
// priorities on the rest of the group.
const fanOutPaths = new Set(Object.keys(activeOverrides).filter((p) => isFanOutPriorities(activeOverrides[p])));
const overridePrecedences = buildOverridePrecedences(activeOverrides);
const { sourceToGroupId, groupPrecedences } = buildGroupPrecedences(groups, fallbackMs);
// Tracks which group has "claimed" each path. A path is claimed the
// first time a delta from any group source emits it. Once claimed,
// every delta on that path — including from sources NOT in the group
// (e.g. a derived-data plugin computing the same value) — resolves
// against the claiming group's ranking. Without this, an
// unconfigured source publishing a group-covered path would always
// bypass priority filtering, defeating the user's saved ranking.
const pathToGroupId = new Map();
// Per-path set of distinct publisher identities the engine has
// seen. Used by routesPath to gate the "this path is contested"
// signal on ≥2 publishers — single-publisher paths shouldn't
// surface a "Preferred" badge in the admin UI even though the
// engine internally claims the path for the group (the claim is
// there to keep an unconfigured second publisher in line, not to
// declare the engine has made a decision).
const pathSeenPublishers = new Map();
// Seed from the caller's snapshot (typically derived from the
// delta cache loaded from disk). Without this, every server
// restart leaves routesPath returning false for every path until
// a second publisher's first delta lands — which can take seconds
// for slow PGNs and surfaces as a transient "no priority
// configured" warning across the admin UI. Pre-claim every path
// that already had ≥2 known publishers.
const seed = config.seenPublishersByPath;
if (seed) {
for (const path of Object.keys(seed)) {
const refs = seed[path];
if (!refs || refs.length === 0)
continue;
const identities = new Set();
for (const ref of refs) {
identities.add(sourceRefIdentity(canonicalise(ref)));
}
pathSeenPublishers.set(path, identities);
if (identities.size >= 2) {
// Find which group (if any) owns this path via one of its
// sources, so routesPath flips on at boot.
for (const id of identities) {
const groupId = sourceToGroupId.get(id);
if (groupId) {
pathToGroupId.set(path, groupId);
break;
}
}
}
}
}
const contextPathTimestamps = new Map();
const setLatest = (context, path, sourceRef, millis) => {
let pathLatestTimestamps = contextPathTimestamps.get(context);
if (!pathLatestTimestamps) {
pathLatestTimestamps = new Map();
contextPathTimestamps.set(context, pathLatestTimestamps);
}
pathLatestTimestamps.set(path, { sourceRef, timestamp: millis });
};
const getLatest = (context, path) => {
const pathLatestTimestamps = contextPathTimestamps.get(context);
if (!pathLatestTimestamps) {
return {
sourceRef: '',
timestamp: 0
};
}
const latestTimestamp = pathLatestTimestamps.get(path);
if (!latestTimestamp) {
return {
sourceRef: '',
timestamp: 0
};
}
return latestTimestamp;
};
const HIGHESTPRECEDENCE = {
precedence: 0,
timeout: 0
};
const LOWESTPRECEDENCE = {
precedence: Number.POSITIVE_INFINITY,
timeout: unknownSourceTimeout
};
// Resolve which precedence map applies to (path, source).
// Override on the path wins; otherwise the source's group ranking
// applies AND the path is claimed by that group for any future
// delta on the same path (from any source); otherwise the path's
// existing claim — if any — applies; otherwise null → passthrough.
//
// Once a path is claimed by a group, an unconfigured source emitting
// the same path is treated as an unknown source against the group's
// precedences (LOWESTPRECEDENCE) and obeys the existing
// configured-displaces-unknown rule. That stops a derived-data
// plugin from oscillating the cache's preferred winner against the
// group's actual rank-1 source.
const resolvePrecedences = (path, canonicalSource) => {
// Track distinct publishers per path. Used by routesPath to
// suppress the Preferred badge for single-publisher paths.
let publishers = pathSeenPublishers.get(path);
if (!publishers) {
publishers = new Set();
pathSeenPublishers.set(path, publishers);
}
publishers.add(sourceRefIdentity(canonicalSource));
const override = overridePrecedences.get(path);
if (override)
return override;
const groupId = sourceToGroupId.get(sourceRefIdentity(canonicalSource));
if (groupId) {
// Claim this path for the source's group on first encounter.
// Subsequent deltas on this path from sources NOT in the group
// will fall through to the existing claim below.
if (!pathToGroupId.has(path)) {
pathToGroupId.set(path, groupId);
}
return groupPrecedences.get(groupId) ?? null;
}
const claimedGroupId = pathToGroupId.get(path);
if (claimedGroupId) {
return groupPrecedences.get(claimedGroupId) ?? null;
}
return null;
};
const getPrecedence = (pathPrecedences, sourceRef, isLatest) => {
const p = pathPrecedences.get(sourceRefIdentity(canonicalise(sourceRef)));
if (p)
return p;
return isLatest ? HIGHESTPRECEDENCE : LOWESTPRECEDENCE;
};
const isKnownSource = (pathPrecedences, sourceRef) => {
return pathPrecedences.has(sourceRefIdentity(canonicalise(sourceRef)));
};
const isPreferredValue = (pathPrecedences, path, latest, sourceRef, millis) => {
const latestPrecedence = getPrecedence(pathPrecedences, latest.sourceRef, true);
const incomingPrecedence = getPrecedence(pathPrecedences, sourceRef, false);
// Negative timeout means the source is disabled — always reject
if (incomingPrecedence.timeout < 0) {
return false;
}
const latestKnown = isKnownSource(pathPrecedences, latest.sourceRef);
const incomingKnown = isKnownSource(pathPrecedences, sourceRef);
// A configured source must always outrank an unconfigured one:
// if the user ranked source X for this path, X should displace
// any unranked competitor that happens to be publishing the same
// path — otherwise the unknown incumbent keeps its HIGHESTPRECEDENCE
// and the ranked source (with precedence >= 0) can never take over.
if (incomingKnown && !latestKnown) {
return true;
}
// A source updating its own value is always accepted — but only if
// the currently-latest source is actually configured. Otherwise an
// unknown source that briefly won (e.g. because the configured
// source was momentarily silent) would self-renew forever and
// permanently shadow the configured preference.
if (latest.sourceRef === sourceRef && (latestKnown || !incomingKnown)) {
return true;
}
const latestIsFromHigherPrecedence = latestPrecedence.precedence < incomingPrecedence.precedence;
// Baseline: the incoming (lower-ranked) source's timeout governs —
// that is how path-level priority lists express "b may take over
// after 5s of a-silence".
//
// Additional constraint when a known source is winning and an
// unknown source tries to take over: the unknown source must also
// outwait the winner's own timeout. Otherwise a configured source
// with a long timeout can still be stolen after just
// unknownSourceTimeout by any random source that publishes the
// same path.
const holdTimeout = latestKnown && !incomingKnown
? Math.max(latestPrecedence.timeout, incomingPrecedence.timeout)
: incomingPrecedence.timeout;
const isPreferred = !latestIsFromHigherPrecedence || millis - latest.timestamp > holdTimeout;
if (debug.enabled) {
debug(`${path}:${sourceRef}:${isPreferred}:${millis - latest.timestamp}`);
}
return isPreferred;
};
// Mirror of the per-delta resolvePrecedences gate, exposed so the
// deltacache can decide whether a given (path, sourceRef) tuple is
// routed by the engine — i.e. whether updating preferredSources for
// it would mean anything to the admin UI's Priority-filtered view.
// Pass-through paths return false so onValue skips the write and
// the UI renders every source's row without a Preferred badge.
//
// The check is path-driven, NOT source-driven, AND requires ≥2
// publishers seen on the path. A saved group's ranking applies to
// the group's path list, not to every path the group's sources
// happen to publish; a single-publisher path has no contention to
// resolve, so tagging the lone source as "Preferred" would imply a
// decision the engine never made. The 2-publisher gate filters
// those out — the engine still tracks the path internally so a
// late-arriving second publisher gets correctly demoted.
const routesPath = (path, _sourceRef) => {
if (overridePrecedences.has(path))
return true;
if (!pathToGroupId.has(path))
return false;
const publishers = pathSeenPublishers.get(path);
return !!publishers && publishers.size >= 2;
};
const fn = ((delta, now, selfContext) => {
// Excludes act on every context (a plugin subscribed with
// context:'*' still doesn't want its own output back), so run the
// identity check before the self-context guard that gates the
// priority cascade.
if (hasExcludes && delta.updates) {
for (const update of delta.updates) {
if ('values' in update && update.values && update.values.length > 0) {
const canonicalSource = canonicalise(update.$source);
if (excludeIdentities.has(sourceRefIdentity(canonicalSource))) {
update.values = [];
}
}
}
}
if (delta.context === selfContext) {
const millis = now.getTime();
delta.updates &&
delta.updates.forEach((update) => {
if ('values' in update) {
// Translate the source once per update so identity matching,
// self-renew (`latest.sourceRef === sourceRef`), and the
// setLatest snapshot all key off the same canonical form.
// Without this, a saved canName-form ranking never matches
// a numeric-form delta and the engine silently ignores the
// user's preference.
const canonicalSource = canonicalise(update.$source);
update.values = update.values.reduce((acc, pathValue) => {
// Notifications are events, not measurements — never subject
// to source priority. Every source's notifications are
// delivered unchanged.
const p = pathValue.path;
if (p === 'notifications' || p.startsWith('notifications.')) {
acc.push(pathValue);
return acc;
}
// Fan-out path: user explicitly wants every source's
// value delivered (e.g. satellitesInView aggregated
// from multiple GPSes). Bypass identity matching.
if (fanOutPaths.has(p)) {
acc.push(pathValue);
return acc;
}
const path = pathValue.path;
const pathPrecedences = resolvePrecedences(path, canonicalSource);
if (!pathPrecedences) {
// No override and source not in any active group → passthrough.
acc.push(pathValue);
return acc;
}
const latest = getLatest(delta.context, path);
const isPreferred = isPreferredValue(pathPrecedences, path, latest, canonicalSource, millis);
if (isPreferred) {
setLatest(delta.context, path, canonicalSource, millis);
acc.push(pathValue);
return acc;
}
return acc;
}, []);
}
});
}
return delta;
});
fn.routesPath = routesPath;
return fn;
};
exports.getToPreferredDelta = getToPreferredDelta;
//# sourceMappingURL=deltaPriority.js.map