const { outputUtils, configManager, convert } = require("generalFunctions"); const Specific = require("./specificClass"); class nodeClass { /** * Create a MeasurementNode. * @param {object} uiConfig - Node-RED node configuration. * @param {object} RED - Node-RED runtime API. * @param {object} nodeInstance - The Node-RED node instance. * @param {string} nameOfNode - The name of the node, used for */ 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 // Load default & UI config this._loadConfig(uiConfig, this.node); this._reconcileIntervalMs = this._resolveReconcileIntervalMs(uiConfig); // Instantiate core Measurement class this._setupSpecificClass(); // 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(); this.defaultConfig = cfgMgr.getConfig(this.name); // Resolve flow unit with validation before building config const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow'); const resolvedUiConfig = { ...uiConfig, unit: flowUnit }; // Build config: base sections (no domain-specific config for group controller) this.config = cfgMgr.buildConfig(this.name, resolvedUiConfig, node.id); // 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; } } _resolveReconcileIntervalMs(uiConfig) { const raw = Number( uiConfig?.reconcileIntervalSeconds ?? uiConfig?.reconcileIntervalSec ?? uiConfig?.reconcileEverySeconds ?? 1 ); const sec = Number.isFinite(raw) && raw > 0 ? raw : 1; return Math.max(100, Math.round(sec * 1000)); } _updateNodeStatus() { const vg = this.source; const mode = vg.currentMode; const flowUnit = vg?.unitPolicy?.output?.flow || this.config.general.unit || "m3/h"; const measuredFlow = vg.measurements.type("flow").variant("measured").position("atEquipment").getCurrentValue(flowUnit); const predictedFlow = vg.measurements.type("flow").variant("predicted").position("atEquipment").getCurrentValue(flowUnit); const totalFlowRaw = Number.isFinite(measuredFlow) ? measuredFlow : predictedFlow; const totalFlow = Number.isFinite(totalFlowRaw) ? Math.round(totalFlowRaw) : 0; const availableValves = Array.isArray(vg.getAvailableValves?.()) ? vg.getAvailableValves() : []; // const totalCapacity = Math.round(vg.dynamicTotals.flow.max * 1) / 1; ADD LATER? // Determine overall status based on available valves const status = availableValves.length > 0 ? `${availableValves.length} valve(s) connected` : "No valves"; // Generate status text in a single line const text = `${mode} | flow=${totalFlow} ${flowUnit} | ${status}`; return { fill: availableValves.length > 0 ? "green" : "red", shape: "dot", text, }; } /** * Instantiate the core logic and store as source. */ _setupSpecificClass() { this.source = new Specific(this.config); 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() { } /** * 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 to drive the Measurement class. */ _startTickLoop() { setTimeout(() => { this._tickInterval = setInterval(() => this._tick(), this._reconcileIntervalMs); // Update node status on nodered screen every second ( this is not the best way to do this, but it works for now) this._statusInterval = setInterval(() => { const status = this._updateNodeStatus(); this.node.status(status); }, 1000); }, 1000); } /** * Execute a single tick: update measurement, format and send outputs. */ _tick() { if (typeof this.source?.calcValveFlows === 'function') { this.source.calcValveFlows(); } const raw = this.source.getOutput(); const processMsg = this._output.formatMsg(raw, this.config, "process"); const influxMsg = this._output.formatMsg(raw, this.config, "influxdb"); // Send only updated outputs on ports 0 & 1 this.node.send([processMsg, influxMsg]); } /** * Attach the node's input handler, routing control messages to the class. */ _attachInputHandler() { this.node.on( "input", async (msg, send, done) => { const vg = this.source; const RED = this.RED; try { switch (msg.topic) { case "registerChild": { const childId = msg.payload; const childObj = RED.nodes.getNode(childId); if (!childObj || !childObj.source) { vg.logger.warn(`registerChild skipped: missing child/source for id=${childId}`); break; } vg.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent); break; } case 'setMode': vg.setMode(msg.payload); break; case 'setReconcileInterval': { const nextSec = Number(msg.payload); if (!Number.isFinite(nextSec) || nextSec <= 0) { vg.logger.warn(`Invalid reconcile interval payload '${msg.payload}'. Expected seconds > 0.`); break; } this._reconcileIntervalMs = Math.max(100, Math.round(nextSec * 1000)); clearInterval(this._tickInterval); this._tickInterval = setInterval(() => this._tick(), this._reconcileIntervalMs); vg.logger.info(`Flow reconciliation interval updated to ${nextSec}s (${this._reconcileIntervalMs}ms).`); break; } case 'execSequence': { const { source: seqSource, action: seqAction, parameter } = msg.payload; vg.handleInput(seqSource, seqAction, parameter); break; } case 'totalFlowChange': { const payload = msg.payload || {}; if (payload && typeof payload === "object" && Object.prototype.hasOwnProperty.call(payload, "source")) { const tfcSource = payload.source || "parent"; const tfcAction = payload.action || "totalFlowChange"; vg.handleInput(tfcSource, tfcAction, payload); } else { vg.handleInput("parent", "totalFlowChange", payload); } break; } case 'emergencystop': case 'emergencyStop': { const payload = msg.payload || {}; const esSource = payload.source || "parent"; vg.handleInput(esSource, "emergencystop"); break; } default: vg.logger.warn(`Unknown topic: ${msg.topic}`); break; } } catch (error) { vg.logger.error(`Input handler failure: ${error.message}`); } if (typeof done === 'function') done(); } ); } /** * Clean up timers and intervals when Node-RED stops the node. */ _attachCloseHandler() { this.node.on("close", (done) => { clearInterval(this._tickInterval); clearInterval(this._statusInterval); this.source?.destroy?.(); if (typeof done === 'function') done(); }); } } module.exports = nodeClass; // Export the class for Node-RED to use