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,229 @@
1
+ 'use strict';
2
+
3
+ module.exports = function (RED) {
4
+ const helpers = require('./lib/node-helpers.js');
5
+
6
+ function StaircaseLightUltimate(config) {
7
+ RED.nodes.createNode(this, config);
8
+ const node = this;
9
+ const REDUtil = RED.util;
10
+
11
+ const setNodeStatus = helpers.createStatus(node);
12
+ const timerBag = helpers.createTimerBag(node);
13
+
14
+ const controlTopic = config.controlTopic || 'stairs';
15
+ const payloadPropName = config.payloadPropName || 'payload';
16
+
17
+ let translatorConfig = null;
18
+ if (config.translatorConfig) {
19
+ translatorConfig = RED.nodes.getNode(config.translatorConfig);
20
+ }
21
+
22
+ let durationMs = toMilliseconds(config.durationSeconds, 30);
23
+ let warningEnabled = Boolean(config.warningEnabled);
24
+ let warningOffsetMs = toMilliseconds(config.warningOffsetSeconds, 5);
25
+ const restartOnTrigger = config.restartOnTrigger !== false; // default true
26
+
27
+ let active = false;
28
+ let lightTimer = null;
29
+ let warningTimer = null;
30
+ let statusInterval = null;
31
+ let expiresAt = 0;
32
+ let lastTriggerMsg = null;
33
+ let cycleCount = 0;
34
+
35
+ function toMilliseconds(value, defaultSeconds) {
36
+ const seconds = Number(value);
37
+ if (Number.isFinite(seconds) && seconds > 0) {
38
+ return seconds * 1000;
39
+ }
40
+ return defaultSeconds * 1000;
41
+ }
42
+
43
+ function updateStatus(state) {
44
+ if (active) {
45
+ const remaining = Math.max(0, Math.round((expiresAt - Date.now()) / 1000));
46
+ setNodeStatus({
47
+ fill: 'green',
48
+ shape: 'dot',
49
+ text: `ON ${remaining}s (${cycleCount})`,
50
+ });
51
+ } else {
52
+ setNodeStatus({
53
+ fill: state === 'warning' ? 'yellow' : 'grey',
54
+ shape: state === 'warning' ? 'dot' : 'ring',
55
+ text: state === 'warning' ? 'Pre-off' : 'Idle',
56
+ });
57
+ }
58
+ }
59
+
60
+ function clearTimers() {
61
+ if (lightTimer) {
62
+ timerBag.clearTimeout(lightTimer);
63
+ lightTimer = null;
64
+ }
65
+ if (warningTimer) {
66
+ timerBag.clearTimeout(warningTimer);
67
+ warningTimer = null;
68
+ }
69
+ if (statusInterval) {
70
+ timerBag.clearInterval(statusInterval);
71
+ statusInterval = null;
72
+ }
73
+ }
74
+
75
+ function buildOutputMessage(type, value, baseMsg) {
76
+ const msg = baseMsg ? REDUtil.cloneMessage(baseMsg) : {};
77
+ try {
78
+ msg.payload = REDUtil.evaluateNodeProperty(value, type, node, baseMsg);
79
+ } catch (err) {
80
+ msg.payload = value;
81
+ }
82
+ return msg;
83
+ }
84
+
85
+ function sendMainOutput(on, baseMsg) {
86
+ const type = on ? config.onPayloadType || 'bool' : config.offPayloadType || 'bool';
87
+ const value = on ? config.onPayload : config.offPayload;
88
+ const msg = buildOutputMessage(type, value, baseMsg);
89
+ msg.event = on ? 'on' : 'off';
90
+ node.send([msg, null]);
91
+ }
92
+
93
+ function sendWarningOutput(baseMsg, remainingSeconds) {
94
+ const type = config.warningPayloadType || 'str';
95
+ const value = config.warningPayload || 'warning';
96
+ const msg = buildOutputMessage(type, value, baseMsg);
97
+ msg.event = 'warning';
98
+ msg.remaining = remainingSeconds;
99
+ node.send([null, msg]);
100
+ updateStatus('warning');
101
+ }
102
+
103
+ function scheduleStatusInterval() {
104
+ if (!statusInterval) {
105
+ statusInterval = timerBag.setInterval(() => {
106
+ if (!active) {
107
+ timerBag.clearInterval(statusInterval);
108
+ statusInterval = null;
109
+ return;
110
+ }
111
+ updateStatus();
112
+ }, 1000);
113
+ }
114
+ }
115
+
116
+ function forceOff() {
117
+ if (!active) {
118
+ updateStatus();
119
+ return;
120
+ }
121
+ clearTimers();
122
+ active = false;
123
+ cycleCount += 1;
124
+ sendMainOutput(false, lastTriggerMsg);
125
+ updateStatus();
126
+ }
127
+
128
+ function scheduleWarning() {
129
+ if (!warningEnabled) {
130
+ return;
131
+ }
132
+ const remainingMs = expiresAt - Date.now();
133
+ if (remainingMs <= warningOffsetMs) {
134
+ // Not enough time for warning
135
+ return;
136
+ }
137
+ warningTimer = timerBag.setTimeout(() => {
138
+ const remainingSeconds = Math.max(0, Math.round((expiresAt - Date.now()) / 1000));
139
+ sendWarningOutput(lastTriggerMsg, remainingSeconds);
140
+ }, remainingMs - warningOffsetMs);
141
+ }
142
+
143
+ function scheduleOff() {
144
+ clearTimers();
145
+ const now = Date.now();
146
+ expiresAt = now + durationMs;
147
+ lightTimer = timerBag.setTimeout(() => {
148
+ forceOff();
149
+ }, durationMs);
150
+ scheduleWarning();
151
+ scheduleStatusInterval();
152
+ updateStatus();
153
+ }
154
+
155
+ function activate(baseMsg) {
156
+ lastTriggerMsg = baseMsg ? REDUtil.cloneMessage(baseMsg) : lastTriggerMsg;
157
+ if (active) {
158
+ if (restartOnTrigger) {
159
+ scheduleOff();
160
+ }
161
+ return;
162
+ }
163
+ active = true;
164
+ sendMainOutput(true, baseMsg);
165
+ scheduleOff();
166
+ }
167
+
168
+ function handleControlMessage(msg) {
169
+ let consumed = false;
170
+ if (msg.command === 'start' || msg.command === 'on' || msg.start === true) {
171
+ activate(msg);
172
+ consumed = true;
173
+ }
174
+ if (msg.command === 'stop' || msg.command === 'off' || msg.stop === true) {
175
+ forceOff();
176
+ consumed = true;
177
+ }
178
+ if (msg.command === 'extend' || msg.extend === true) {
179
+ if (active) {
180
+ scheduleOff();
181
+ }
182
+ consumed = true;
183
+ }
184
+ if (msg.hasOwnProperty('duration')) {
185
+ durationMs = toMilliseconds(msg.duration, durationMs / 1000);
186
+ if (active) {
187
+ scheduleOff();
188
+ }
189
+ consumed = true;
190
+ }
191
+ if (msg.hasOwnProperty('warningEnabled')) {
192
+ warningEnabled = Boolean(msg.warningEnabled);
193
+ consumed = true;
194
+ }
195
+ if (msg.hasOwnProperty('warningOffset')) {
196
+ warningOffsetMs = toMilliseconds(msg.warningOffset, warningOffsetMs / 1000);
197
+ if (active) {
198
+ scheduleOff();
199
+ }
200
+ consumed = true;
201
+ }
202
+ return consumed;
203
+ }
204
+
205
+ node.on('input', (msg) => {
206
+ if (msg.topic === controlTopic) {
207
+ if (handleControlMessage(msg)) {
208
+ return;
209
+ }
210
+ }
211
+
212
+ const resolved = helpers.resolveInput(msg, payloadPropName, config.translatorConfig, RED);
213
+ const value = resolved.boolean;
214
+ if (value === true) {
215
+ activate(msg);
216
+ } else if (value === false && config.allowOffInput) {
217
+ forceOff();
218
+ }
219
+ });
220
+
221
+ node.on('close', () => {
222
+ clearTimers();
223
+ });
224
+
225
+ updateStatus();
226
+ }
227
+
228
+ RED.nodes.registerType('StaircaseLightUltimate', StaircaseLightUltimate);
229
+ };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const utils = require('../utils.js');
4
+
5
+ function createStatus(node) {
6
+ return function setNodeStatus({ fill, shape, text }) {
7
+ const dDate = new Date();
8
+ node.status({
9
+ fill,
10
+ shape,
11
+ text: `${text} (${dDate.getDate()}, ${dDate.toLocaleTimeString()})`,
12
+ });
13
+ };
14
+ }
15
+
16
+ function resolveInput(msg, propertyPath, translatorConfigId, RED) {
17
+ const propName = propertyPath || 'payload';
18
+ let value;
19
+ try {
20
+ value = utils.fetchFromObject(msg, propName);
21
+ } catch (error) {
22
+ return { value: undefined, boolean: undefined };
23
+ }
24
+ if (value === undefined) {
25
+ return { value: undefined, boolean: undefined };
26
+ }
27
+
28
+ const translatorConfig = translatorConfigId
29
+ ? RED.nodes.getNode(translatorConfigId)
30
+ : null;
31
+
32
+ return {
33
+ value,
34
+ boolean: utils.ToBoolean(value, translatorConfig),
35
+ };
36
+ }
37
+
38
+ function createTimerBag(node) {
39
+ const timers = new Set();
40
+ const intervals = new Set();
41
+
42
+ function trackTimeout(handle) {
43
+ timers.add(handle);
44
+ return handle;
45
+ }
46
+
47
+ function trackInterval(handle) {
48
+ intervals.add(handle);
49
+ return handle;
50
+ }
51
+
52
+ function clearAll() {
53
+ for (const handle of timers) {
54
+ clearTimeout(handle);
55
+ }
56
+ timers.clear();
57
+ for (const handle of intervals) {
58
+ clearInterval(handle);
59
+ }
60
+ intervals.clear();
61
+ }
62
+
63
+ node.on('close', (_removed, done) => {
64
+ clearAll();
65
+ done();
66
+ });
67
+
68
+ return {
69
+ setTimeout(fn, timeout) {
70
+ const handle = setTimeout(() => {
71
+ timers.delete(handle);
72
+ fn();
73
+ }, timeout);
74
+ return trackTimeout(handle);
75
+ },
76
+ setInterval(fn, interval) {
77
+ const handle = setInterval(fn, interval);
78
+ return trackInterval(handle);
79
+ },
80
+ clearTimeout(handle) {
81
+ clearTimeout(handle);
82
+ timers.delete(handle);
83
+ },
84
+ clearInterval(handle) {
85
+ clearInterval(handle);
86
+ intervals.delete(handle);
87
+ },
88
+ clearAll,
89
+ };
90
+ }
91
+
92
+ module.exports = {
93
+ createStatus,
94
+ resolveInput,
95
+ createTimerBag,
96
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-boolean-logic-ultimate",
3
- "version": "1.1.27",
3
+ "version": "1.2.1",
4
4
  "description": "A set of Node-RED enhanced boolean logic and utility nodes, flow interruption, blinker, invert, filter, toggle etc.., with persistent values after reboot. Compatible also with Homeassistant values.",
5
5
  "author": "Supergiovane (https://github.com/Supergiovane)",
6
6
  "dependencies": {
@@ -8,6 +8,12 @@
8
8
  "path": ">=0.12.7",
9
9
  "kalmanjs": "1.1.0"
10
10
  },
11
+ "devDependencies": {
12
+ "chai": "^4.3.10",
13
+ "mocha": "^10.4.0",
14
+ "node-red": "^3.1.0",
15
+ "node-red-node-test-helper": "^0.3.5"
16
+ },
11
17
  "keywords": [
12
18
  "node-red",
13
19
  "boolean",
@@ -36,7 +42,13 @@
36
42
  "RailwaySwitchUltimate": "boolean-logic-ultimate/RailwaySwitchUltimate.js",
37
43
  "Comparator": "boolean-logic-ultimate/Comparator.js",
38
44
  "KalmanFilterUltimate": "boolean-logic-ultimate/KalmanFilterUltimate.js",
45
+ "RateLimiterUltimate": "boolean-logic-ultimate/RateLimiterUltimate.js",
46
+ "PresenceSimulatorUltimate": "boolean-logic-ultimate/PresenceSimulatorUltimate.js",
47
+ "StaircaseLightUltimate": "boolean-logic-ultimate/StaircaseLightUltimate.js",
39
48
  "translator-config": "boolean-logic-ultimate/translator-config.js"
40
49
  }
50
+ },
51
+ "scripts": {
52
+ "test": "mocha test/**/*.spec.js"
41
53
  }
42
54
  }
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const helper = require('node-red-node-test-helper');
5
+
6
+ helper.init(require.resolve('node-red')); // initialise with Node-RED runtime
7
+
8
+ const nodes = {
9
+ RateLimiterUltimate: require(path.join('..', 'boolean-logic-ultimate', 'RateLimiterUltimate.js')),
10
+ PresenceSimulatorUltimate: require(path.join('..', 'boolean-logic-ultimate', 'PresenceSimulatorUltimate.js')),
11
+ };
12
+
13
+ function loadNode(nodeDef, flow, credentials = {}) {
14
+ return helper.load(nodeDef, flow, credentials);
15
+ }
16
+
17
+ function loadRateLimiter(flow, credentials = {}) {
18
+ return loadNode(nodes.RateLimiterUltimate, flow, credentials);
19
+ }
20
+
21
+ function loadPresence(flow, credentials = {}) {
22
+ return loadNode(nodes.PresenceSimulatorUltimate, flow, credentials);
23
+ }
24
+
25
+ module.exports = {
26
+ helper,
27
+ loadRateLimiter,
28
+ loadPresence,
29
+ };
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const { helper, loadPresence } = require('./helpers');
5
+
6
+ describe('PresenceSimulatorUltimate node', function () {
7
+ this.timeout(5000);
8
+
9
+ before(function (done) {
10
+ helper.startServer(done);
11
+ });
12
+
13
+ after(function (done) {
14
+ helper.stopServer(done);
15
+ });
16
+
17
+ afterEach(function () {
18
+ return helper.unload();
19
+ });
20
+
21
+ it('plays sequence after start command', function (done) {
22
+ const flowId = 'flowPresence1';
23
+ const flow = [
24
+ { id: flowId, type: 'tab', label: 'presence1' },
25
+ {
26
+ id: 'presence',
27
+ type: 'PresenceSimulatorUltimate',
28
+ z: flowId,
29
+ name: '',
30
+ controlTopic: 'presence',
31
+ autoStart: false,
32
+ autoLoop: false,
33
+ randomize: false,
34
+ patterns: '{"delay":20,"payload":true,"topic":"light"}',
35
+ wires: [['out']],
36
+ },
37
+ { id: 'in', type: 'helper', z: flowId, wires: [['presence']] },
38
+ { id: 'out', type: 'helper', z: flowId },
39
+ ];
40
+
41
+ loadPresence(flow).then(() => {
42
+ const input = helper.getNode('in');
43
+ const out = helper.getNode('out');
44
+
45
+ out.on('input', (msg) => {
46
+ try {
47
+ expect(msg).to.have.property('payload', true);
48
+ expect(msg).to.have.property('topic', 'light');
49
+ done();
50
+ } catch (err) {
51
+ done(err);
52
+ }
53
+ });
54
+
55
+ input.receive({ topic: 'presence', command: 'start' });
56
+ }).catch(done);
57
+ });
58
+
59
+ it('stops sequence on stop command', function (done) {
60
+ const flowId = 'flowPresence2';
61
+ const flow = [
62
+ { id: flowId, type: 'tab', label: 'presence2' },
63
+ {
64
+ id: 'presence',
65
+ type: 'PresenceSimulatorUltimate',
66
+ z: flowId,
67
+ controlTopic: 'presence',
68
+ autoStart: false,
69
+ autoLoop: true,
70
+ randomize: false,
71
+ patterns: '{"delay":20,"payload":"on","topic":"light"}',
72
+ wires: [['out']],
73
+ },
74
+ { id: 'in', type: 'helper', z: flowId, wires: [['presence']] },
75
+ { id: 'out', type: 'helper', z: flowId },
76
+ ];
77
+
78
+ loadPresence(flow).then(() => {
79
+ const input = helper.getNode('in');
80
+ const out = helper.getNode('out');
81
+ let received = 0;
82
+
83
+ out.on('input', () => {
84
+ received += 1;
85
+ if (received === 1) {
86
+ input.receive({ topic: 'presence', command: 'stop' });
87
+ setTimeout(() => {
88
+ try {
89
+ expect(received).to.equal(1);
90
+ done();
91
+ } catch (err) {
92
+ done(err);
93
+ }
94
+ }, 100);
95
+ }
96
+ });
97
+
98
+ input.receive({ topic: 'presence', command: 'start' });
99
+ }).catch(done);
100
+ });
101
+ });
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const { helper, loadRateLimiter } = require('./helpers');
5
+
6
+ describe('RateLimiterUltimate node', function () {
7
+ this.timeout(5000);
8
+
9
+ before(function (done) {
10
+ helper.startServer(done);
11
+ });
12
+
13
+ after(function (done) {
14
+ helper.stopServer(done);
15
+ });
16
+
17
+ afterEach(function () {
18
+ return helper.unload();
19
+ });
20
+
21
+ it('debounce trailing emits after wait', function (done) {
22
+ const flowId = 'flow1';
23
+ const flow = [
24
+ { id: flowId, type: 'tab', label: 'flow1' },
25
+ {
26
+ id: 'rate',
27
+ type: 'RateLimiterUltimate',
28
+ z: flowId,
29
+ name: 'debounce-test',
30
+ mode: 'debounce',
31
+ wait: 40,
32
+ emitOn: 'trailing',
33
+ payloadPropName: 'payload',
34
+ controlTopic: 'rate',
35
+ statInterval: 0,
36
+ wires: [['out'], ['diag']],
37
+ },
38
+ { id: 'in', type: 'helper', z: flowId, wires: [['rate']] },
39
+ { id: 'out', type: 'helper', z: flowId },
40
+ { id: 'diag', type: 'helper', z: flowId },
41
+ ];
42
+
43
+ loadRateLimiter(flow).then(() => {
44
+ const input = helper.getNode('in');
45
+ const out = helper.getNode('out');
46
+ const diag = helper.getNode('diag');
47
+
48
+ diag.on('input', () => done(new Error('Should not emit diagnostics for trailing debounce')));
49
+
50
+ out.on('input', (msg) => {
51
+ try {
52
+ expect(msg.payload).to.equal('first');
53
+ done();
54
+ } catch (err) {
55
+ done(err);
56
+ }
57
+ });
58
+
59
+ input.receive({ topic: 'sensor', payload: 'first' });
60
+ }).catch(done);
61
+ });
62
+
63
+ it('throttle drops rapid message when trailing disabled', function (done) {
64
+ const flowId = 'flow2';
65
+ const flow = [
66
+ { id: flowId, type: 'tab', label: 'flow2' },
67
+ {
68
+ id: 'rate',
69
+ type: 'RateLimiterUltimate',
70
+ z: flowId,
71
+ name: 'throttle-test',
72
+ mode: 'throttle',
73
+ interval: 120,
74
+ trailing: false,
75
+ payloadPropName: 'payload',
76
+ controlTopic: 'rate',
77
+ statInterval: 0,
78
+ wires: [['out'], ['diag']],
79
+ },
80
+ { id: 'in', type: 'helper', z: flowId, wires: [['rate']] },
81
+ { id: 'out', type: 'helper', z: flowId },
82
+ { id: 'diag', type: 'helper', z: flowId },
83
+ ];
84
+
85
+ loadRateLimiter(flow).then(() => {
86
+ const input = helper.getNode('in');
87
+ const out = helper.getNode('out');
88
+ const diag = helper.getNode('diag');
89
+ let seenForward = 0;
90
+
91
+ out.on('input', (msg) => {
92
+ seenForward += 1;
93
+ if (seenForward > 1) {
94
+ done(new Error('Unexpected second forward message'));
95
+ } else {
96
+ expect(msg.payload).to.equal('first');
97
+ }
98
+ });
99
+
100
+ diag.on('input', (msg) => {
101
+ try {
102
+ expect(msg.payload).to.have.property('reason', 'dropped-throttle');
103
+ expect(msg.payload.msg.payload).to.equal('second');
104
+ done();
105
+ } catch (err) {
106
+ done(err);
107
+ }
108
+ });
109
+
110
+ input.receive({ topic: 'sensor', payload: 'first' });
111
+ setTimeout(() => {
112
+ input.receive({ topic: 'sensor', payload: 'second' });
113
+ }, 20);
114
+ }).catch(done);
115
+ });
116
+
117
+ it('window queue replays message after window expires', function (done) {
118
+ const flowId = 'flow3';
119
+ const flow = [
120
+ { id: flowId, type: 'tab', label: 'flow3' },
121
+ {
122
+ id: 'rate',
123
+ type: 'RateLimiterUltimate',
124
+ z: flowId,
125
+ name: 'window-test',
126
+ mode: 'window',
127
+ windowSize: 120,
128
+ maxInWindow: 1,
129
+ dropStrategy: 'queue',
130
+ payloadPropName: 'payload',
131
+ controlTopic: 'rate',
132
+ statInterval: 0,
133
+ wires: [['out'], ['diag']],
134
+ },
135
+ { id: 'in', type: 'helper', z: flowId, wires: [['rate']] },
136
+ { id: 'out', type: 'helper', z: flowId },
137
+ { id: 'diag', type: 'helper', z: flowId },
138
+ ];
139
+
140
+ loadRateLimiter(flow).then(() => {
141
+ const input = helper.getNode('in');
142
+ const out = helper.getNode('out');
143
+ const diag = helper.getNode('diag');
144
+ const start = Date.now();
145
+ const outputs = [];
146
+
147
+ diag.on('input', () => done(new Error('Queue strategy should not drop messages')));
148
+
149
+ out.on('input', (msg) => {
150
+ outputs.push({ msg, ts: Date.now() - start });
151
+ if (outputs.length === 2) {
152
+ try {
153
+ expect(outputs[0].msg.payload).to.equal('one');
154
+ expect(outputs[1].msg.payload).to.equal('two');
155
+ expect(outputs[1].ts).to.be.at.least(100);
156
+ done();
157
+ } catch (err) {
158
+ done(err);
159
+ }
160
+ }
161
+ });
162
+
163
+ input.receive({ topic: 'sensor', payload: 'one' });
164
+ setTimeout(() => {
165
+ input.receive({ topic: 'sensor', payload: 'two' });
166
+ }, 20);
167
+ }).catch(done);
168
+ });
169
+ });