node-red-contrib-event-calc 3.3.3 → 3.3.15

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.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * event-frame - ISA-88 batch structure tracking node
3
+ *
4
+ * Creates ISA-88 procedural model records:
5
+ * Procedure > Unit Procedure > Operation > Phase
6
+ *
7
+ * Features:
8
+ * - Trigger-based: starts on true, ends on false
9
+ * - Auto-generated unique IDs
10
+ * - Chainable: start output carries frame_id for child nodes to use as parent_id
11
+ * - Auto-end children: when a parent ends, all children with matching parent_id end too
12
+ * - Hierarchical parent-child linking via global context dictionary
13
+ * - Outputs complete batch record on end
14
+ */
15
+ module.exports = function(RED) {
16
+ const crypto = require('crypto');
17
+ const EventEmitter = require('events');
18
+
19
+ // Shared emitter for end-children cascade (one per contextKey)
20
+ const sharedEmitters = new Map();
21
+
22
+ function getEmitter(contextKey) {
23
+ if (!sharedEmitters.has(contextKey)) {
24
+ const emitter = new EventEmitter();
25
+ emitter.setMaxListeners(100);
26
+ sharedEmitters.set(contextKey, emitter);
27
+ }
28
+ return sharedEmitters.get(contextKey);
29
+ }
30
+
31
+ function EventFrameNode(config) {
32
+ RED.nodes.createNode(this, config);
33
+ const node = this;
34
+
35
+ node.level = config.level || 'operation';
36
+ node.batchName = config.batchName || '';
37
+ node.batchNameType = config.batchNameType || 'str';
38
+ node.unit = config.unit || '';
39
+ node.unitType = config.unitType || 'str';
40
+ node.batchId = config.batchId || '';
41
+ node.batchIdType = config.batchIdType || 'str';
42
+ node.parentLevel = config.parentLevel || '';
43
+ node.triggerField = config.triggerField || 'payload';
44
+ node.metadataField = config.metadataField || '';
45
+ node.contextKey = config.contextKey || 'isa88_batches';
46
+ node.endChildren = config.endChildren !== false; // default true
47
+
48
+ const globalContext = node.context().global;
49
+ const emitter = getEmitter(node.contextKey);
50
+
51
+ // Tracker key: use node.id so multiple nodes at the same level can coexist
52
+ const trackerKey = node.id;
53
+
54
+ function getTracker() {
55
+ return globalContext.get(node.contextKey) || {};
56
+ }
57
+
58
+ function setTracker(tracker) {
59
+ globalContext.set(node.contextKey, tracker);
60
+ }
61
+
62
+ function generateId() {
63
+ return crypto.randomUUID();
64
+ }
65
+
66
+ function resolveValue(value, type, msg) {
67
+ if (!value) return '';
68
+ switch (type) {
69
+ case 'msg': return RED.util.getMessageProperty(msg, value) || '';
70
+ case 'flow': return node.context().flow.get(value) || '';
71
+ case 'global': return globalContext.get(value) || '';
72
+ case 'env': return RED.util.evaluateNodeProperty(value, 'env', node, msg) || '';
73
+ default: return value;
74
+ }
75
+ }
76
+
77
+ // Resolve parent_id: msg.frame_id (from chained wiring) > parentLevel lookup
78
+ function resolveParentId(tracker, msg) {
79
+ if (msg.frame_id) {
80
+ return msg.frame_id;
81
+ }
82
+ if (node.parentLevel) {
83
+ // Search tracker for an active record at the parent level
84
+ for (const key in tracker) {
85
+ const entry = tracker[key];
86
+ if (entry && entry.active && entry.record && entry.record.level === node.parentLevel) {
87
+ return entry.id;
88
+ }
89
+ }
90
+ }
91
+ return '';
92
+ }
93
+
94
+ /**
95
+ * End the current frame record, emit cascade event, and output
96
+ */
97
+ function endFrame(send) {
98
+ const tracker = getTracker();
99
+ const entry = tracker[trackerKey];
100
+ if (!entry || !entry.active) return null;
101
+
102
+ const record = entry.record;
103
+ record.endtime = new Date().toISOString();
104
+ record.state = 'complete';
105
+
106
+ // Clear active record
107
+ tracker[trackerKey] = { active: false };
108
+ setTracker(tracker);
109
+
110
+ node.status({ fill: "grey", shape: "ring", text: `${node.level}: complete` });
111
+
112
+ const endMsg = {
113
+ topic: `batch/${node.level}/end`,
114
+ payload: { ...record },
115
+ frame_id: record.id
116
+ };
117
+
118
+ if (send) {
119
+ send([null, endMsg]);
120
+ } else {
121
+ node.send([null, endMsg]);
122
+ }
123
+
124
+ // Cascade: signal children to end
125
+ if (node.endChildren) {
126
+ emitter.emit('end-frame', record.id);
127
+ }
128
+
129
+ return record;
130
+ }
131
+
132
+ // Listen for parent ending — auto-end this frame if our parent_id matches
133
+ function onParentEnd(parentId) {
134
+ const tracker = getTracker();
135
+ const entry = tracker[trackerKey];
136
+ if (entry && entry.active && entry.record && entry.record.parent_id === parentId) {
137
+ endFrame(null);
138
+ }
139
+ }
140
+
141
+ emitter.on('end-frame', onParentEnd);
142
+
143
+ node.status({ fill: "grey", shape: "ring", text: "idle" });
144
+
145
+ node.on('input', function(msg, send, done) {
146
+ send = send || function() { node.send.apply(node, arguments); };
147
+ done = done || function(err) { if (err) node.error(err, msg); };
148
+
149
+ try {
150
+ // Extract trigger value
151
+ let trigger;
152
+ if (node.triggerField.startsWith('msg.')) {
153
+ trigger = RED.util.getMessageProperty(msg, node.triggerField.substring(4));
154
+ } else {
155
+ trigger = RED.util.getMessageProperty(msg, node.triggerField);
156
+ }
157
+ const isActive = !!trigger;
158
+
159
+ const tracker = getTracker();
160
+ const entry = tracker[trackerKey] || { active: false };
161
+
162
+ if (isActive && !entry.active) {
163
+ // ── START frame record ──
164
+ const id = generateId();
165
+ const batchName = resolveValue(node.batchName, node.batchNameType, msg);
166
+ const unitVal = resolveValue(node.unit, node.unitType, msg);
167
+ const batchIdVal = resolveValue(node.batchId, node.batchIdType, msg);
168
+ const parentId = resolveParentId(tracker, msg);
169
+
170
+ // Extract optional metadata
171
+ let metadata = '';
172
+ if (node.metadataField) {
173
+ const raw = RED.util.getMessageProperty(msg, node.metadataField);
174
+ metadata = (raw !== undefined) ? (typeof raw === 'string' ? raw : JSON.stringify(raw)) : '';
175
+ }
176
+
177
+ const record = {
178
+ id: id,
179
+ starttime: new Date().toISOString(),
180
+ endtime: '9999-12-31T23:59:59.000Z',
181
+ name: batchName,
182
+ parent_id: parentId,
183
+ level: node.level,
184
+ state: 'running',
185
+ batch_id: batchIdVal,
186
+ unit: unitVal,
187
+ metadata: metadata
188
+ };
189
+
190
+ // Store active record in tracker
191
+ tracker[trackerKey] = {
192
+ active: true,
193
+ id: id,
194
+ record: record
195
+ };
196
+ setTracker(tracker);
197
+
198
+ node.status({ fill: "green", shape: "dot", text: `${node.level}: running` });
199
+
200
+ // Output start event — frame_id enables chaining to child nodes
201
+ const startMsg = {
202
+ topic: `batch/${node.level}/start`,
203
+ payload: { ...record },
204
+ frame_id: id
205
+ };
206
+ send([startMsg, null]);
207
+ done();
208
+
209
+ } else if (!isActive && entry.active) {
210
+ // ── END frame record ──
211
+ endFrame(send);
212
+ done();
213
+
214
+ } else {
215
+ // No state change
216
+ done();
217
+ }
218
+ } catch (err) {
219
+ node.status({ fill: "red", shape: "ring", text: "error" });
220
+ done(err);
221
+ }
222
+ });
223
+
224
+ node.on('close', function(done) {
225
+ emitter.removeListener('end-frame', onParentEnd);
226
+ done();
227
+ });
228
+ }
229
+
230
+ RED.nodes.registerType("event-frame", EventFrameNode);
231
+
232
+ // Admin endpoint to get current batch tracker state
233
+ RED.httpAdmin.get("/event-frame/tracker", function(req, res) {
234
+ const contextKey = req.query.key || 'isa88_batches';
235
+ let tracker = {};
236
+ RED.nodes.eachNode(function(n) {
237
+ if (n.type === 'event-frame') {
238
+ const frameNode = RED.nodes.getNode(n.id);
239
+ if (frameNode) {
240
+ tracker = frameNode.context().global.get(contextKey) || {};
241
+ }
242
+ }
243
+ });
244
+ res.json(tracker);
245
+ });
246
+ };
@@ -0,0 +1,125 @@
1
+ <style>
2
+ .event-calc-white-text { fill: #ffffff !important; }
3
+ .red-ui-palette-node[data-palette-type="simple-frame"] .red-ui-palette-label { color: #ffffff !important; }
4
+ </style>
5
+
6
+ <script type="text/javascript">
7
+ RED.nodes.registerType('simple-frame', {
8
+ category: 'event calc',
9
+ color: '#4A90D9',
10
+ defaults: {
11
+ name: { value: "" },
12
+ eventType: { value: "" },
13
+ eventTypeType: { value: "str" },
14
+ eventName: { value: "" },
15
+ eventNameType: { value: "str" },
16
+ triggerField: { value: "payload" },
17
+ metadataField: { value: "" },
18
+ outputTopic: { value: "" }
19
+ },
20
+ inputs: 1,
21
+ outputs: 2,
22
+ outputLabels: ["start", "end"],
23
+ icon: "font-awesome/fa-bookmark",
24
+ label: function() {
25
+ return this.name || "simple frame";
26
+ },
27
+ paletteLabel: "simple frame",
28
+ labelStyle: function() { return (this.name ? "node_label_italic" : "") + " event-calc-white-text"; },
29
+ oneditprepare: function() {
30
+ $("#node-input-eventType").typedInput({
31
+ default: 'str',
32
+ types: ['str', 'msg', 'flow', 'global', 'env'],
33
+ typeField: "#node-input-eventTypeType"
34
+ });
35
+ $("#node-input-eventName").typedInput({
36
+ default: 'str',
37
+ types: ['str', 'msg', 'flow', 'global', 'env'],
38
+ typeField: "#node-input-eventNameType"
39
+ });
40
+ }
41
+ });
42
+ </script>
43
+
44
+ <script type="text/html" data-template-name="simple-frame">
45
+ <div class="form-row">
46
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
47
+ <input type="text" id="node-input-name" placeholder="Name">
48
+ </div>
49
+ <div class="form-row">
50
+ <label for="node-input-eventType"><i class="fa fa-bookmark"></i> Event Type</label>
51
+ <input type="text" id="node-input-eventType" placeholder="e.g. maintenance">
52
+ <input type="hidden" id="node-input-eventTypeType">
53
+ </div>
54
+ <div class="form-row">
55
+ <label for="node-input-eventName"><i class="fa fa-pencil"></i> Event Name</label>
56
+ <input type="text" id="node-input-eventName" placeholder="e.g. Oil Change">
57
+ <input type="hidden" id="node-input-eventNameType">
58
+ </div>
59
+ <div class="form-row">
60
+ <label for="node-input-triggerField"><i class="fa fa-bolt"></i> Trigger Field</label>
61
+ <input type="text" id="node-input-triggerField" placeholder="payload">
62
+ <div class="form-tips">Message property: <code>truthy</code> starts, <code>falsy</code> ends the event.</div>
63
+ </div>
64
+ <div class="form-row">
65
+ <label for="node-input-metadataField"><i class="fa fa-info-circle"></i> Metadata Field</label>
66
+ <input type="text" id="node-input-metadataField" placeholder="e.g. metadata">
67
+ <div class="form-tips">Optional <code>msg</code> property containing extra metadata (string or object).</div>
68
+ </div>
69
+ <div class="form-row">
70
+ <label for="node-input-outputTopic"><i class="fa fa-envelope"></i> Output Topic</label>
71
+ <input type="text" id="node-input-outputTopic" placeholder="auto: event/{type}/start|end">
72
+ <div class="form-tips">Override <code>msg.topic</code>. Leave blank for automatic: <code>event/{type}/start</code> and <code>event/{type}/end</code>.</div>
73
+ </div>
74
+ </script>
75
+
76
+ <script type="text/html" data-help-name="simple-frame">
77
+ <p>A simple event frame that tracks start and end times.</p>
78
+
79
+ <h3>Properties</h3>
80
+ <dl class="message-properties">
81
+ <dt>Event Type</dt>
82
+ <dd>A label for the kind of event (e.g. "maintenance", "downtime", "shift").
83
+ Can be a static string, msg property, flow/global context, or env variable.</dd>
84
+ <dt>Event Name</dt>
85
+ <dd>A descriptive name for the event (e.g. "Oil Change", "Day Shift").
86
+ Supports the same input types as Event Type.</dd>
87
+ <dt>Trigger Field</dt>
88
+ <dd>Message property: truthy starts, falsy ends the event record.</dd>
89
+ <dt>Metadata Field</dt>
90
+ <dd>Optional msg property for extra metadata (stored as string).</dd>
91
+ <dt>Output Topic</dt>
92
+ <dd>Override msg.topic. Leave blank to auto-generate <code>event/{type}/start</code> and <code>event/{type}/end</code>.</dd>
93
+ </dl>
94
+
95
+ <h3>Inputs</h3>
96
+ <p>Any message. The trigger field determines start/end:</p>
97
+ <ul>
98
+ <li><b>Truthy</b> (and no active event): starts a new event record</li>
99
+ <li><b>Falsy</b> (and event is active): ends the current record</li>
100
+ </ul>
101
+
102
+ <h3>Outputs</h3>
103
+ <ol>
104
+ <li><b>Start</b> — emits when a new event begins (state: "running")</li>
105
+ <li><b>End</b> — emits the completed record with <code>endtime</code> filled in (state: "complete")</li>
106
+ </ol>
107
+
108
+ <h3>Output Record</h3>
109
+ <pre>{
110
+ "id": "auto-generated UUID",
111
+ "starttime": "2024-01-15T10:30:00.000Z",
112
+ "endtime": "9999-12-31T23:59:59.000Z",
113
+ "type": "maintenance",
114
+ "name": "Oil Change",
115
+ "state": "complete",
116
+ "metadata": ""
117
+ }</pre>
118
+
119
+ <h3>Details</h3>
120
+ <p>A simplified event frame — no ISA-88 hierarchy, no parent-child
121
+ relationships, no cascade. Just a clean start/end record for any event you
122
+ want to track.</p>
123
+ <p>Each node tracks one active event at a time. Send a truthy trigger to start,
124
+ and a falsy trigger to end. The active record is stored in node context.</p>
125
+ </script>
@@ -0,0 +1,126 @@
1
+ /**
2
+ * simple-frame - Simple event recorder node
3
+ *
4
+ * Records events with start/end time, type, name, and metadata.
5
+ * No ISA-88 hierarchy, no parent-child relationships, no cascade.
6
+ *
7
+ * Features:
8
+ * - Trigger-based: truthy starts, falsy ends
9
+ * - Auto-generated unique IDs
10
+ * - Configurable type and name (str/msg/flow/global/env)
11
+ * - Optional metadata from msg field
12
+ * - Two outputs: [start, end]
13
+ */
14
+ module.exports = function(RED) {
15
+ const crypto = require('crypto');
16
+
17
+ function SimpleFrameNode(config) {
18
+ RED.nodes.createNode(this, config);
19
+ const node = this;
20
+
21
+ node.eventType = config.eventType || '';
22
+ node.eventTypeType = config.eventTypeType || 'str';
23
+ node.eventName = config.eventName || '';
24
+ node.eventNameType = config.eventNameType || 'str';
25
+ node.triggerField = config.triggerField || 'payload';
26
+ node.metadataField = config.metadataField || '';
27
+ node.outputTopic = config.outputTopic || '';
28
+
29
+ function resolveValue(value, type, msg) {
30
+ if (!value) return '';
31
+ switch (type) {
32
+ case 'msg': return RED.util.getMessageProperty(msg, value) || '';
33
+ case 'flow': return node.context().flow.get(value) || '';
34
+ case 'global': return node.context().global.get(value) || '';
35
+ case 'env': return RED.util.evaluateNodeProperty(value, 'env', node, msg) || '';
36
+ default: return value;
37
+ }
38
+ }
39
+
40
+ node.status({ fill: "grey", shape: "ring", text: "idle" });
41
+
42
+ node.on('input', function(msg, send, done) {
43
+ send = send || function() { node.send.apply(node, arguments); };
44
+ done = done || function(err) { if (err) node.error(err, msg); };
45
+
46
+ try {
47
+ // Extract trigger value
48
+ let trigger;
49
+ if (node.triggerField.startsWith('msg.')) {
50
+ trigger = RED.util.getMessageProperty(msg, node.triggerField.substring(4));
51
+ } else {
52
+ trigger = RED.util.getMessageProperty(msg, node.triggerField);
53
+ }
54
+ const isActive = !!trigger;
55
+
56
+ // Get active record from node context
57
+ const activeRecord = node.context().get('activeRecord') || null;
58
+
59
+ if (isActive && !activeRecord) {
60
+ // ── START event ──
61
+ const typeVal = resolveValue(node.eventType, node.eventTypeType, msg);
62
+ const nameVal = resolveValue(node.eventName, node.eventNameType, msg);
63
+
64
+ // Extract optional metadata
65
+ let metadata = '';
66
+ if (node.metadataField) {
67
+ const raw = RED.util.getMessageProperty(msg, node.metadataField);
68
+ metadata = (raw !== undefined) ? (typeof raw === 'string' ? raw : JSON.stringify(raw)) : '';
69
+ }
70
+
71
+ const record = {
72
+ id: crypto.randomUUID(),
73
+ starttime: new Date().toISOString(),
74
+ endtime: '9999-12-31T23:59:59.000Z',
75
+ type: typeVal,
76
+ name: nameVal,
77
+ state: 'running',
78
+ metadata: metadata
79
+ };
80
+
81
+ // Store in node context
82
+ node.context().set('activeRecord', record);
83
+
84
+ node.status({ fill: "green", shape: "dot", text: "running" });
85
+
86
+ const topic = node.outputTopic || (typeVal ? `event/${typeVal}/start` : 'event/start');
87
+ const startMsg = {
88
+ topic: topic,
89
+ payload: { ...record }
90
+ };
91
+ send([startMsg, null]);
92
+ done();
93
+
94
+ } else if (!isActive && activeRecord) {
95
+ // ── END event ──
96
+ const record = { ...activeRecord };
97
+ record.endtime = new Date().toISOString();
98
+ record.state = 'complete';
99
+
100
+ // Clear active record
101
+ node.context().set('activeRecord', null);
102
+
103
+ node.status({ fill: "grey", shape: "ring", text: "complete" });
104
+
105
+ const typeVal = record.type;
106
+ const topic = node.outputTopic || (typeVal ? `event/${typeVal}/end` : 'event/end');
107
+ const endMsg = {
108
+ topic: topic,
109
+ payload: { ...record }
110
+ };
111
+ send([null, endMsg]);
112
+ done();
113
+
114
+ } else {
115
+ // No state change
116
+ done();
117
+ }
118
+ } catch (err) {
119
+ node.status({ fill: "red", shape: "ring", text: "error" });
120
+ done(err);
121
+ }
122
+ });
123
+ }
124
+
125
+ RED.nodes.registerType("simple-frame", SimpleFrameNode);
126
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-event-calc",
3
- "version": "3.3.3",
3
+ "version": "3.3.15",
4
4
  "description": "Node-RED nodes for event caching and calculations",
5
5
  "author": {
6
6
  "name": "Holger Amort"
@@ -29,9 +29,13 @@
29
29
  "event-in": "nodes/event-in.js",
30
30
  "event-topic": "nodes/event-topic.js",
31
31
  "event-calc": "nodes/event-calc.js",
32
+ "event-flatten": "nodes/event-flatten.js",
32
33
  "event-preview": "nodes/event-preview.js",
33
34
  "event-simulator": "nodes/event-simulator.js",
34
- "event-chart": "nodes/event-chart.js"
35
+ "event-chart": "nodes/event-chart.js",
36
+ "event-frame": "nodes/event-frame.js",
37
+ "event-alarm": "nodes/event-alarm.js",
38
+ "simple-frame": "nodes/simple-frame.js"
35
39
  }
36
40
  },
37
41
  "engines": {
package/nul DELETED
File without changes
@@ -1,22 +0,0 @@
1
- // @ts-check
2
- const { defineConfig } = require('@playwright/test');
3
-
4
- module.exports = defineConfig({
5
- testDir: './tests',
6
- fullyParallel: false,
7
- forbidOnly: !!process.env.CI,
8
- retries: process.env.CI ? 2 : 0,
9
- workers: 1,
10
- reporter: 'html',
11
- use: {
12
- baseURL: 'http://localhost:1880',
13
- trace: 'on-first-retry',
14
- screenshot: 'only-on-failure',
15
- },
16
- projects: [
17
- {
18
- name: 'chromium',
19
- use: { browserName: 'chromium' },
20
- },
21
- ],
22
- });