node-red-contrib-boolean-logic-ultimate 1.1.27 → 1.2.1

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,396 @@
1
+ 'use strict';
2
+
3
+ module.exports = function (RED) {
4
+ function RateLimiterUltimate(config) {
5
+ RED.nodes.createNode(this, config);
6
+ const node = this;
7
+ const REDUtil = RED.util;
8
+ const helpers = require('./lib/node-helpers.js');
9
+
10
+ const {
11
+ mode: initialMode = 'debounce',
12
+ wait = 500,
13
+ emitOn = 'trailing',
14
+ interval = 1000,
15
+ trailing = false,
16
+ windowSize = 1000,
17
+ maxInWindow = 10,
18
+ dropStrategy = 'drop',
19
+ payloadPropName = 'payload',
20
+ translatorConfig,
21
+ controlTopic = 'rate',
22
+ statInterval = 0,
23
+ } = config;
24
+
25
+ let currentMode = initialMode;
26
+ let passedCount = 0;
27
+ let droppedCount = 0;
28
+ let currentState = 'idle';
29
+
30
+ const setNodeStatus = helpers.createStatus(node);
31
+ const timerBag = helpers.createTimerBag(node);
32
+
33
+ const modeLabels = {
34
+ debounce: 'DB',
35
+ throttle: 'TH',
36
+ window: 'WN',
37
+ };
38
+
39
+ const stateLabels = {
40
+ idle: 'idle',
41
+ waiting: 'waiting',
42
+ cooldown: 'cooldown',
43
+ blocked: 'blocked',
44
+ };
45
+
46
+ function sendStatus() {
47
+ const prefix = modeLabels[currentMode] || currentMode.toUpperCase();
48
+ const state = stateLabels[currentState] || currentState;
49
+ const colour = currentState === 'idle' ? 'green' : currentState === 'waiting' || currentState === 'cooldown' ? 'yellow' : 'red';
50
+ setNodeStatus({
51
+ fill: colour,
52
+ shape: 'dot',
53
+ text: `${prefix}|${state} pass:${passedCount} drop:${droppedCount}`,
54
+ });
55
+ }
56
+
57
+ function emitToOutputs(msg, droppedMeta) {
58
+ const outputs = [msg, null];
59
+ if (droppedMeta) {
60
+ outputs[0] = null;
61
+ outputs[1] = droppedMeta;
62
+ }
63
+ node.send(outputs);
64
+ sendStatus();
65
+ }
66
+
67
+ function clone(msg) {
68
+ return REDUtil.cloneMessage ? REDUtil.cloneMessage(msg) : JSON.parse(JSON.stringify(msg));
69
+ }
70
+
71
+ function emitForward(msg, nextState = 'idle') {
72
+ passedCount += 1;
73
+ currentState = nextState;
74
+ emitToOutputs(msg, null);
75
+ }
76
+
77
+ function emitDrop(reason, originalMsg, resolved) {
78
+ droppedCount += 1;
79
+ const dropState = {
80
+ 'dropped-window': 'blocked',
81
+ 'suppressed-debounce': 'cooldown',
82
+ 'dropped-throttle': 'cooldown',
83
+ }[reason] || 'blocked';
84
+ currentState = dropState;
85
+ emitToOutputs(null, {
86
+ topic: `${controlTopic}/drop`,
87
+ payload: {
88
+ mode: currentMode,
89
+ reason,
90
+ msg: originalMsg,
91
+ passed: passedCount,
92
+ dropped: droppedCount,
93
+ property: payloadPropName,
94
+ propertyValue: resolved ? resolved.value : undefined,
95
+ propertyBoolean: resolved ? resolved.boolean : undefined,
96
+ },
97
+ });
98
+ }
99
+
100
+ let debounceTimer = null;
101
+ let debounceLeadingSent = false;
102
+ let pendingDebounceMessage = null;
103
+
104
+ let throttleTimer = null;
105
+ let throttleLastEmit = 0;
106
+ let throttlePendingMessage = null;
107
+
108
+ let windowTimestamps = [];
109
+ let windowQueueMessage = null;
110
+ let windowQueueTimer = null;
111
+
112
+ function clearDebounce() {
113
+ if (debounceTimer) {
114
+ timerBag.clearTimeout(debounceTimer);
115
+ debounceTimer = null;
116
+ }
117
+ debounceLeadingSent = false;
118
+ pendingDebounceMessage = null;
119
+ }
120
+
121
+ function clearThrottle() {
122
+ if (throttleTimer) {
123
+ timerBag.clearTimeout(throttleTimer);
124
+ throttleTimer = null;
125
+ }
126
+ throttlePendingMessage = null;
127
+ }
128
+
129
+ function clearWindowQueue() {
130
+ if (windowQueueTimer) {
131
+ timerBag.clearTimeout(windowQueueTimer);
132
+ windowQueueTimer = null;
133
+ }
134
+ windowQueueMessage = null;
135
+ }
136
+
137
+ function resetAll() {
138
+ clearDebounce();
139
+ clearThrottle();
140
+ clearWindowQueue();
141
+ windowTimestamps = [];
142
+ throttleLastEmit = 0;
143
+ currentState = 'idle';
144
+ sendStatus();
145
+ }
146
+
147
+ function flushPending() {
148
+ switch (currentMode) {
149
+ case 'debounce':
150
+ if (pendingDebounceMessage) {
151
+ const toSend = pendingDebounceMessage;
152
+ clearDebounce();
153
+ emitForward(toSend);
154
+ }
155
+ break;
156
+ case 'throttle':
157
+ if (throttlePendingMessage) {
158
+ const toSend = throttlePendingMessage;
159
+ throttlePendingMessage = null;
160
+ throttleLastEmit = Date.now();
161
+ emitForward(toSend);
162
+ if (throttleTimer) {
163
+ timerBag.clearTimeout(throttleTimer);
164
+ throttleTimer = null;
165
+ }
166
+ }
167
+ break;
168
+ case 'window':
169
+ if (windowQueueMessage) {
170
+ const queued = windowQueueMessage;
171
+ clearWindowQueue();
172
+ tryEmitWindow(queued.msg, queued.propertyValue);
173
+ }
174
+ break;
175
+ default:
176
+ break;
177
+ }
178
+ }
179
+
180
+ function handleControlMessage(msg) {
181
+ let consumed = false;
182
+ if (msg.reset === true) {
183
+ resetAll();
184
+ consumed = true;
185
+ }
186
+ if (msg.flush === true) {
187
+ flushPending();
188
+ consumed = true;
189
+ }
190
+ if (typeof msg.mode === 'string') {
191
+ const newMode = msg.mode.toLowerCase();
192
+ if (['debounce', 'throttle', 'window'].includes(newMode)) {
193
+ currentMode = newMode;
194
+ resetAll();
195
+ consumed = true;
196
+ }
197
+ }
198
+ if (typeof msg.interval === 'number') {
199
+ config.interval = msg.interval;
200
+ consumed = true;
201
+ }
202
+ if (typeof msg.wait === 'number') {
203
+ config.wait = msg.wait;
204
+ consumed = true;
205
+ }
206
+ if (typeof msg.windowSize === 'number') {
207
+ config.windowSize = msg.windowSize;
208
+ consumed = true;
209
+ }
210
+ if (typeof msg.maxInWindow === 'number') {
211
+ config.maxInWindow = msg.maxInWindow;
212
+ consumed = true;
213
+ }
214
+ return consumed;
215
+ }
216
+
217
+ function scheduleStats() {
218
+ if (Number(statInterval) > 0) {
219
+ timerBag.setInterval(() => {
220
+ node.send([
221
+ null,
222
+ {
223
+ topic: `${controlTopic}/stats`,
224
+ payload: {
225
+ mode: currentMode,
226
+ state: currentState,
227
+ passed: passedCount,
228
+ dropped: droppedCount,
229
+ },
230
+ },
231
+ ]);
232
+ }, Number(statInterval) * 1000);
233
+ }
234
+ }
235
+
236
+ function tryEmitWindow(msg, resolved) {
237
+ const now = Date.now();
238
+ const size = Number(config.windowSize || windowSize);
239
+ const max = Number(config.maxInWindow || maxInWindow);
240
+
241
+ windowTimestamps = windowTimestamps.filter((ts) => now - ts <= size);
242
+ if (windowTimestamps.length < max) {
243
+ const hadQueue = Boolean(windowQueueMessage);
244
+ emitForward(msg, hadQueue ? 'waiting' : 'idle');
245
+ windowTimestamps.push(now);
246
+ if (windowQueueMessage && !windowQueueTimer) {
247
+ const queued = windowQueueMessage;
248
+ windowQueueMessage = null;
249
+ const delay = size - (now - windowTimestamps[0]);
250
+ windowQueueTimer = timerBag.setTimeout(() => {
251
+ windowQueueTimer = null;
252
+ tryEmitWindow(queued.msg, queued.resolved);
253
+ }, Math.max(delay, 0));
254
+ }
255
+ return true;
256
+ }
257
+
258
+ if ((config.dropStrategy || dropStrategy) === 'queue') {
259
+ windowQueueMessage = { msg, resolved };
260
+ currentState = 'blocked';
261
+ if (!windowQueueTimer) {
262
+ const delay = size - (now - windowTimestamps[0]);
263
+ windowQueueTimer = timerBag.setTimeout(() => {
264
+ windowQueueTimer = null;
265
+ if (windowQueueMessage) {
266
+ const queued = windowQueueMessage;
267
+ windowQueueMessage = null;
268
+ tryEmitWindow(queued.msg, queued.resolved);
269
+ }
270
+ }, Math.max(delay, 0));
271
+ }
272
+ } else {
273
+ emitDrop('dropped-window', msg, resolved);
274
+ }
275
+ return false;
276
+ }
277
+
278
+ function handleDebounce(msg, resolved) {
279
+ const waitTime = Number(config.wait || wait);
280
+ const emitSetting = config.emitOn || emitOn;
281
+ const cloned = clone(msg);
282
+
283
+ if (debounceTimer) {
284
+ timerBag.clearTimeout(debounceTimer);
285
+ debounceTimer = null;
286
+ }
287
+
288
+ pendingDebounceMessage = cloned;
289
+ currentState = 'waiting';
290
+
291
+ const shouldEmitLeading = (emitSetting === 'leading' || emitSetting === 'both') && !debounceLeadingSent;
292
+ if (shouldEmitLeading) {
293
+ emitForward(cloned, 'waiting');
294
+ debounceLeadingSent = true;
295
+ }
296
+
297
+ if (emitSetting === 'leading' && !shouldEmitLeading) {
298
+ emitDrop('suppressed-debounce', cloned, resolved);
299
+ return;
300
+ }
301
+
302
+ debounceTimer = timerBag.setTimeout(() => {
303
+ debounceTimer = null;
304
+ debounceLeadingSent = false;
305
+ const shouldEmitTrailing = emitSetting === 'trailing' || emitSetting === 'both';
306
+ if (shouldEmitTrailing && pendingDebounceMessage) {
307
+ const toSend = pendingDebounceMessage;
308
+ pendingDebounceMessage = null;
309
+ emitForward(toSend, 'idle');
310
+ } else {
311
+ pendingDebounceMessage = null;
312
+ currentState = 'idle';
313
+ sendStatus();
314
+ }
315
+ }, waitTime);
316
+ }
317
+
318
+ function handleThrottle(msg, resolved) {
319
+ const intervalMs = Number(config.interval || interval);
320
+ const trailingEnabled = Boolean(config.trailing ?? trailing);
321
+ const now = Date.now();
322
+ const cloned = clone(msg);
323
+
324
+ if (now - throttleLastEmit >= intervalMs) {
325
+ throttleLastEmit = now;
326
+ emitForward(cloned);
327
+ clearThrottle();
328
+ return;
329
+ }
330
+
331
+ if (!trailingEnabled) {
332
+ emitDrop('dropped-throttle', cloned, resolved);
333
+ return;
334
+ }
335
+
336
+ throttlePendingMessage = cloned;
337
+ currentState = 'cooldown';
338
+ if (!throttleTimer) {
339
+ const delay = intervalMs - (now - throttleLastEmit);
340
+ throttleTimer = timerBag.setTimeout(() => {
341
+ throttleTimer = null;
342
+ if (throttlePendingMessage) {
343
+ throttleLastEmit = Date.now();
344
+ const toSend = throttlePendingMessage;
345
+ throttlePendingMessage = null;
346
+ emitForward(toSend);
347
+ } else {
348
+ currentState = 'idle';
349
+ sendStatus();
350
+ }
351
+ }, delay);
352
+ }
353
+ }
354
+
355
+ function handleWindow(msg, resolved) {
356
+ const cloned = clone(msg);
357
+ if (!tryEmitWindow(cloned, resolved)) {
358
+ if ((config.dropStrategy || dropStrategy) !== 'queue') {
359
+ // Drop already emitted; ensure status reflects blocked state
360
+ currentState = 'blocked';
361
+ sendStatus();
362
+ }
363
+ }
364
+ }
365
+
366
+ node.on('input', function (msg) {
367
+ if (msg.topic === controlTopic) {
368
+ if (handleControlMessage(msg)) {
369
+ return;
370
+ }
371
+ }
372
+
373
+ const resolved = helpers.resolveInput(msg, payloadPropName, config.translatorConfig, RED);
374
+
375
+ switch (currentMode) {
376
+ case 'debounce':
377
+ handleDebounce(msg, resolved);
378
+ break;
379
+ case 'throttle':
380
+ handleThrottle(msg, resolved);
381
+ break;
382
+ case 'window':
383
+ handleWindow(msg, resolved);
384
+ break;
385
+ default:
386
+ emitForward(clone(msg));
387
+ break;
388
+ }
389
+ });
390
+
391
+ scheduleStats();
392
+ sendStatus();
393
+ }
394
+
395
+ RED.nodes.registerType('RateLimiterUltimate', RateLimiterUltimate);
396
+ };
@@ -0,0 +1,165 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('StaircaseLightUltimate', {
3
+ category: 'Boolean Logic Ultimate',
4
+ color: '#ff8080',
5
+ defaults: {
6
+ name: { value: '' },
7
+ controlTopic: { value: 'stairs' },
8
+ payloadPropName: { value: 'payload', required: false },
9
+ translatorConfig: { type: 'translator-config', required: false },
10
+ durationSeconds: { value: 30, validate: RED.validators.number() },
11
+ warningEnabled: { value: true },
12
+ warningOffsetSeconds: { value: 5, validate: RED.validators.number() },
13
+ restartOnTrigger: { value: true },
14
+ allowOffInput: { value: false },
15
+ onPayload: { value: true },
16
+ onPayloadType: { value: 'bool' },
17
+ offPayload: { value: false },
18
+ offPayloadType: { value: 'bool' },
19
+ warningPayload: { value: 'warning' },
20
+ warningPayloadType: { value: 'str' }
21
+ },
22
+ inputs: 1,
23
+ outputs: 2,
24
+ outputLabels: ['Light command', 'Warning'],
25
+ icon: 'timer.png',
26
+ label: function () {
27
+ return this.name || 'Staircase Light';
28
+ },
29
+ paletteLabel: function () {
30
+ return 'Staircase Light';
31
+ },
32
+ oneditprepare: function () {
33
+ const payloadField = $('#node-input-payloadPropName');
34
+ if (payloadField.val() === '') payloadField.val('payload');
35
+ payloadField.typedInput({ default: 'msg', types: ['msg'] });
36
+
37
+ $('#node-input-onPayload').typedInput({
38
+ default: this.onPayloadType || 'bool',
39
+ types: ['str', 'num', 'bool', 'json', 'date']
40
+ });
41
+ $('#node-input-onPayload').typedInput('type', this.onPayloadType || 'bool');
42
+
43
+ $('#node-input-offPayload').typedInput({
44
+ default: this.offPayloadType || 'bool',
45
+ types: ['str', 'num', 'bool', 'json', 'date']
46
+ });
47
+ $('#node-input-offPayload').typedInput('type', this.offPayloadType || 'bool');
48
+
49
+ $('#node-input-warningPayload').typedInput({
50
+ default: this.warningPayloadType || 'str',
51
+ types: ['str', 'num', 'bool', 'json']
52
+ });
53
+ $('#node-input-warningPayload').typedInput('type', this.warningPayloadType || 'str');
54
+ },
55
+ oneditsave: function () {
56
+ this.onPayloadType = $('#node-input-onPayload').typedInput('type');
57
+ this.offPayloadType = $('#node-input-offPayload').typedInput('type');
58
+ this.warningPayloadType = $('#node-input-warningPayload').typedInput('type');
59
+ }
60
+ });
61
+ </script>
62
+
63
+ <script type="text/html" data-template-name="StaircaseLightUltimate">
64
+ <div class="form-row">
65
+ <b>Staircase Light Ultimate</b>
66
+ &nbsp;&nbsp;<span style="color:red"><i class="fa fa-question-circle"></i>&nbsp;<a target="_blank" href="https://github.com/Supergiovane/node-red-contrib-boolean-logic-ultimate"><u>Help online</u></a></span>
67
+ </div>
68
+
69
+ <div class="form-row">
70
+ <label for="node-input-name"><i class="icon-tag"></i> Name</label>
71
+ <input type="text" id="node-input-name" placeholder="Name">
72
+ </div>
73
+
74
+ <div class="form-row">
75
+ <label for="node-input-controlTopic"><i class="fa fa-tag"></i> Control topic</label>
76
+ <input type="text" id="node-input-controlTopic">
77
+ </div>
78
+
79
+ <div class="form-row">
80
+ <label for="node-input-durationSeconds"><i class="fa fa-clock-o"></i> Duration (s)</label>
81
+ <input type="number" id="node-input-durationSeconds" min="1">
82
+ </div>
83
+
84
+ <div class="form-row">
85
+ <label for="node-input-warningEnabled"><i class="fa fa-bell"></i> Warning before off</label>
86
+ <input type="checkbox" id="node-input-warningEnabled" style="width:auto; margin-top:7px;">
87
+ </div>
88
+
89
+ <div class="form-row">
90
+ <label for="node-input-warningOffsetSeconds"><i class="fa fa-hourglass-end"></i> Warning offset (s)</label>
91
+ <input type="number" id="node-input-warningOffsetSeconds" min="1">
92
+ </div>
93
+
94
+ <div class="form-row">
95
+ <label for="node-input-restartOnTrigger"><i class="fa fa-repeat"></i> Restart on trigger</label>
96
+ <input type="checkbox" id="node-input-restartOnTrigger" style="width:auto; margin-top:7px;">
97
+ </div>
98
+
99
+ <div class="form-row">
100
+ <label for="node-input-allowOffInput"><i class="fa fa-power-off"></i> Allow off input</label>
101
+ <input type="checkbox" id="node-input-allowOffInput" style="width:auto; margin-top:7px;">
102
+ </div>
103
+
104
+ <div class="form-row">
105
+ <label for="node-input-payloadPropName"><i class="fa fa-ellipsis-h"></i> With Input</label>
106
+ <input type="text" id="node-input-payloadPropName">
107
+ </div>
108
+
109
+ <div class="form-row">
110
+ <label for="node-input-translatorConfig"><i class="fa fa-language"></i> Translator</label>
111
+ <input type="text" id="node-input-translatorConfig">
112
+ </div>
113
+
114
+ <div class="form-row">
115
+ <label for="node-input-onPayload"><i class="fa fa-lightbulb-o"></i> On payload</label>
116
+ <input type="text" id="node-input-onPayload">
117
+ </div>
118
+
119
+ <div class="form-row">
120
+ <label for="node-input-offPayload"><i class="fa fa-lightbulb-o"></i> Off payload</label>
121
+ <input type="text" id="node-input-offPayload">
122
+ </div>
123
+
124
+ <div class="form-row">
125
+ <label for="node-input-warningPayload"><i class="fa fa-exclamation-triangle"></i> Warning payload</label>
126
+ <input type="text" id="node-input-warningPayload">
127
+ </div>
128
+ </script>
129
+
130
+ <script type="text/markdown" data-help-name="StaircaseLightUltimate">
131
+ <p>The purpose of this node is to control staircase lighting with timed auto-off and optional warning messages.</p>
132
+
133
+ |Property|Description|
134
+ |--|--|
135
+ | Control topic | Topic that receives manual commands (on/off/extend). |
136
+ | Duration (s) | Length of the lighting period for each trigger. |
137
+ | Warning before off | Enables emission of a pre-off warning on output 2. |
138
+ | Warning offset (s) | Seconds before switch-off when the warning is sent. |
139
+ | Restart on trigger | Restarts the timer every time a new trigger arrives while active. |
140
+ | Allow off input | Allows a `false` from the main input to switch off immediately. |
141
+ | With Input | Message property to evaluate as trigger (default `payload`). |
142
+ | Translator | Optional translator-config for true/false conversion. |
143
+ | On/Off payload | Values emitted on output 1 to switch the light. |
144
+ | Warning payload | Value emitted on output 2 when the warning fires. |
145
+
146
+ <br/>
147
+
148
+ * Control topic commands (`msg.topic` must match the control topic)
149
+
150
+ Pass <code>msg.command = "on"</code> (or <code>msg.start = true</code>) to turn on and start the timer</br>
151
+ Pass <code>msg.command = "off"</code> (or <code>msg.stop = true</code>) to force immediate off</br>
152
+ Pass <code>msg.command = "extend"</code> (or <code>msg.extend = true</code>) to refresh the timer while keeping the light on</br>
153
+ Pass <code>msg.duration</code>, <code>msg.warningEnabled</code> or <code>msg.warningOffset</code> to update runtime settings</br>
154
+
155
+ <br/>
156
+
157
+ Output 1 emits the configured ON/OFF command. Output 2 emits the pre-off warning and includes <code>msg.remaining</code> with the seconds left.</br>
158
+
159
+ <br/>
160
+
161
+ [SEE THE README FOR FULL HELP AND SAMPLES](https://github.com/Supergiovane/node-red-contrib-boolean-logic-ultimate)</br>
162
+
163
+ [Find it useful?](https://www.paypal.me/techtoday)
164
+
165
+ </script>