UNPKG

signalk-server

Version:

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

268 lines 11.6 kB
"use strict"; 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.migrateSourceRef = migrateSourceRef; const config_1 = require("./config/config"); const debug_1 = require("./debug"); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const debug = (0, debug_1.createDebug)('signalk-server:sourceref-migration'); const LABELS_FILENAME = 'n2k-channel-labels.json'; /** * canboatjs fires `n2kSourceChanged` whenever the same bus address * starts claiming a different CAN Name. That can mean two very * different things: * * - "Reclaim": one physical device rebooted, was reassigned, or * swapped its address — the old CAN Name disappears from the bus. * - "Takeover": a different physical device joined the bus and * happened to land on an address the original device was already * using; the original device then arbitrates and moves to a new * address — both CAN Names continue to exist on the bus. * * The migration code rewrites every reference to oldRef so it points * at newRef. That is correct for a reclaim but corrupts the saved * priority group on a takeover, since the original device's rank * silently moves to the unrelated newcomer. * * Distinguish the two by looking at app.signalk.sources: if any device * under the same provider still carries the old CAN Name (under any * address), this is a takeover and the migration must be skipped. */ function oldCanNameStillOnBus(app, oldRef) { const sources = app.signalk?.sources; if (!sources || typeof sources !== 'object') return false; const dotIdx = oldRef.indexOf('.'); if (dotIdx === -1) return false; const providerId = oldRef.slice(0, dotIdx); // CAN Name suffixes are 16-hex strings. Persisted refs are normalised // to lowercase upstream, but a settings file edited by hand or // imported from another tool may still have uppercase characters — // compare case-insensitively so the takeover guard isn't fooled by // mixed casing. Plugin sources have no canName suffix and fall // through to the normal migration path. const oldCanName = oldRef.slice(dotIdx + 1).toLowerCase(); if (!/^[0-9a-f]{16}$/.test(oldCanName)) return false; const conn = sources[providerId]; if (!conn || typeof conn !== 'object') return false; for (const [key, dev] of Object.entries(conn)) { if (key === 'type' || key === 'label') continue; // eslint-disable-next-line @typescript-eslint/no-explicit-any const canName = dev?.n2k?.canName; if (typeof canName === 'string' && canName.toLowerCase() === oldCanName) { return true; } } return false; } function migrateSourceRef(app, oldRef, newRef) { if (oldCanNameStillOnBus(app, oldRef)) { debug('Skipping migration %s -> %s: old CAN Name is still claimed by another device on the bus (address takeover, not a reclaim).', oldRef, newRef); return; } const settings = app.config.settings; let settingsChanged = false; const migrated = new Set(); // 1. priorityOverrides (path-level) — dedupe per path if newRef already present if (settings.priorityOverrides) { for (const [, entries] of Object.entries(settings.priorityOverrides)) { if (!Array.isArray(entries)) continue; const hasNewRef = entries.some((e) => e.sourceRef === newRef); if (hasNewRef) { const before = entries.length; const filtered = entries.filter((e) => e.sourceRef !== oldRef); if (filtered.length !== before) { entries.length = 0; entries.push(...filtered); settingsChanged = true; migrated.add('priorityOverrides'); } } else { for (const entry of entries) { if (entry.sourceRef === oldRef) { entry.sourceRef = newRef; settingsChanged = true; migrated.add('priorityOverrides'); } } } } } // 2. sourceAliases — keep existing newRef alias if present if (settings.sourceAliases && oldRef in settings.sourceAliases) { if (!(newRef in settings.sourceAliases)) { settings.sourceAliases[newRef] = settings.sourceAliases[oldRef]; } delete settings.sourceAliases[oldRef]; settingsChanged = true; migrated.add('sourceAliases'); } // 3. ignoredInstanceConflicts (keys are "refA+refB" sorted pairs) if (settings.ignoredInstanceConflicts) { const updates = []; for (const [key, value] of Object.entries(settings.ignoredInstanceConflicts)) { const parts = key.split('+'); if (parts.includes(oldRef)) { const newParts = parts.map((p) => (p === oldRef ? newRef : p)); const newKey = newParts.sort().join('+'); updates.push({ oldKey: key, newKey, value }); } } for (const { oldKey, newKey, value } of updates) { delete settings.ignoredInstanceConflicts[oldKey]; if (!(newKey in settings.ignoredInstanceConflicts)) { settings.ignoredInstanceConflicts[newKey] = value; } settingsChanged = true; } if (updates.length > 0) { migrated.add('ignoredInstanceConflicts'); } } // 4. priorityGroups — rewrite or dedupe sourceRef inside each group if (settings.priorityGroups) { for (const group of settings.priorityGroups) { const hasNewRef = group.sources.includes(newRef); if (hasNewRef) { const before = group.sources.length; const filtered = group.sources.filter((ref) => ref !== oldRef); if (filtered.length !== before) { group.sources = filtered; settingsChanged = true; migrated.add('priorityGroups'); } } else { const idx = group.sources.indexOf(oldRef); if (idx !== -1) { group.sources[idx] = newRef; settingsChanged = true; migrated.add('priorityGroups'); } } } } // 5. Channel labels file const labelsPath = path.join(app.config.configPath, LABELS_FILENAME); try { const raw = fs.readFileSync(labelsPath, 'utf-8'); const labels = JSON.parse(raw); const oldPrefix = oldRef + ':'; const labelUpdates = []; for (const [key, value] of Object.entries(labels)) { if (key.startsWith(oldPrefix)) { const suffix = key.slice(oldPrefix.length); labelUpdates.push([key, `${newRef}:${suffix}`, value]); } } if (labelUpdates.length > 0) { for (const [oldKey, newKey, value] of labelUpdates) { delete labels[oldKey]; if (!(newKey in labels)) { labels[newKey] = value; } } // Sync write keeps callers (including the migration tests) able to // observe the new file shape immediately on return. Channel labels // files are tiny (one entry per N2K instance) so the blocking cost // is negligible at startup. fs.writeFileSync(labelsPath, JSON.stringify(labels, null, 2)); migrated.add('channelLabels'); } } catch (e) { if (e.code !== 'ENOENT') { debug('Failed to migrate channel labels: %s', e.message); } } const finalize = () => { // 6. Clean up deltaCache for old sourceRef. Only safe to do once // settings have been persisted — otherwise a write failure would // leave disk pointing at the old ref while the cache has already // dropped its leaf, producing two-second-of-amnesia on restart. app.deltaCache.removeSource(oldRef); // 7. Recompile priority engine app.activateSourcePriorities(); // 8. Notify clients (only for sections that were actually migrated) if (migrated.has('priorityOverrides') && settings.priorityOverrides) { app.emit('serverevent', { type: 'PRIORITYOVERRIDES', data: settings.priorityOverrides }); } if (migrated.has('sourceAliases') && settings.sourceAliases) { app.emit('serverAdminEvent', { type: 'SOURCEALIASES', data: settings.sourceAliases }); } if (migrated.has('priorityGroups') && settings.priorityGroups) { app.emit('serverAdminEvent', { type: 'PRIORITYGROUPS', data: settings.priorityGroups }); } if (migrated.size > 0) { console.log(`sourceRef migrated ${oldRef} -> ${newRef}: ${[...migrated].join(', ')}`); } debug('Migration complete: %s -> %s (%s)', oldRef, newRef, [...migrated].join(', ')); }; if (settingsChanged) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (0, config_1.writeSettingsFile)(app, settings, (err) => { if (err) { console.error('Failed to save settings after sourceRef migration:', err); // Still finalise: in-memory state has already been mutated // (settings, deltaCache references), so skipping the priority // engine recompile and event broadcast would leave the running // server inconsistent with itself. The disk/memory divergence // self-heals on the next successful settings write. } finalize(); }); } else { // Nothing to persist (e.g. only channelLabels file was rewritten). finalize(); } } //# sourceMappingURL=sourceref-migration.js.map