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.
- package/README.md +127 -55
- package/examples/alarm-simulation-example.json +288 -0
- package/examples/change-detection-example.json +448 -0
- package/examples/isa88-batch-example.json +278 -0
- package/examples/opcua-alarm-example.json +516 -0
- package/examples/test-flows.json +723 -0
- package/examples/throughput-test.json +293 -0
- package/nodes/event-alarm.html +252 -0
- package/nodes/event-alarm.js +292 -0
- package/nodes/event-cache.js +84 -33
- package/nodes/event-calc.js +132 -77
- package/nodes/event-flatten.html +53 -0
- package/nodes/event-flatten.js +25 -0
- package/nodes/event-frame.html +192 -0
- package/nodes/event-frame.js +246 -0
- package/nodes/simple-frame.html +125 -0
- package/nodes/simple-frame.js +126 -0
- package/package.json +6 -2
- package/node-red-contrib-event-calc-3.3.2.tgz +0 -0
- package/nul +0 -0
- package/playwright.config.js +0 -22
- package/tests/external-trigger.spec.js +0 -141
|
@@ -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
|
+
"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": {
|
|
Binary file
|
package/nul
DELETED
|
File without changes
|
package/playwright.config.js
DELETED
|
@@ -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
|
-
});
|