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,293 @@
1
+ [
2
+ {
3
+ "id": "throughput-test-flow",
4
+ "type": "tab",
5
+ "label": "Throughput Test",
6
+ "disabled": false,
7
+ "info": "Measures end-to-end throughput with per-stage breakdown.\n\nEach message carries a birth timestamp through the full pipeline:\ninject → generator → event-in → cache → calc → counter\n\nReports: total time, msg/sec, and per-message avg latency."
8
+ },
9
+ {
10
+ "id": "cache-tp",
11
+ "type": "event-cache",
12
+ "name": "TP Cache",
13
+ "maxEntries": "10000",
14
+ "ttl": "0"
15
+ },
16
+ {
17
+ "id": "comment-tp-e2e",
18
+ "type": "comment",
19
+ "z": "throughput-test-flow",
20
+ "name": "--- End-to-End Pipeline: inject → event-in → cache → calc → measure ---",
21
+ "info": "",
22
+ "x": 300,
23
+ "y": 40,
24
+ "wires": []
25
+ },
26
+ {
27
+ "id": "inject-e2e-1k",
28
+ "type": "inject",
29
+ "z": "throughput-test-flow",
30
+ "name": "Burst 1,000",
31
+ "props": [
32
+ { "p": "payload" }
33
+ ],
34
+ "payload": "1000",
35
+ "payloadType": "num",
36
+ "repeat": "",
37
+ "crontab": "",
38
+ "once": false,
39
+ "onceDelay": 0.1,
40
+ "x": 130,
41
+ "y": 100,
42
+ "wires": [["e2e-generator"]]
43
+ },
44
+ {
45
+ "id": "inject-e2e-10k",
46
+ "type": "inject",
47
+ "z": "throughput-test-flow",
48
+ "name": "Burst 10,000",
49
+ "props": [
50
+ { "p": "payload" }
51
+ ],
52
+ "payload": "10000",
53
+ "payloadType": "num",
54
+ "repeat": "",
55
+ "crontab": "",
56
+ "once": false,
57
+ "onceDelay": 0.1,
58
+ "x": 130,
59
+ "y": 160,
60
+ "wires": [["e2e-generator"]]
61
+ },
62
+ {
63
+ "id": "inject-e2e-50k",
64
+ "type": "inject",
65
+ "z": "throughput-test-flow",
66
+ "name": "Burst 50,000",
67
+ "props": [
68
+ { "p": "payload" }
69
+ ],
70
+ "payload": "50000",
71
+ "payloadType": "num",
72
+ "repeat": "",
73
+ "crontab": "",
74
+ "once": false,
75
+ "onceDelay": 0.1,
76
+ "x": 130,
77
+ "y": 220,
78
+ "wires": [["e2e-generator"]]
79
+ },
80
+ {
81
+ "id": "e2e-generator",
82
+ "type": "function",
83
+ "z": "throughput-test-flow",
84
+ "name": "Generator (stamps birthTime)",
85
+ "func": "const count = msg.payload || 1000;\nconst batchStart = Date.now();\n\n// Reset all counters\nconst resetMsg = { payload: 'reset', _batch: { count: count, startTime: batchStart } };\nnode.send([null, null, resetMsg]);\n\n// Generate messages with birth timestamps\nfor (let i = 0; i < count; i++) {\n node.send([{\n topic: 'tp/sensor',\n payload: 20 + Math.random() * 10,\n _birthTime: Date.now(),\n _seq: i\n }, null, null]);\n}\n\nconst genElapsed = Date.now() - batchStart;\nnode.send([null, {\n payload: {\n stage: 'generator',\n messages: count,\n elapsed_ms: genElapsed,\n rate_per_sec: Math.round(count / (genElapsed / 1000))\n }\n}, null]);\n\nreturn null;",
86
+ "outputs": 3,
87
+ "x": 390,
88
+ "y": 160,
89
+ "wires": [["event-in-tp-e2e"], ["debug-tp-result"], ["e2e-simple-counter", "e2e-complex-counter", "e2e-passthru-counter"]]
90
+ },
91
+ {
92
+ "id": "event-in-tp-e2e",
93
+ "type": "event-in",
94
+ "z": "throughput-test-flow",
95
+ "name": "event-in",
96
+ "cache": "cache-tp",
97
+ "topicField": "topic",
98
+ "valueField": "payload",
99
+ "x": 610,
100
+ "y": 100,
101
+ "wires": [["e2e-passthru-counter"]]
102
+ },
103
+ {
104
+ "id": "calc-e2e-simple",
105
+ "type": "event-calc",
106
+ "z": "throughput-test-flow",
107
+ "name": "Simple: a * 2",
108
+ "cache": "cache-tp",
109
+ "inputMappings": [
110
+ { "name": "a", "pattern": "tp/sensor" }
111
+ ],
112
+ "expression": "a * 2",
113
+ "triggerOn": "any",
114
+ "outputTopic": "tp/result_simple",
115
+ "x": 610,
116
+ "y": 180,
117
+ "wires": [["e2e-simple-counter"], []]
118
+ },
119
+ {
120
+ "id": "calc-e2e-complex",
121
+ "type": "event-calc",
122
+ "z": "throughput-test-flow",
123
+ "name": "Complex: round + hasChanged + prev",
124
+ "cache": "cache-tp",
125
+ "inputMappings": [
126
+ { "name": "a", "pattern": "tp/sensor" }
127
+ ],
128
+ "expression": "round(a, 2) + (hasChanged('a') ? 1 : 0) + (prev('a') || 0)",
129
+ "triggerOn": "any",
130
+ "outputTopic": "tp/result_complex",
131
+ "x": 660,
132
+ "y": 250,
133
+ "wires": [["e2e-complex-counter"], []]
134
+ },
135
+ {
136
+ "id": "e2e-passthru-counter",
137
+ "type": "function",
138
+ "z": "throughput-test-flow",
139
+ "name": "Pass-thru Counter (event-in only)",
140
+ "func": "const ctx = context.get('state') || { count: 0, batch: null };\n\nif (msg.payload === 'reset') {\n ctx.count = 0;\n ctx.batch = msg._batch;\n context.set('state', ctx);\n return null;\n}\n\nctx.count++;\n\nif (ctx.batch && ctx.count >= ctx.batch.count) {\n const elapsed = Date.now() - ctx.batch.startTime;\n const rate = Math.round(ctx.count / (elapsed / 1000));\n const report = {\n payload: {\n stage: 'event-in pass-thru (no calc)',\n messages: ctx.count,\n elapsed_ms: elapsed,\n rate_per_sec: rate\n }\n };\n ctx.count = 0;\n ctx.batch = null;\n context.set('state', ctx);\n return report;\n}\n\ncontext.set('state', ctx);\nreturn null;",
141
+ "outputs": 1,
142
+ "x": 910,
143
+ "y": 100,
144
+ "wires": [["debug-tp-result"]]
145
+ },
146
+ {
147
+ "id": "e2e-simple-counter",
148
+ "type": "function",
149
+ "z": "throughput-test-flow",
150
+ "name": "Simple Calc Counter",
151
+ "func": "const ctx = context.get('state') || { count: 0, batch: null };\n\nif (msg.payload === 'reset') {\n ctx.count = 0;\n ctx.batch = msg._batch;\n context.set('state', ctx);\n return null;\n}\n\nctx.count++;\n\nif (ctx.batch && ctx.count >= ctx.batch.count) {\n const elapsed = Date.now() - ctx.batch.startTime;\n const rate = Math.round(ctx.count / (elapsed / 1000));\n const report = {\n payload: {\n stage: 'simple calc (a * 2)',\n messages: ctx.count,\n elapsed_ms: elapsed,\n rate_per_sec: rate\n }\n };\n ctx.count = 0;\n ctx.batch = null;\n context.set('state', ctx);\n return report;\n}\n\ncontext.set('state', ctx);\nreturn null;",
152
+ "outputs": 1,
153
+ "x": 880,
154
+ "y": 180,
155
+ "wires": [["debug-tp-result"]]
156
+ },
157
+ {
158
+ "id": "e2e-complex-counter",
159
+ "type": "function",
160
+ "z": "throughput-test-flow",
161
+ "name": "Complex Calc Counter",
162
+ "func": "const ctx = context.get('state') || { count: 0, batch: null };\n\nif (msg.payload === 'reset') {\n ctx.count = 0;\n ctx.batch = msg._batch;\n context.set('state', ctx);\n return null;\n}\n\nctx.count++;\n\nif (ctx.batch && ctx.count >= ctx.batch.count) {\n const elapsed = Date.now() - ctx.batch.startTime;\n const rate = Math.round(ctx.count / (elapsed / 1000));\n const report = {\n payload: {\n stage: 'complex calc (round + hasChanged + prev)',\n messages: ctx.count,\n elapsed_ms: elapsed,\n rate_per_sec: rate\n }\n };\n ctx.count = 0;\n ctx.batch = null;\n context.set('state', ctx);\n return report;\n}\n\ncontext.set('state', ctx);\nreturn null;",
163
+ "outputs": 1,
164
+ "x": 890,
165
+ "y": 250,
166
+ "wires": [["debug-tp-result"]]
167
+ },
168
+ {
169
+ "id": "debug-tp-result",
170
+ "type": "debug",
171
+ "z": "throughput-test-flow",
172
+ "name": "Throughput Results",
173
+ "active": true,
174
+ "tosidebar": true,
175
+ "console": false,
176
+ "tostatus": true,
177
+ "complete": "payload",
178
+ "targetType": "msg",
179
+ "statusVal": "payload.rate_per_sec",
180
+ "statusType": "auto",
181
+ "x": 1140,
182
+ "y": 180,
183
+ "wires": []
184
+ },
185
+ {
186
+ "id": "comment-tp-compare",
187
+ "type": "comment",
188
+ "z": "throughput-test-flow",
189
+ "name": "Results show: generator speed, event-in passthru speed, simple calc speed, complex calc speed",
190
+ "info": "Compare the rates to see where time is spent.\nGenerator = raw message creation\nPass-thru = event-in + cache write only\nSimple = cache write + simple expression\nComplex = cache write + expression with hasChanged/prev lookups",
191
+ "x": 370,
192
+ "y": 320,
193
+ "wires": []
194
+ },
195
+ {
196
+ "id": "comment-tp-sustained",
197
+ "type": "comment",
198
+ "z": "throughput-test-flow",
199
+ "name": "--- Sustained Rate Test: continuous stream with interval ---",
200
+ "info": "",
201
+ "x": 270,
202
+ "y": 400,
203
+ "wires": []
204
+ },
205
+ {
206
+ "id": "inject-sustained-start",
207
+ "type": "inject",
208
+ "z": "throughput-test-flow",
209
+ "name": "Start (10ms interval)",
210
+ "props": [
211
+ { "p": "payload" },
212
+ { "p": "topic", "vt": "str" }
213
+ ],
214
+ "topic": "tp/sensor_b",
215
+ "payload": "",
216
+ "payloadType": "date",
217
+ "repeat": "0.01",
218
+ "crontab": "",
219
+ "once": false,
220
+ "onceDelay": 0.1,
221
+ "x": 160,
222
+ "y": 460,
223
+ "wires": [["sustained-value"]]
224
+ },
225
+ {
226
+ "id": "inject-sustained-stop",
227
+ "type": "inject",
228
+ "z": "throughput-test-flow",
229
+ "name": "Report & Reset",
230
+ "props": [
231
+ { "p": "payload" }
232
+ ],
233
+ "payload": "report",
234
+ "payloadType": "str",
235
+ "repeat": "",
236
+ "crontab": "",
237
+ "once": false,
238
+ "onceDelay": 0.1,
239
+ "x": 140,
240
+ "y": 520,
241
+ "wires": [["sustained-counter"]]
242
+ },
243
+ {
244
+ "id": "sustained-value",
245
+ "type": "function",
246
+ "z": "throughput-test-flow",
247
+ "name": "Random Value",
248
+ "func": "msg.payload = Math.random() * 100;\nreturn msg;",
249
+ "outputs": 1,
250
+ "x": 370,
251
+ "y": 460,
252
+ "wires": [["event-in-tp-sustained"]]
253
+ },
254
+ {
255
+ "id": "event-in-tp-sustained",
256
+ "type": "event-in",
257
+ "z": "throughput-test-flow",
258
+ "name": "",
259
+ "cache": "cache-tp",
260
+ "topicField": "topic",
261
+ "valueField": "payload",
262
+ "x": 570,
263
+ "y": 460,
264
+ "wires": [[]]
265
+ },
266
+ {
267
+ "id": "calc-tp-sustained",
268
+ "type": "event-calc",
269
+ "z": "throughput-test-flow",
270
+ "name": "Sustained: hasChanged + prev",
271
+ "cache": "cache-tp",
272
+ "inputMappings": [
273
+ { "name": "b", "pattern": "tp/sensor_b" }
274
+ ],
275
+ "expression": "ifelse(hasChanged('b'), round(b - prev('b'), 2), 0)",
276
+ "triggerOn": "any",
277
+ "outputTopic": "tp/result_sustained",
278
+ "x": 620,
279
+ "y": 530,
280
+ "wires": [["sustained-counter"], []]
281
+ },
282
+ {
283
+ "id": "sustained-counter",
284
+ "type": "function",
285
+ "z": "throughput-test-flow",
286
+ "name": "Sustained Counter",
287
+ "func": "const ctx = context.get('state') || { count: 0, startTime: Date.now() };\n\nif (msg.payload === 'report') {\n const elapsed = Date.now() - ctx.startTime;\n const rate = elapsed > 0 ? Math.round(ctx.count / (elapsed / 1000)) : 0;\n const report = {\n payload: {\n stage: 'sustained (hasChanged + prev + ifelse)',\n messages: ctx.count,\n elapsed_ms: elapsed,\n elapsed_sec: Math.round(elapsed / 100) / 10,\n rate_per_sec: rate\n }\n };\n context.set('state', { count: 0, startTime: Date.now() });\n return report;\n}\n\nctx.count++;\ncontext.set('state', ctx);\nreturn null;",
288
+ "outputs": 1,
289
+ "x": 880,
290
+ "y": 520,
291
+ "wires": [["debug-tp-result"]]
292
+ }
293
+ ]
@@ -0,0 +1,252 @@
1
+ <style>
2
+ .event-alarm-white-text { fill: #ffffff !important; }
3
+ .red-ui-palette-node[data-palette-type="event-alarm"] .red-ui-palette-label { color: #ffffff !important; }
4
+ </style>
5
+
6
+ <script type="text/javascript">
7
+ RED.nodes.registerType('event-alarm', {
8
+ category: 'event calc',
9
+ color: '#C44040',
10
+ defaults: {
11
+ name: { value: "" },
12
+ cache: { value: "", type: "event-cache", required: true },
13
+ conditionId: { value: "" },
14
+ conditionName: { value: "" },
15
+ inputMappings: { value: [] },
16
+ condition: { value: "" },
17
+ severity: { value: 500, validate: function(v) { var n = parseInt(v); return !isNaN(n) && n >= 0 && n <= 1000; } },
18
+ outputTopic: { value: "alarm/event" }
19
+ },
20
+ inputs: 1,
21
+ outputs: 4,
22
+ outputLabels: ["raised", "acknowledged", "cleared", "resolved"],
23
+ icon: "font-awesome/fa-bell",
24
+ label: function() {
25
+ return this.name || this.conditionName || "event alarm";
26
+ },
27
+ paletteLabel: "event alarm",
28
+ labelStyle: function() { return (this.name ? "node_label_italic" : "") + " event-alarm-white-text"; },
29
+ oneditprepare: function() {
30
+ var node = this;
31
+ var cachedTopics = [];
32
+
33
+ function fetchTopics() {
34
+ var cacheId = $("#node-input-cache").val();
35
+ if (cacheId) {
36
+ $.getJSON("event-cache/" + cacheId + "/topics", function(topics) {
37
+ cachedTopics = topics || [];
38
+ $(".input-topic").each(function() {
39
+ $(this).autocomplete("option", "source", cachedTopics);
40
+ });
41
+ });
42
+ }
43
+ }
44
+
45
+ $("#node-input-cache").on("change", fetchTopics);
46
+ setTimeout(fetchTopics, 100);
47
+
48
+ // Input mappings editable list
49
+ var inputList = $("#node-input-inputMappings-list").css({
50
+ 'min-height': '150px',
51
+ 'min-width': '450px'
52
+ }).editableList({
53
+ addItem: function(container, i, data) {
54
+ var row = $('<div/>', { style: "display:flex; align-items:center; gap:5px;" }).appendTo(container);
55
+
56
+ $('<input/>', {
57
+ type: "text",
58
+ placeholder: "var (e.g. 'temp')",
59
+ class: "input-name"
60
+ })
61
+ .css({ width: "25%", flex: "0 0 auto" })
62
+ .val(data.name || "")
63
+ .appendTo(row);
64
+
65
+ $('<span/>', { style: "flex: 0 0 auto;" }).text(" = ").appendTo(row);
66
+
67
+ var topicInput = $('<input/>', {
68
+ type: "text",
69
+ placeholder: "topic (type to search cached topics)",
70
+ class: "input-topic"
71
+ })
72
+ .css({ flex: "1 1 auto" })
73
+ .val(data.topic || data.pattern || "")
74
+ .appendTo(row);
75
+
76
+ topicInput.autocomplete({
77
+ source: cachedTopics,
78
+ minLength: 0,
79
+ delay: 0
80
+ }).on("focus", function() {
81
+ if (!$(this).val()) {
82
+ $(this).autocomplete("search", "");
83
+ }
84
+ });
85
+ },
86
+ removable: true,
87
+ sortable: true,
88
+ addButton: true
89
+ });
90
+
91
+ if (node.inputMappings && node.inputMappings.length > 0) {
92
+ node.inputMappings.forEach(function(input) {
93
+ inputList.editableList('addItem', input);
94
+ });
95
+ } else {
96
+ inputList.editableList('addItem', { name: 'val', pattern: '' });
97
+ }
98
+ },
99
+ oneditsave: function() {
100
+ var node = this;
101
+ node.inputMappings = [];
102
+
103
+ $("#node-input-inputMappings-list").editableList('items').each(function() {
104
+ var name = $(this).find(".input-name").val().trim();
105
+ var topic = $(this).find(".input-topic").val().trim();
106
+ if (name && topic) {
107
+ node.inputMappings.push({ name: name, topic: topic });
108
+ }
109
+ });
110
+ }
111
+ });
112
+ </script>
113
+
114
+ <script type="text/html" data-template-name="event-alarm">
115
+ <div class="form-row">
116
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
117
+ <input type="text" id="node-input-name" placeholder="Name">
118
+ </div>
119
+ <div class="form-row">
120
+ <label for="node-input-cache"><i class="fa fa-database"></i> Cache</label>
121
+ <input type="text" id="node-input-cache">
122
+ </div>
123
+ <div class="form-row">
124
+ <label for="node-input-conditionId"><i class="fa fa-key"></i> Condition ID</label>
125
+ <input type="text" id="node-input-conditionId" placeholder="Unique alarm identifier (defaults to node ID)">
126
+ </div>
127
+ <div class="form-row">
128
+ <label for="node-input-conditionName"><i class="fa fa-bookmark"></i> Condition Name</label>
129
+ <input type="text" id="node-input-conditionName" placeholder="Human readable alarm name">
130
+ </div>
131
+ <div class="form-row">
132
+ <label for="node-input-severity"><i class="fa fa-exclamation-triangle"></i> Severity</label>
133
+ <input type="number" id="node-input-severity" min="0" max="1000" placeholder="0-1000">
134
+ <div class="form-tips">Severity scale: 0 (lowest) to 1000 (highest)</div>
135
+ </div>
136
+ <div class="form-row">
137
+ <label style="width:100%;"><i class="fa fa-sign-in"></i> Input Variables</label>
138
+ <ol id="node-input-inputMappings-list"></ol>
139
+ <div class="form-tips">
140
+ Map variable names to UNS topics. These variables are used in the condition expression.
141
+ </div>
142
+ </div>
143
+ <div class="form-row">
144
+ <label for="node-input-condition"><i class="fa fa-code"></i> Condition</label>
145
+ <input type="text" id="node-input-condition" placeholder="e.g. temp > 80, pressure > 100 && flow < 10">
146
+ <div class="form-tips">
147
+ JavaScript expression that evaluates to true (alarm active) or false (normal).
148
+ Use the variable names defined above.
149
+ </div>
150
+ </div>
151
+ <div class="form-row">
152
+ <label for="node-input-outputTopic"><i class="fa fa-bell"></i> Output Topic</label>
153
+ <input type="text" id="node-input-outputTopic" placeholder="alarm/event">
154
+ </div>
155
+ </script>
156
+
157
+ <script type="text/html" data-help-name="event-alarm">
158
+ <p>Alarm management node with ISA-18.2 lifecycle tracking. Subscribes to UNS topics,
159
+ evaluates a condition expression, and manages the full alarm lifecycle with stateful tracking.</p>
160
+
161
+ <h3>Properties</h3>
162
+ <dl class="message-properties">
163
+ <dt>Condition ID</dt>
164
+ <dd>Unique alarm identifier used as the state key. Defaults to the node ID.</dd>
165
+ <dt>Condition Name</dt>
166
+ <dd>Human-readable alarm name included in all output messages.</dd>
167
+ <dt>Severity</dt>
168
+ <dd>Alarm severity on a 0-1000 scale.</dd>
169
+ <dt>Input Variables</dt>
170
+ <dd>Map variable names to UNS topics (same as event-calc). These variables are available in the condition expression.</dd>
171
+ <dt>Condition</dt>
172
+ <dd>JavaScript expression that evaluates to <code>true</code> (alarm active) or <code>false</code> (normal).
173
+ Example: <code>temp > 80</code>, <code>pressure > 100 && flow < 10</code></dd>
174
+ <dt>Output Topic</dt>
175
+ <dd>Topic for output alarm event messages.</dd>
176
+ </dl>
177
+
178
+ <h3>Alarm Lifecycle (ISA-18.2)</h3>
179
+ <pre>
180
+ NORMAL ──condition true──► UNACK_ALM (Active + Unacknowledged)
181
+ │ │
182
+ ack │ condition false
183
+ ▼ ▼
184
+ ACK_ALM UNACK_RTN
185
+ (Active + Acked) (Inactive + Unacked)
186
+ │ │
187
+ condition false ack │
188
+ │ │
189
+ ▼ ▼
190
+ NORMAL ◄─────────── NORMAL
191
+ (Resolved) (Resolved)
192
+ </pre>
193
+
194
+ <h3>Outputs</h3>
195
+ <p>The node has four outputs, one for each state transition:</p>
196
+ <ol>
197
+ <li><b>Raised</b> - Alarm activated (Active + Unacknowledged)</li>
198
+ <li><b>Acknowledged</b> - Alarm acknowledged by operator (Active + Acknowledged)</li>
199
+ <li><b>Cleared</b> - Condition cleared but not yet acknowledged (Inactive + Unacknowledged)</li>
200
+ <li><b>Resolved</b> - Alarm fully resolved, removed from state dictionary</li>
201
+ </ol>
202
+
203
+ <h4>Output Payload</h4>
204
+ <dl class="message-properties">
205
+ <dt>condition_id <span class="property-type">string</span></dt>
206
+ <dd>Unique alarm identifier</dd>
207
+ <dt>condition_name <span class="property-type">string</span></dt>
208
+ <dd>Human-readable alarm name</dd>
209
+ <dt>source <span class="property-type">string</span></dt>
210
+ <dd>The topic that triggered the alarm</dd>
211
+ <dt>source_node <span class="property-type">string</span></dt>
212
+ <dd>The node ID of the alarm node</dd>
213
+ <dt>active_state <span class="property-type">string</span></dt>
214
+ <dd>"Active" or "Inactive"</dd>
215
+ <dt>acked_state <span class="property-type">string</span></dt>
216
+ <dd>"Acknowledged" or "Unacknowledged"</dd>
217
+ <dt>severity <span class="property-type">number</span></dt>
218
+ <dd>0-1000 severity scale</dd>
219
+ <dt>retain <span class="property-type">boolean</span></dt>
220
+ <dd>true = still interesting, false = fully resolved</dd>
221
+ <dt>ts <span class="property-type">number</span></dt>
222
+ <dd>Event timestamp</dd>
223
+ <dt>lifecycle <span class="property-type">object</span></dt>
224
+ <dd>Timestamps for each lifecycle event: raised_ts, acked_ts, cleared_ts, resolved_ts</dd>
225
+ </dl>
226
+
227
+ <h3>Inputs</h3>
228
+ <p>Send messages to control alarm state:</p>
229
+ <dl class="message-properties">
230
+ <dt>payload.action = "ack" <span class="property-type">string</span></dt>
231
+ <dd>Acknowledge a specific alarm. Requires <code>payload.source</code> to identify which alarm.</dd>
232
+ <dt>payload.action = "ack_all" <span class="property-type">string</span></dt>
233
+ <dd>Acknowledge all active alarms.</dd>
234
+ <dt>payload.action = "list" <span class="property-type">string</span></dt>
235
+ <dd>List all active alarms (sent on output 4).</dd>
236
+ </dl>
237
+
238
+ <h3>State Persistence</h3>
239
+ <p>Alarm state is stored in node context and persists across Node-RED restarts.
240
+ Each alarm is keyed by the source topic that triggered it. When the alarm lifecycle
241
+ completes (resolved), the entry is removed from the state dictionary.</p>
242
+
243
+ <h3>Examples</h3>
244
+ <h4>High Temperature Alarm</h4>
245
+ <p>Map <code>temp</code> to <code>plant/reactor1/temperature</code>, set condition to <code>temp > 80</code></p>
246
+
247
+ <h4>Combined Condition</h4>
248
+ <p>Map <code>pressure</code> and <code>flow</code> to their topics, set condition to <code>pressure > 100 && flow < 10</code></p>
249
+
250
+ <h4>Acknowledging an Alarm</h4>
251
+ <p>Send: <code>{"payload": {"action": "ack", "source": "plant/reactor1/temperature"}}</code></p>
252
+ </script>