diff --git a/monster.html b/monster.html
index 044c5d8..5f52b5b 100644
--- a/monster.html
+++ b/monster.html
@@ -10,6 +10,8 @@
// Define default properties
name: { value: "" },
+ processOutputFormat: { value: "process" },
+ dbaseOutputFormat: { value: "influxdb" },
// Define specific properties
samplingtime: { value: 0 },
@@ -64,7 +66,7 @@
};
waitForMenuData();
- // your existing project‐settings & asset dropdown logic can remain here
+ // your existing project-settings & asset dropdown logic can remain here
document.getElementById("node-input-samplingtime");
document.getElementById("node-input-minvolume");
document.getElementById("node-input-maxweight");
diff --git a/monster.js b/monster.js
index ae13f64..3f14963 100644
--- a/monster.js
+++ b/monster.js
@@ -1,5 +1,5 @@
const nameOfNode = 'monster';
-const nodeClass = require('./src/nodeClass.js');
+const nodeClass = require('./src/nodeClass.js');
const { MenuManager, configManager } = require('generalFunctions');
module.exports = function(RED) {
diff --git a/src/nodeClass.js b/src/nodeClass.js
index 04887c2..6621a99 100644
--- a/src/nodeClass.js
+++ b/src/nodeClass.js
@@ -5,25 +5,25 @@
* 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");
+const Specific = require('./specificClass');
class nodeClass {
/**
* Create a Node.
* @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.
*/
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.node = nodeInstance;
+ this.RED = RED;
+ this.name = nameOfNode;
+ this.source = null;
+ this.config = null;
// Load default & UI config
- this._loadConfig(uiConfig,this.node);
+ this._loadConfig(uiConfig);
// Instantiate core class
this._setupSpecificClass(uiConfig);
@@ -38,46 +38,34 @@ class nodeClass {
/**
* Load and merge default config with user-defined settings.
+ * Uses ConfigManager.buildConfig() for base sections, then adds monster-specific domain config.
* @param {object} uiConfig - Raw config from Node-RED UI.
*/
- _loadConfig(uiConfig,node) {
+ _loadConfig(uiConfig) {
const cfgMgr = new configManager();
- this.defaultConfig = cfgMgr.getConfig(this.name);
- // Merge UI config over defaults
- this.config = {
- general: {
- name: uiConfig.name || uiConfig.category || this.name,
- id: node.id, // node.id is for the child registration process
- unit: uiConfig.unit, // add converter options later to convert to default units (need like a model that defines this which units we are going to use and then conver to those standards)
- logging: {
- enabled: uiConfig.enableLog,
- logLevel: uiConfig.logLevel
- }
- },
- asset: {
- uuid: uiConfig.uuid,
- tagCode: uiConfig.assetTagCode,
- supplier: uiConfig.supplier,
- category: uiConfig.category, //add later to define as the software type
- type: uiConfig.assetType,
- model: uiConfig.model,
- unit: uiConfig.unit,
- emptyWeightBucket: Number(uiConfig.emptyWeightBucket)
- },
+ // Build config: base sections + monster-specific domain config
+ this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
constraints: {
- samplingtime: Number(uiConfig.samplingtime),
- minVolume: Number(uiConfig.minvolume),
- maxWeight: Number(uiConfig.maxweight),
- nominalFlowMin: Number(uiConfig.nominalFlowMin),
- flowMax: Number(uiConfig.flowMax),
- maxRainRef: Number(uiConfig.maxRainRef),
- minSampleIntervalSec: Number(uiConfig.minSampleIntervalSec),
+ samplingtime: Number(uiConfig.samplingtime) || 0,
+ minVolume: Number(uiConfig.minvolume ?? uiConfig.minVolume) || 5,
+ maxWeight: Number(uiConfig.maxweight ?? uiConfig.maxWeight) || 23,
+ nominalFlowMin: Number(uiConfig.nominalFlowMin) || 0,
+ flowMax: Number(uiConfig.flowMax) || 0,
+ maxRainRef: Number(uiConfig.maxRainRef) || 10,
+ minSampleIntervalSec: Number(uiConfig.minSampleIntervalSec) || 60,
},
- functionality: {
- positionVsParent: uiConfig.positionVsParent || 'atEquipment',
- distance: uiConfig.hasDistance ? uiConfig.distance : undefined
- }
+ });
+
+ this.config.functionality = {
+ ...this.config.functionality,
+ role: 'samplingCabinet',
+ aquonSampleName: uiConfig.aquon_sample_name || undefined,
+ };
+
+ this.config.asset = {
+ ...this.config.asset,
+ emptyWeightBucket: Number(uiConfig.emptyWeightBucket) || 3,
};
// Utility for formatting outputs
@@ -85,67 +73,62 @@ class nodeClass {
}
/**
- * Instantiate the core Measurement logic and store as source.
+ * Instantiate the core logic and store as source.
*/
_setupSpecificClass(uiConfig) {
- const monsterConfig = this.config;
-
- this.source = new Specific(monsterConfig);
+ this.source = new Specific(this.config);
if (uiConfig?.aquon_sample_name) {
this.source.aquonSampleName = uiConfig.aquon_sample_name;
}
- //store in node
- this.node.source = this.source; // Store the source in the node instance for easy access
-
+ this.node.source = this.source;
}
/**
- * Bind events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES
+ * Bind events to Node-RED status updates.
*/
- _bindEvents() {
-
- }
+ _bindEvents() {}
_updateNodeStatus() {
const m = this.source;
-try{
- const bucketVol = m.bucketVol;
- const maxVolume = m.maxVolume;
- const state = m.running;
- const mode = "AI"; //m.mode;
- const flowMin = m.nominalFlowMin;
- const flowMax = m.flowMax;
+ try {
+ const bucketVol = m.bucketVol;
+ const maxVolume = m.maxVolume;
+ const state = m.running;
+ const mode = 'AI';
+ const flowMin = m.nominalFlowMin;
+ const flowMax = m.flowMax;
- if (m.invalidFlowBounds) {
- return {
- fill: "red",
- shape: "ring",
- text: `Config error: nominalFlowMin (${flowMin}) >= flowMax (${flowMax})`
- };
- }
-
- if (state) {
- const levelText = `${bucketVol}/${maxVolume} L`;
- const cooldownMs = typeof m.getSampleCooldownMs === 'function'
- ? m.getSampleCooldownMs()
- : 0;
-
- if (cooldownMs > 0) {
- const cooldownSec = Math.ceil(cooldownMs / 1000);
- return { fill: "yellow", shape: "ring", text: `SAMPLING (${cooldownSec}s) ${levelText}` };
- }
-
- return { fill: "green", shape: "dot", text: `${mode}: RUNNING ${levelText}` };
- }
-
- return { fill: "grey", shape: "ring", text: `${mode}: IDLE` };
- } catch (error) {
- this.node.error("Error in updateNodeStatus: " + error);
- return { fill: "red", shape: "ring", text: "Status Error" };
+ if (m.invalidFlowBounds) {
+ return {
+ fill: 'red',
+ shape: 'ring',
+ text: `Config error: nominalFlowMin (${flowMin}) >= flowMax (${flowMax})`,
+ };
}
+
+ if (state) {
+ const levelText = `${bucketVol}/${maxVolume} L`;
+ const cooldownMs = typeof m.getSampleCooldownMs === 'function'
+ ? m.getSampleCooldownMs()
+ : 0;
+
+ if (cooldownMs > 0) {
+ const cooldownSec = Math.ceil(cooldownMs / 1000);
+ return { fill: 'yellow', shape: 'ring', text: `SAMPLING (${cooldownSec}s) ${levelText}` };
+ }
+
+ return { fill: 'green', shape: 'dot', text: `${mode}: RUNNING ${levelText}` };
+ }
+
+ return { fill: 'grey', shape: 'ring', text: `${mode}: IDLE` };
+ } 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.
@@ -166,13 +149,9 @@ try{
_startTickLoop() {
setTimeout(() => {
this._tickInterval = setInterval(() => this._tick(), 1000);
-
- // 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);
+ this.node.status(this._updateNodeStatus());
}, 1000);
-
}, 1000);
}
@@ -184,9 +163,8 @@ try{
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');
+ const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
- // Send only updated outputs on ports 0 & 1
this.node.send([processMsg, influxMsg]);
}
@@ -195,10 +173,9 @@ try{
*/
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
- /* Update to complete event based node by putting the tick function after an input event */
const m = this.source;
try {
- switch(msg.topic) {
+ switch (msg.topic) {
case 'input_q': {
const value = Number(msg.payload?.value);
const unit = msg.payload?.unit;
@@ -232,33 +209,13 @@ try{
case 'setMode':
m.setMode(msg.payload);
break;
- case 'execSequence': {
- const { source, action, parameter } = msg.payload || {};
- m.handleInput(source, action, parameter);
- break;
- }
- case 'execMovement': {
- const { source: mvSource, action: mvAction, setpoint } = msg.payload || {};
- m.handleInput(mvSource, mvAction, Number(setpoint));
- break;
- }
- case 'flowMovement': {
- const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload || {};
- m.handleInput(fmSource, fmAction, Number(fmSetpoint));
- break;
- }
- case 'emergencystop': {
- const { source: esSource, action: esAction } = msg.payload || {};
- m.handleInput(esSource, esAction);
- break;
- }
- case 'showWorkingCurves':
- send({ topic : "Showing curve" , payload: m.showWorkingCurves() });
- break;
- case 'CoG':
- send({ topic : "Showing CoG" , payload: m.showCoG() });
+ case 'model_prediction':
+ if (typeof m.setModelPrediction === 'function') {
+ m.setModelPrediction(msg.payload);
+ }
break;
default:
+ m.logger?.warn(`Unknown topic: ${msg.topic}`);
break;
}
} catch (error) {
diff --git a/test/specificClass.test.js b/test/specificClass.test.js
new file mode 100644
index 0000000..8adbdb7
--- /dev/null
+++ b/test/specificClass.test.js
@@ -0,0 +1,95 @@
+const Monster = require('../src/specificClass');
+
+describe('monster specificClass', () => {
+ function createMonster(overrides = {}) {
+ return new Monster({
+ general: {
+ name: 'Monster Test',
+ unit: 'm3/h',
+ logging: {
+ enabled: false,
+ logLevel: 'error',
+ },
+ },
+ asset: {
+ emptyWeightBucket: 3,
+ },
+ constraints: {
+ samplingtime: 1,
+ minVolume: 5,
+ maxWeight: 23,
+ },
+ functionality: {
+ aquonSampleName: '112100',
+ },
+ ...overrides,
+ });
+ }
+
+ test('aggregates rain data and exposes output state', () => {
+ const monster = createMonster();
+
+ monster.rain_data = [
+ {
+ latitude: 51.7,
+ longitude: 4.81,
+ hourly: {
+ time: ['2026-03-12T00:00', '2026-03-12T01:00'],
+ precipitation: [1, 3],
+ precipitation_probability: [100, 50],
+ },
+ },
+ {
+ latitude: 51.8,
+ longitude: 4.91,
+ hourly: {
+ time: ['2026-03-12T00:00', '2026-03-12T01:00'],
+ precipitation: [2, 2],
+ precipitation_probability: [100, 100],
+ },
+ },
+ ];
+
+ const output = monster.getOutput();
+
+ expect(monster.sumRain).toBe(6.5);
+ expect(monster.avgRain).toBe(3.25);
+ expect(output.sumRain).toBe(6.5);
+ expect(output.avgRain).toBe(3.25);
+ });
+
+ test('supports external prediction input and starts sampling safely', () => {
+ const monster = createMonster();
+
+ monster.setModelPrediction(120);
+ monster.q = 3600;
+ monster.i_start = true;
+ monster.flowTime = Date.now() - 1000;
+
+ monster.tick();
+
+ const output = monster.getOutput();
+ expect(output.running).toBe(true);
+ expect(output.predFlow).toBe(120);
+ expect(output.predM3PerSec).toBeCloseTo(120 / 3600, 6);
+ });
+
+ test('calculates the next AQUON date from monsternametijden input', () => {
+ const monster = createMonster();
+ const nextMonth = new Date();
+ nextMonth.setMonth(nextMonth.getMonth() + 1);
+
+ monster.monsternametijden = [
+ {
+ SAMPLE_NAME: '112100',
+ DESCRIPTION: 'future sample',
+ SAMPLED_DATE: null,
+ START_DATE: nextMonth.toISOString(),
+ END_DATE: nextMonth.toISOString(),
+ },
+ ];
+
+ expect(monster.daysPerYear).toBeGreaterThanOrEqual(0);
+ expect(monster.nextDate).toBeGreaterThan(Date.now());
+ });
+});