/** * node class.js * * Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use. * This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers. */ const { outputUtils, configManager, convert } = require('generalFunctions'); const Specific = require("./specificClass"); class nodeClass { /** * Create a Node. * @param {object} uiConfig - Node-RED node configuration. * @param {object} RED - Node-RED runtime API. */ constructor(uiConfig, RED, nodeInstance, nameOfNode) { // Preserve RED reference for HTTP endpoints if needed this.node = nodeInstance; // This is the Node-RED node instance, we can use this to send messages and update status this.RED = RED; // This is the Node-RED runtime API, we can use this to create endpoints if needed this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED this.source = null; // Will hold the specific class instance this.config = null; // Will hold the merged configuration this._pressureInitWarned = false; // Load default & UI config this._loadConfig(uiConfig,this.node); // Instantiate core class this._setupSpecificClass(uiConfig); // Wire up event and lifecycle handlers this._bindEvents(); this._registerChild(); this._startTickLoop(); this._attachInputHandler(); this._attachCloseHandler(); } /** * Load and merge default config with user-defined settings. * @param {object} uiConfig - Raw config from Node-RED UI. */ _loadConfig(uiConfig,node) { const cfgMgr = new configManager(); const resolvedAssetUuid = uiConfig.assetUuid || uiConfig.uuid || null; const resolvedAssetTagCode = uiConfig.assetTagCode || uiConfig.assetTagNumber || null; const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow'); const curveUnits = { pressure: this._resolveUnitOrFallback(uiConfig.curvePressureUnit, 'pressure', 'mbar', 'curve pressure'), flow: this._resolveUnitOrFallback(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit, 'curve flow'), power: this._resolveUnitOrFallback(uiConfig.curvePowerUnit, 'power', 'kW', 'curve power'), control: this._resolveControlUnitOrFallback(uiConfig.curveControlUnit, '%'), }; // Build config: base sections + rotatingMachine-specific domain config this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, { flowNumber: uiConfig.flowNumber }); // Override asset with rotatingMachine-specific fields this.config.asset = { ...this.config.asset, uuid: resolvedAssetUuid, tagCode: resolvedAssetTagCode, tagNumber: uiConfig.assetTagNumber || null, unit: flowUnit, curveUnits }; // Ensure general unit uses resolved flow unit this.config.general.unit = flowUnit; // Utility for formatting outputs this._output = new outputUtils(); } _resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) { const raw = typeof candidate === 'string' ? candidate.trim() : ''; const fallback = String(fallbackUnit || '').trim(); if (!raw) { return fallback; } try { const desc = convert().describe(raw); if (expectedMeasure && desc.measure !== expectedMeasure) { throw new Error(`expected '${expectedMeasure}' but got '${desc.measure}'`); } return raw; } catch (error) { this.node?.warn?.(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`); return fallback; } } _resolveControlUnitOrFallback(candidate, fallback = '%') { const raw = typeof candidate === 'string' ? candidate.trim() : ''; return raw || fallback; } /** * Instantiate the core Measurement logic and store as source. */ _setupSpecificClass(uiConfig) { const machineConfig = this.config; // need extra state for this const stateConfig = { general: { logging: { enabled: machineConfig.general.logging.enabled, logLevel: machineConfig.general.logging.logLevel } }, movement: { speed: Number(uiConfig.speed), mode: uiConfig.movementMode }, time: { starting: Number(uiConfig.startup), warmingup: Number(uiConfig.warmup), stopping: Number(uiConfig.shutdown), coolingdown: Number(uiConfig.cooldown) } }; this.source = new Specific(machineConfig, stateConfig); //store in node this.node.source = this.source; // Store the source in the node instance for easy access } /** * Bind events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES */ _bindEvents() { } _updateNodeStatus() { const m = this.source; try { const mode = m.currentMode; const state = m.state.getCurrentState(); const requiresPressurePrediction = ["operational", "warmingup", "accelerating", "decelerating"].includes(state); const pressureStatus = typeof m.getPressureInitializationStatus === "function" ? m.getPressureInitializationStatus() : { initialized: true }; if (requiresPressurePrediction && !pressureStatus.initialized) { if (!this._pressureInitWarned) { this.node.warn("Pressure input is not initialized (upstream/downstream missing). Predictions are using minimum pressure."); this._pressureInitWarned = true; } return { fill: "yellow", shape: "ring", text: `${mode}: pressure not initialized` }; } if (pressureStatus.initialized) { this._pressureInitWarned = false; } const flowUnit = m?.config?.general?.unit || 'm3/h'; const flow = Math.round(m.measurements.type("flow").variant("predicted").position('downstream').getCurrentValue(flowUnit)); const power = Math.round(m.measurements.type("power").variant("predicted").position('atEquipment').getCurrentValue('kW')); let symbolState; switch(state){ case "off": symbolState = "⬛"; break; case "idle": symbolState = "⏸️"; break; case "operational": symbolState = "⏵️"; break; case "starting": symbolState = "⏯️"; break; case "warmingup": symbolState = "🔄"; break; case "accelerating": symbolState = "⏩"; break; case "stopping": symbolState = "⏹️"; break; case "coolingdown": symbolState = "❄️"; break; case "decelerating": symbolState = "⏪"; break; case "maintenance": symbolState = "🔧"; break; } const position = m.state.getCurrentPosition(); const roundedPosition = Math.round(position * 100) / 100; let status; switch (state) { case "off": status = { fill: "red", shape: "dot", text: `${mode}: OFF` }; break; case "idle": status = { fill: "blue", shape: "dot", text: `${mode}: ${symbolState}` }; break; case "operational": status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` }; break; case "starting": status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` }; break; case "warmingup": status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` }; break; case "accelerating": status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}%| 💨${flow}${flowUnit} | ⚡${power}kW` }; break; case "stopping": status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` }; break; case "coolingdown": status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` }; break; case "decelerating": status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} - ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` }; break; default: status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` }; } return status; } catch (error) { this.node.error("Error in updateNodeStatus: " + error.message); return { fill: "red", shape: "ring", text: "Status Error" }; } } /** * Register this node as a child upstream and downstream. * Delayed to avoid Node-RED startup race conditions. */ _registerChild() { setTimeout(() => { this.node.send([ null, null, { topic: 'registerChild', payload: this.node.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' }, ]); }, 100); } /** * Start the periodic tick loop. */ _startTickLoop() { this._startupTimeout = setTimeout(() => { this._startupTimeout = null; this._tickInterval = setInterval(() => this._tick(), 1000); // Update node status on nodered screen every second this._statusInterval = setInterval(() => { const status = this._updateNodeStatus(); this.node.status(status); }, 1000); }, 1000); } /** * Execute a single tick: update measurement, format and send outputs. */ _tick() { //this.source.tick(); const raw = this.source.getOutput(); const processMsg = this._output.formatMsg(raw, this.source.config, 'process'); const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb'); // Send only updated outputs on ports 0 & 1 this.node.send([processMsg, influxMsg, null]); } /** * Attach the node's input handler, routing control messages to the class. */ _attachInputHandler() { this.node.on('input', async (msg, send, done) => { const m = this.source; const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg); try { switch(msg.topic) { case 'registerChild': { const childId = msg.payload; const childObj = this.RED.nodes.getNode(childId); if (!childObj || !childObj.source) { this.node.warn(`registerChild failed: child '${childId}' not found or has no source`); break; } m.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent); break; } case 'setMode': m.setMode(msg.payload); break; case 'execSequence': { const { source, action, parameter } = msg.payload; await m.handleInput(source, action, parameter); break; } case 'execMovement': { const { source: mvSource, action: mvAction, setpoint } = msg.payload; await m.handleInput(mvSource, mvAction, Number(setpoint)); break; } case 'flowMovement': { const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload; await m.handleInput(fmSource, fmAction, Number(fmSetpoint)); break; } case 'emergencystop': { const { source: esSource, action: esAction } = msg.payload; await m.handleInput(esSource, esAction); break; } case 'simulateMeasurement': { const payload = msg.payload || {}; const type = String(payload.type || '').toLowerCase(); const position = payload.position || 'atEquipment'; const value = Number(payload.value); const unit = typeof payload.unit === 'string' ? payload.unit.trim() : ''; const supportedTypes = new Set(['pressure', 'flow', 'temperature', 'power']); const context = { timestamp: payload.timestamp || Date.now(), unit, childName: 'dashboard-sim', childId: 'dashboard-sim', }; if (!Number.isFinite(value)) { this.node.warn('simulateMeasurement payload.value must be a finite number'); break; } if (!supportedTypes.has(type)) { this.node.warn(`Unsupported simulateMeasurement type: ${type}`); break; } if (!unit) { this.node.warn('simulateMeasurement payload.unit is required'); break; } if (typeof m.isUnitValidForType === 'function' && !m.isUnitValidForType(type, unit)) { this.node.warn(`simulateMeasurement payload.unit '${unit}' is invalid for type '${type}'`); break; } switch (type) { case 'pressure': if (typeof m.updateSimulatedMeasurement === "function") { m.updateSimulatedMeasurement(type, position, value, context); } else { m.updateMeasuredPressure(value, position, context); } break; case 'flow': m.updateMeasuredFlow(value, position, context); break; case 'temperature': m.updateMeasuredTemperature(value, position, context); break; case 'power': m.updateMeasuredPower(value, position, context); break; } } break; case 'showWorkingCurves': nodeSend([{ ...msg, topic : "showWorkingCurves" , payload: m.showWorkingCurves() }, null, null]); break; case 'CoG': nodeSend([{ ...msg, topic : "showCoG" , payload: m.showCoG() }, null, null]); break; } if (typeof done === 'function') done(); } catch (error) { if (typeof done === 'function') { done(error); } else { this.node.error(error, msg); } } }); } /** * Clean up timers and intervals when Node-RED stops the node. */ _attachCloseHandler() { this.node.on('close', (done) => { clearTimeout(this._startupTimeout); clearInterval(this._tickInterval); clearInterval(this._statusInterval); // Clean up child measurement listeners const m = this.source; if (m?.childMeasurementListeners) { for (const [, entry] of m.childMeasurementListeners) { if (typeof entry.emitter?.off === 'function') { entry.emitter.off(entry.eventName, entry.handler); } else if (typeof entry.emitter?.removeListener === 'function') { entry.emitter.removeListener(entry.eventName, entry.handler); } } m.childMeasurementListeners.clear(); } // Clean up state emitter listeners if (m?.state?.emitter) { m.state.emitter.removeAllListeners(); } if (typeof done === 'function') done(); }); } } module.exports = nodeClass;