node-red-contrib-event-calc 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  * event-calc - Calculation node for multi-topic expressions
3
3
  *
4
4
  * Features:
5
- * - Maps multiple variables to topic patterns
5
+ * - Maps variables to exact topics
6
6
  * - Evaluates JavaScript expressions when inputs update
7
7
  * - Trigger modes: 'any' (any input updates) or 'all' (all inputs have values)
8
8
  * - Safe expression evaluation using Function constructor
@@ -10,6 +10,7 @@
10
10
  * - Built-in helper functions for common operations
11
11
  */
12
12
  module.exports = function(RED) {
13
+
13
14
  // Helper functions available in expressions
14
15
  const helpers = {
15
16
  // Math shortcuts
@@ -67,7 +68,6 @@ module.exports = function(RED) {
67
68
  node.outputTopic = config.outputTopic || 'calc/result';
68
69
 
69
70
  const subscriptionIds = [];
70
- const latestValues = new Map(); // name -> { topic, value, ts }
71
71
 
72
72
  if (!node.cacheConfig) {
73
73
  node.status({ fill: "red", shape: "ring", text: "no cache configured" });
@@ -84,32 +84,43 @@ module.exports = function(RED) {
84
84
  return;
85
85
  }
86
86
 
87
+ // Track subscribed topics to ignore updates from our own output
88
+ const subscribedTopics = new Set();
89
+ for (const input of node.inputMappings) {
90
+ const topicName = input.topic || input.pattern;
91
+ if (topicName) {
92
+ subscribedTopics.add(topicName);
93
+ }
94
+ }
95
+
87
96
  /**
88
97
  * Attempt to calculate and output result
89
98
  */
90
- function tryCalculate(triggerTopic) {
91
- // Check if we should trigger
99
+ function tryCalculate(triggerTopic, latestValues) {
100
+ // Ignore updates triggered by our own output
101
+ if (triggerTopic === node.outputTopic) {
102
+ return;
103
+ }
104
+
92
105
  if (node.triggerOn === 'all') {
93
- // All inputs must have values
94
106
  for (const input of node.inputMappings) {
95
107
  if (!latestValues.has(input.name)) {
96
- return; // Not all values available yet
108
+ return;
97
109
  }
98
110
  }
99
111
  }
100
112
 
101
- // At least one value must exist to calculate
102
113
  if (latestValues.size === 0) {
103
114
  return;
104
115
  }
105
116
 
106
- // Build context object for expression evaluation
107
117
  const context = {};
108
118
  const inputDetails = {};
119
+ const missingInputs = [];
109
120
 
110
121
  for (const input of node.inputMappings) {
111
122
  const data = latestValues.get(input.name);
112
- if (data) {
123
+ if (data && data.value !== undefined && data.value !== null) {
113
124
  context[input.name] = data.value;
114
125
  inputDetails[input.name] = {
115
126
  topic: data.topic,
@@ -118,63 +129,98 @@ module.exports = function(RED) {
118
129
  };
119
130
  } else {
120
131
  context[input.name] = undefined;
132
+ missingInputs.push(input.name);
121
133
  }
122
134
  }
123
135
 
124
- // Evaluate expression safely
136
+ // Build topics mapping: variable name -> topic
137
+ const topics = { _output: node.outputTopic };
138
+ const timestamps = {};
139
+ for (const [name, details] of Object.entries(inputDetails)) {
140
+ topics[name] = details.topic;
141
+ timestamps[name] = details.ts;
142
+ }
143
+
125
144
  try {
126
- // Create a function with named parameters from context + helpers
127
145
  const allParams = { ...helpers, ...context };
128
146
  const paramNames = Object.keys(allParams);
129
147
  const paramValues = Object.values(allParams);
130
148
 
131
- // Build function body with helpers and variables available
132
149
  const fn = new Function(...paramNames, `return ${node.expression};`);
133
150
  const result = fn(...paramValues);
134
151
 
152
+ // Check for NaN or invalid result
153
+ if (typeof result === 'number' && isNaN(result)) {
154
+ const errorMsg = {
155
+ topic: node.outputTopic,
156
+ payload: {
157
+ error: 'Expression resulted in NaN',
158
+ missingInputs: missingInputs,
159
+ expression: node.expression
160
+ },
161
+ inputs: inputDetails,
162
+ trigger: triggerTopic,
163
+ timestamp: Date.now()
164
+ };
165
+ node.send([null, errorMsg]);
166
+ node.status({ fill: "yellow", shape: "ring", text: "NaN" });
167
+ return;
168
+ }
169
+
135
170
  const msg = {
136
171
  topic: node.outputTopic,
137
172
  payload: result,
173
+ topics: topics,
138
174
  inputs: inputDetails,
175
+ timestamps: timestamps,
139
176
  expression: node.expression,
140
177
  trigger: triggerTopic,
141
178
  timestamp: Date.now()
142
179
  };
143
180
 
144
- node.send(msg);
181
+ node.send([msg, null]);
145
182
 
146
- // Store result back in cache so it can be used by other calculations
147
183
  node.cacheConfig.setValue(node.outputTopic, result, {
148
184
  source: 'event-calc',
149
185
  expression: node.expression,
150
186
  inputs: Object.keys(inputDetails)
151
187
  });
152
188
 
153
- // Update status with result (truncate if too long)
154
189
  const resultStr = String(result);
155
190
  const displayResult = resultStr.length > 15 ? resultStr.substring(0, 12) + '...' : resultStr;
156
191
  node.status({ fill: "green", shape: "dot", text: `= ${displayResult}` });
157
192
 
158
193
  } catch (err) {
194
+ const errorMsg = {
195
+ topic: node.outputTopic,
196
+ payload: {
197
+ error: err.message,
198
+ expression: node.expression,
199
+ context: context
200
+ },
201
+ inputs: inputDetails,
202
+ trigger: triggerTopic,
203
+ timestamp: Date.now()
204
+ };
205
+ node.send([null, errorMsg]);
159
206
  node.status({ fill: "red", shape: "ring", text: "eval error" });
160
- node.error(`Expression evaluation failed: ${err.message}`, { expression: node.expression, context: context });
161
207
  }
162
208
  }
163
209
 
164
- // Subscribe to each input pattern
210
+ // Subscribe to inputs
211
+ const latestValues = new Map();
212
+
165
213
  for (const input of node.inputMappings) {
166
- if (!input.name || !input.pattern) {
167
- continue;
168
- }
214
+ const topicName = input.topic || input.pattern;
215
+ if (!input.name || !topicName) continue;
169
216
 
170
- const subId = node.cacheConfig.subscribe(input.pattern, (topic, entry) => {
217
+ const subId = node.cacheConfig.subscribe(topicName, (topic, entry) => {
171
218
  latestValues.set(input.name, {
172
219
  topic: topic,
173
220
  value: entry.value,
174
221
  ts: entry.ts
175
222
  });
176
-
177
- tryCalculate(topic);
223
+ tryCalculate(topic, latestValues);
178
224
  });
179
225
  subscriptionIds.push(subId);
180
226
  }
@@ -193,9 +239,11 @@ module.exports = function(RED) {
193
239
  node.status({ fill: "blue", shape: "dot", text: "expr updated" });
194
240
  }
195
241
 
196
- // Force recalculation
242
+ // Force recalculation (use special topic to bypass self-output check)
197
243
  if (msg.payload === 'recalc' || msg.topic === 'recalc') {
198
- tryCalculate('manual');
244
+ if (latestValues.size > 0) {
245
+ tryCalculate('_recalc', latestValues);
246
+ }
199
247
  }
200
248
 
201
249
  done();
@@ -208,7 +256,6 @@ module.exports = function(RED) {
208
256
  }
209
257
  }
210
258
  subscriptionIds.length = 0;
211
- latestValues.clear();
212
259
  done();
213
260
  });
214
261
  }
@@ -0,0 +1,239 @@
1
+ <style>
2
+ .event-calc-white-text { fill: #ffffff !important; }
3
+ .red-ui-palette-node[data-palette-type="event-chart"] .red-ui-palette-label { color: #ffffff !important; }
4
+ </style>
5
+
6
+ <script type="text/javascript">
7
+ RED.nodes.registerType('event-chart', {
8
+ category: 'event calc',
9
+ color: '#758467',
10
+ defaults: {
11
+ name: { value: "" },
12
+ title: { value: "Event Chart" },
13
+ maxPoints: { value: 200, validate: RED.validators.number() },
14
+ timestampField: { value: "timestamp" },
15
+ valueField: { value: "payload" },
16
+ seriesField: { value: "topic" }
17
+ },
18
+ inputs: 1,
19
+ outputs: 0,
20
+ icon: "font-awesome/fa-line-chart",
21
+ paletteLabel: "chart",
22
+ label: function() {
23
+ return this.name || this.title || "Event Chart";
24
+ },
25
+ labelStyle: function() {
26
+ return (this.name ? "node_label_italic" : "") + " event-calc-white-text";
27
+ },
28
+ oneditprepare: function() {
29
+ var node = this;
30
+ var chartInstance = null;
31
+ var container = document.getElementById('event-chart-preview-container');
32
+
33
+ // Create canvas
34
+ var canvas = document.createElement('canvas');
35
+ canvas.id = 'event-chart-canvas-' + node.id;
36
+ canvas.style.width = '100%';
37
+ canvas.style.height = '100%';
38
+ container.innerHTML = '';
39
+ container.appendChild(canvas);
40
+
41
+ // Clear button
42
+ $('#event-chart-clear-btn').on('click', function() {
43
+ $.post('event-chart/' + node.id + '/clear', function(data) {
44
+ if (chartInstance) {
45
+ chartInstance.data.datasets = [];
46
+ chartInstance.update('none');
47
+ }
48
+ }).fail(function() {
49
+ RED.notify("Failed to clear chart", "error");
50
+ });
51
+ });
52
+
53
+ function loadScript(src) {
54
+ return new Promise(function(resolve, reject) {
55
+ if (document.querySelector('script[src="' + src + '"]')) {
56
+ resolve();
57
+ return;
58
+ }
59
+ var script = document.createElement('script');
60
+ script.src = src;
61
+ script.onload = resolve;
62
+ script.onerror = reject;
63
+ document.head.appendChild(script);
64
+ });
65
+ }
66
+
67
+ function initChart() {
68
+ if (chartInstance) {
69
+ chartInstance.destroy();
70
+ }
71
+
72
+ var ctx = document.getElementById('event-chart-canvas-' + node.id);
73
+ if (!ctx) return;
74
+
75
+ chartInstance = new Chart(ctx, {
76
+ type: 'line',
77
+ data: { datasets: [] },
78
+ options: {
79
+ responsive: true,
80
+ maintainAspectRatio: false,
81
+ animation: false,
82
+ scales: {
83
+ x: {
84
+ type: 'linear',
85
+ title: { display: true, text: 'Time' },
86
+ ticks: {
87
+ callback: function(value) {
88
+ return new Date(value).toLocaleTimeString();
89
+ }
90
+ }
91
+ },
92
+ y: { title: { display: true, text: 'Value' } }
93
+ },
94
+ plugins: {
95
+ legend: { position: 'top' },
96
+ title: { display: true, text: node.title || 'Event Chart' }
97
+ }
98
+ }
99
+ });
100
+
101
+ // Subscribe to chart data updates
102
+ RED.comms.subscribe("event-chart-data-" + node.id, function(topic, msg) {
103
+ if (chartInstance && msg && msg.data) {
104
+ updateChart(msg.data);
105
+ }
106
+ });
107
+
108
+ // Load initial data
109
+ $.getJSON('event-chart/' + node.id + '/data', function(data) {
110
+ if (data && data.data) {
111
+ updateChart(data.data);
112
+ }
113
+ });
114
+ }
115
+
116
+ function updateChart(data) {
117
+ if (!chartInstance) return;
118
+
119
+ var colors = [
120
+ '#2196F3', '#FF5722', '#4CAF50', '#9C27B0',
121
+ '#FF9800', '#00BCD4', '#E91E63', '#8BC34A',
122
+ '#3F51B5', '#CDDC39', '#009688', '#FFC107'
123
+ ];
124
+ var datasets = [];
125
+ var i = 0;
126
+ for (var series in data) {
127
+ datasets.push({
128
+ label: series,
129
+ data: data[series],
130
+ borderColor: colors[i % colors.length],
131
+ backgroundColor: colors[i % colors.length] + '20',
132
+ pointRadius: 2,
133
+ borderWidth: 2,
134
+ tension: 0.1,
135
+ fill: false
136
+ });
137
+ i++;
138
+ }
139
+ chartInstance.data.datasets = datasets;
140
+ chartInstance.update('none');
141
+ }
142
+
143
+ // Load Chart.js
144
+ if (typeof Chart === 'undefined') {
145
+ loadScript('https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js')
146
+ .then(function() {
147
+ setTimeout(initChart, 100);
148
+ })
149
+ .catch(function(err) {
150
+ container.innerHTML = '<p style="color:red">Failed to load Chart.js</p>';
151
+ });
152
+ } else {
153
+ initChart();
154
+ }
155
+ },
156
+ oneditcancel: function() {
157
+ RED.comms.unsubscribe("event-chart-data-" + this.id);
158
+ },
159
+ oneditsave: function() {
160
+ RED.comms.unsubscribe("event-chart-data-" + this.id);
161
+ }
162
+ });
163
+ </script>
164
+
165
+ <script type="text/html" data-template-name="event-chart">
166
+ <div class="form-row">
167
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
168
+ <input type="text" id="node-input-name" placeholder="Name">
169
+ </div>
170
+ <div class="form-row">
171
+ <label for="node-input-title"><i class="fa fa-header"></i> Title</label>
172
+ <input type="text" id="node-input-title" placeholder="Event Chart">
173
+ </div>
174
+ <div class="form-row">
175
+ <label for="node-input-valueField"><i class="fa fa-line-chart"></i> Value Field</label>
176
+ <input type="text" id="node-input-valueField" placeholder="payload">
177
+ </div>
178
+ <div class="form-row">
179
+ <label for="node-input-timestampField"><i class="fa fa-clock-o"></i> Timestamp Field</label>
180
+ <input type="text" id="node-input-timestampField" placeholder="timestamp">
181
+ </div>
182
+ <div class="form-row">
183
+ <label for="node-input-seriesField"><i class="fa fa-tags"></i> Series Field</label>
184
+ <input type="text" id="node-input-seriesField" placeholder="topic">
185
+ </div>
186
+ <div class="form-row">
187
+ <label for="node-input-maxPoints"><i class="fa fa-database"></i> Max Points</label>
188
+ <input type="number" id="node-input-maxPoints" placeholder="200">
189
+ </div>
190
+ <div class="form-row">
191
+ <label><i class="fa fa-area-chart"></i> Preview</label>
192
+ <div id="event-chart-preview-container" style="height: 250px; border: 1px solid #ccc; border-radius: 4px; padding: 5px; background: #fafafa;">
193
+ <p style="color:#888; text-align:center; padding-top:100px;">Loading chart...</p>
194
+ </div>
195
+ </div>
196
+ <div class="form-row">
197
+ <button type="button" id="event-chart-clear-btn" class="red-ui-button" style="width: 100%;">
198
+ <i class="fa fa-trash"></i> Clear Chart Data
199
+ </button>
200
+ </div>
201
+ </script>
202
+
203
+ <script type="text/html" data-help-name="event-chart">
204
+ <p>Displays time-series data on an interactive chart.</p>
205
+
206
+ <h3>Inputs</h3>
207
+ <dl class="message-properties">
208
+ <dt>payload <span class="property-type">number</span></dt>
209
+ <dd>The value to plot (configurable field name)</dd>
210
+ <dt class="optional">timestamp <span class="property-type">number</span></dt>
211
+ <dd>Unix timestamp in ms (defaults to current time)</dd>
212
+ <dt class="optional">topic <span class="property-type">string</span></dt>
213
+ <dd>Series name for grouping data (default: "default")</dd>
214
+ </dl>
215
+
216
+ <h3>Properties</h3>
217
+ <ul>
218
+ <li><b>Title</b> - Chart title displayed at top</li>
219
+ <li><b>Value Field</b> - Message property for value (default: payload)</li>
220
+ <li><b>Timestamp Field</b> - Message property for timestamp</li>
221
+ <li><b>Series Field</b> - Message property for series name</li>
222
+ <li><b>Max Points</b> - Maximum points per series (older points removed)</li>
223
+ </ul>
224
+
225
+ <h3>Usage</h3>
226
+ <p>Connect to any node that outputs numeric data. The chart will automatically:</p>
227
+ <ul>
228
+ <li>Group data by series (topic)</li>
229
+ <li>Display multiple series with different colors</li>
230
+ <li>Show real-time updates as data arrives</li>
231
+ <li>Limit data points to prevent memory issues</li>
232
+ </ul>
233
+
234
+ <h3>Clear Data</h3>
235
+ <p>Use the "Clear Chart Data" button, or send <code>msg.payload = "_clear"</code>.</p>
236
+
237
+ <h3>Preview</h3>
238
+ <p>Double-click the node to see a live preview of the chart with current data.</p>
239
+ </script>
@@ -0,0 +1,106 @@
1
+ module.exports = function(RED) {
2
+ function EventChartNode(config) {
3
+ RED.nodes.createNode(this, config);
4
+ const node = this;
5
+
6
+ node.title = config.title || 'Event Chart';
7
+ node.maxPoints = parseInt(config.maxPoints) || 200;
8
+ node.timestampField = config.timestampField || 'timestamp';
9
+ node.valueField = config.valueField || 'payload';
10
+ node.seriesField = config.seriesField || 'topic';
11
+
12
+ // Store data per series
13
+ node.chartData = {};
14
+
15
+ node.clearChart = function() {
16
+ node.chartData = {};
17
+ node.status({ fill: "grey", shape: "ring", text: "cleared" });
18
+ emitData();
19
+ };
20
+
21
+ node.on('input', function(msg) {
22
+ // Handle clear command
23
+ if (msg.payload === '_clear' || msg.topic === '_clear') {
24
+ node.clearChart();
25
+ return;
26
+ }
27
+
28
+ const series = RED.util.getMessageProperty(msg, node.seriesField) || 'default';
29
+ let timestamp = RED.util.getMessageProperty(msg, node.timestampField);
30
+ let value = RED.util.getMessageProperty(msg, node.valueField);
31
+
32
+ // Handle timestamp
33
+ if (timestamp === undefined || timestamp === null) {
34
+ timestamp = Date.now();
35
+ } else if (typeof timestamp !== 'number') {
36
+ timestamp = new Date(timestamp).getTime();
37
+ }
38
+
39
+ // Handle value
40
+ if (value === undefined || value === null) {
41
+ return;
42
+ }
43
+
44
+ if (typeof value !== 'number') {
45
+ value = parseFloat(value);
46
+ if (isNaN(value)) return;
47
+ }
48
+
49
+ if (!node.chartData[series]) {
50
+ node.chartData[series] = [];
51
+ }
52
+
53
+ node.chartData[series].push({
54
+ x: timestamp,
55
+ y: value
56
+ });
57
+
58
+ // Limit points per series
59
+ if (node.chartData[series].length > node.maxPoints) {
60
+ node.chartData[series].shift();
61
+ }
62
+
63
+ const totalPoints = Object.values(node.chartData).reduce((sum, arr) => sum + arr.length, 0);
64
+ const seriesCount = Object.keys(node.chartData).length;
65
+ node.status({ fill: "green", shape: "dot", text: `${seriesCount} series, ${totalPoints} pts` });
66
+
67
+ emitData();
68
+ });
69
+
70
+ function emitData() {
71
+ RED.comms.publish("event-chart-data-" + node.id, {
72
+ id: node.id,
73
+ title: node.title,
74
+ data: node.chartData
75
+ });
76
+ }
77
+
78
+ node.on('close', function() {
79
+ node.chartData = {};
80
+ node.status({});
81
+ });
82
+ }
83
+
84
+ RED.nodes.registerType("event-chart", EventChartNode);
85
+
86
+ // Clear chart data endpoint
87
+ RED.httpAdmin.post("/event-chart/:id/clear", function(req, res) {
88
+ const node = RED.nodes.getNode(req.params.id);
89
+ if (node && node.clearChart) {
90
+ node.clearChart();
91
+ res.sendStatus(200);
92
+ } else {
93
+ res.status(404).send("Node not found");
94
+ }
95
+ });
96
+
97
+ // Get chart data endpoint
98
+ RED.httpAdmin.get("/event-chart/:id/data", function(req, res) {
99
+ const node = RED.nodes.getNode(req.params.id);
100
+ if (node) {
101
+ res.json({ title: node.title, data: node.chartData || {} });
102
+ } else {
103
+ res.status(404).send("Node not found");
104
+ }
105
+ });
106
+ };
@@ -1,7 +1,12 @@
1
+ <style>
2
+ .event-calc-white-text { fill: #ffffff !important; }
3
+ .red-ui-palette-node[data-palette-type="event-in"] .red-ui-palette-label { color: #ffffff !important; }
4
+ </style>
5
+
1
6
  <script type="text/javascript">
2
7
  RED.nodes.registerType('event-in', {
3
8
  category: 'event calc',
4
- color: '#87CEEB',
9
+ color: '#758467',
5
10
  defaults: {
6
11
  name: { value: "" },
7
12
  cache: { value: "", type: "event-cache", required: true },
@@ -14,7 +19,8 @@
14
19
  label: function() {
15
20
  return this.name || "event in";
16
21
  },
17
- paletteLabel: "event in"
22
+ paletteLabel: "event in",
23
+ labelStyle: function() { return (this.name ? "node_label_italic" : "") + " event-calc-white-text"; }
18
24
  });
19
25
  </script>
20
26