/** * BaseNodeAdapter — shared nodeClass scaffolding. * * Consolidates the boilerplate every node's nodeClass.js repeats today * (config build → domain instantiate → registration delay → tick loop → * status loop → input dispatch → close handler). Subclasses declare what * varies (DomainClass, commands, output strategy) via static fields and * override `buildDomainConfig(uiConfig, nodeId)` to produce the per-node * config slice. * * See CONTRACTS.md §2; OPEN_QUESTIONS.md (event-driven default + tick * fire-and-forget resolution, 2026-05-10). */ 'use strict'; const ConfigManager = require('../configs/index.js'); const OutputUtils = require('../helper/outputUtils.js'); const { createRegistry } = require('./commandRegistry.js'); const { StatusUpdater } = require('./statusUpdater.js'); const convert = require('../convert'); const REGISTRATION_DELAY_MS = 100; function _buildImplicitUnitsCommand(getCommands, getNodeName) { return { topic: 'query.units', payloadSchema: { type: 'any' }, description: 'Returns the unit spec (measure, default, accepted) for every topic that declares units.', handler: (source, msg, ctx) => { const units = {}; for (const d of getCommands()) { if (!d.units) continue; const accepted = (convert && typeof convert.possibilities === 'function') ? convert.possibilities(d.units.measure) : []; units[d.topic] = { measure: d.units.measure, default: d.units.default, accepted, }; } const reply = Object.assign({}, msg, { topic: 'query.units', payload: { node: getNodeName(), units }, }); if (ctx && typeof ctx.send === 'function') ctx.send([reply, null, null]); }, }; } class BaseNodeAdapter { constructor(uiConfig, RED, nodeInstance, nameOfNode) { const ctor = this.constructor; if (ctor === BaseNodeAdapter) { throw new Error('BaseNodeAdapter is abstract; subclass it and declare static DomainClass + commands'); } if (typeof ctor.DomainClass !== 'function') { throw new Error(`${ctor.name}: static DomainClass is required (a class to instantiate)`); } if (!Array.isArray(ctor.commands)) { throw new Error(`${ctor.name}: static commands is required (array of descriptors; use [] for none)`); } if (typeof this.buildDomainConfig !== 'function') { throw new Error(`${ctor.name}: must implement buildDomainConfig(uiConfig, nodeId)`); } this.node = nodeInstance; this.RED = RED; this.name = nameOfNode; const cfgMgr = new ConfigManager(); this.defaultConfig = cfgMgr.getConfig(this.name); this.config = cfgMgr.buildConfig( this.name, uiConfig, this.node.id, this.buildDomainConfig(uiConfig, this.node.id) || {}, ); this.source = new ctor.DomainClass(this.config); // Sibling-node lookup uses RED.nodes.getNode(id).source — see existing // pumpingStation/measurement nodeClass _attachInputHandler patterns. this.node.source = this.source; this._output = new OutputUtils(); const userHasUnitsQuery = ctor.commands.some( (c) => c && (c.topic === 'query.units' || (Array.isArray(c.aliases) && c.aliases.includes('query.units')))); const mergedCommands = userHasUnitsQuery ? ctor.commands : ctor.commands.concat([_buildImplicitUnitsCommand( () => this._commands.list(), () => this.name, )]); this._commands = createRegistry(mergedCommands, { logger: this.source?.logger }); this._tickInterval = null; this._outputChangedListener = null; this._scheduleRegistration(); this._wireOutputs(); this._statusUpdater = new StatusUpdater({ node: this.node, source: this.source, intervalMs: ctor.statusInterval ?? 1000, logger: this.source?.logger, }); this._statusUpdater.start(); this._attachInputHandler(); this._attachCloseHandler(); if (typeof this.extraSetup === 'function') this.extraSetup(); } _scheduleRegistration() { // Delayed so siblings have finished constructing before the parent // receives the registration message. setTimeout(() => { this.node.send([ null, null, { topic: 'child.register', payload: this.node.id, positionVsParent: this.config?.functionality?.positionVsParent ?? 'atEquipment', distance: this.config?.functionality?.distance ?? null, }, ]); }, REGISTRATION_DELAY_MS); } _wireOutputs() { const ctor = this.constructor; const interval = ctor.tickInterval; if (typeof interval === 'number' && interval > 0) { this._tickInterval = setInterval(() => { // Fire-and-forget per OPEN_QUESTIONS 2026-05-10. Domain owns // its own serialisation via LatestWinsGate when needed. try { this.source.tick?.(); } catch (err) { this.source?.logger?.error?.(`tick threw: ${err.message}`); } this._emitOutputs(); }, interval); return; } // Event-driven default: domain emits 'output-changed' when its // public output state shifts; adapter pushes outputs in response. const emitter = this.source?.emitter; if (emitter && typeof emitter.on === 'function') { this._outputChangedListener = () => this._emitOutputs(); emitter.on('output-changed', this._outputChangedListener); } } _emitOutputs() { if (typeof this.source.getOutput !== 'function') return; const raw = this.source.getOutput(); const cfg = this.source.config || this.config; const processMsg = this._output.formatMsg(raw, cfg, 'process'); const influxMsg = this._output.formatMsg(raw, cfg, 'influxdb'); this.node.send([processMsg, influxMsg, null]); } _attachInputHandler() { this.node.on('input', async (msg, send, done) => { try { await this._commands.dispatch(msg, this.source, { node: this.node, RED: this.RED, send, logger: this.source?.logger, }); if (typeof this.extraInputDispatch === 'function') { await this.extraInputDispatch(msg, send, done); } } catch (err) { this.source?.logger?.error?.(err.message); } finally { if (typeof done === 'function') done(); } }); } _attachCloseHandler() { this.node.on('close', (done) => { try { if (this._tickInterval) { clearInterval(this._tickInterval); this._tickInterval = null; } if (this._outputChangedListener && this.source?.emitter?.off) { this.source.emitter.off('output-changed', this._outputChangedListener); this._outputChangedListener = null; } this._statusUpdater?.stop(); this.source?.close?.(); if (typeof this.extraClose === 'function') this.extraClose(); try { this.node.status({}); } catch (_) { /* best effort */ } } catch (err) { this.source?.logger?.error?.(`close handler threw: ${err.message}`); } finally { if (typeof done === 'function') done(); } }); } } // Defaults overridable via subclass static fields. BaseNodeAdapter.tickInterval = null; BaseNodeAdapter.statusInterval = 1000; module.exports = BaseNodeAdapter;