UNPKG

signalk-server

Version:

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

447 lines 21.8 kB
"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