node-red-contrib-event-calc 0.1.2 → 0.1.4
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/nodes/event-cache.js +84 -139
- package/nodes/event-calc.html +37 -21
- package/nodes/event-calc.js +73 -26
- package/nodes/event-chart.html +239 -0
- package/nodes/event-chart.js +106 -0
- package/nodes/event-simulator.html +156 -0
- package/nodes/event-simulator.js +185 -0
- package/nodes/event-topic.html +16 -30
- package/nodes/event-topic.js +33 -40
- package/package.json +4 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.event-calc-white-text { fill: #ffffff !important; }
|
|
3
|
+
.red-ui-palette-node[data-palette-type="event-simulator"] .red-ui-palette-label { color: #ffffff !important; }
|
|
4
|
+
</style>
|
|
5
|
+
|
|
6
|
+
<script type="text/javascript">
|
|
7
|
+
RED.nodes.registerType('event-simulator', {
|
|
8
|
+
category: 'event calc',
|
|
9
|
+
color: '#758467',
|
|
10
|
+
defaults: {
|
|
11
|
+
name: { value: "" },
|
|
12
|
+
waveform: { value: "sinusoid" },
|
|
13
|
+
amplitude: { value: 1, validate: RED.validators.number() },
|
|
14
|
+
frequency: { value: 1, validate: RED.validators.number() },
|
|
15
|
+
offset: { value: 0, validate: RED.validators.number() },
|
|
16
|
+
interval: { value: 100, validate: RED.validators.number() },
|
|
17
|
+
startOnDeploy: { value: true },
|
|
18
|
+
noiseLevel: { value: 0, validate: RED.validators.number() },
|
|
19
|
+
topicCount: { value: 1, validate: RED.validators.number() },
|
|
20
|
+
phaseSpread: { value: true }
|
|
21
|
+
},
|
|
22
|
+
inputs: 1,
|
|
23
|
+
outputs: 1,
|
|
24
|
+
icon: "font-awesome/fa-signal",
|
|
25
|
+
label: function() {
|
|
26
|
+
return this.name || this.waveform || "simulator";
|
|
27
|
+
},
|
|
28
|
+
paletteLabel: "simulator",
|
|
29
|
+
labelStyle: function() {
|
|
30
|
+
return (this.name ? "node_label_italic" : "") + " event-calc-white-text";
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<script type="text/html" data-template-name="event-simulator">
|
|
36
|
+
<div class="form-row">
|
|
37
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
38
|
+
<input type="text" id="node-input-name" placeholder="Name (used as topic base)">
|
|
39
|
+
</div>
|
|
40
|
+
<div class="form-row">
|
|
41
|
+
<label for="node-input-topicCount"><i class="fa fa-clone"></i> Topic Count</label>
|
|
42
|
+
<input type="number" id="node-input-topicCount" min="1" max="100" value="1">
|
|
43
|
+
<div class="form-tips">Number of topics to generate (Name1, Name2, ... if count > 1)</div>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="form-row">
|
|
46
|
+
<label for="node-input-waveform"><i class="fa fa-signal"></i> Waveform</label>
|
|
47
|
+
<select id="node-input-waveform">
|
|
48
|
+
<option value="sinusoid">Sinusoid</option>
|
|
49
|
+
<option value="cosine">Cosine</option>
|
|
50
|
+
<option value="sawtooth">Sawtooth</option>
|
|
51
|
+
<option value="triangle">Triangle</option>
|
|
52
|
+
<option value="rectangle">Rectangle (Square)</option>
|
|
53
|
+
<option value="pulse">Pulse</option>
|
|
54
|
+
<option value="random">Random</option>
|
|
55
|
+
<option value="randomWalk">Random Walk</option>
|
|
56
|
+
</select>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="form-row">
|
|
59
|
+
<label for="node-input-amplitude"><i class="fa fa-arrows-v"></i> Amplitude</label>
|
|
60
|
+
<input type="number" id="node-input-amplitude" step="0.1" value="1">
|
|
61
|
+
</div>
|
|
62
|
+
<div class="form-row">
|
|
63
|
+
<label for="node-input-frequency"><i class="fa fa-tachometer"></i> Frequency (Hz)</label>
|
|
64
|
+
<input type="number" id="node-input-frequency" step="0.1" min="0.001" value="1">
|
|
65
|
+
</div>
|
|
66
|
+
<div class="form-row">
|
|
67
|
+
<label for="node-input-offset"><i class="fa fa-arrows-h"></i> Offset</label>
|
|
68
|
+
<input type="number" id="node-input-offset" step="0.1" value="0">
|
|
69
|
+
</div>
|
|
70
|
+
<div class="form-row">
|
|
71
|
+
<label for="node-input-interval"><i class="fa fa-clock-o"></i> Interval (ms)</label>
|
|
72
|
+
<input type="number" id="node-input-interval" min="10" value="100">
|
|
73
|
+
</div>
|
|
74
|
+
<div class="form-row">
|
|
75
|
+
<label for="node-input-noiseLevel"><i class="fa fa-random"></i> Noise Level</label>
|
|
76
|
+
<input type="number" id="node-input-noiseLevel" step="0.01" min="0" max="1" value="0">
|
|
77
|
+
<div class="form-tips">Noise as fraction of amplitude (0-1)</div>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="form-row">
|
|
80
|
+
<label for="node-input-phaseSpread">
|
|
81
|
+
<input type="checkbox" id="node-input-phaseSpread" style="width:auto; margin-left:0;" checked>
|
|
82
|
+
Phase spread (offset each topic's phase when count > 1)
|
|
83
|
+
</label>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="form-row">
|
|
86
|
+
<label for="node-input-startOnDeploy">
|
|
87
|
+
<input type="checkbox" id="node-input-startOnDeploy" style="width:auto; margin-left:0;" checked>
|
|
88
|
+
Start on deploy
|
|
89
|
+
</label>
|
|
90
|
+
</div>
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<script type="text/html" data-help-name="event-simulator">
|
|
94
|
+
<p>Generates simulated time-series data with various waveforms.</p>
|
|
95
|
+
|
|
96
|
+
<h3>Outputs</h3>
|
|
97
|
+
<dl class="message-properties">
|
|
98
|
+
<dt>payload <span class="property-type">number</span></dt>
|
|
99
|
+
<dd>Generated value</dd>
|
|
100
|
+
<dt>topic <span class="property-type">string</span></dt>
|
|
101
|
+
<dd>Topic name. If count=1: uses Name. If count>1: uses Name1, Name2, etc.</dd>
|
|
102
|
+
<dt>timestamp <span class="property-type">number</span></dt>
|
|
103
|
+
<dd>Unix timestamp in ms</dd>
|
|
104
|
+
</dl>
|
|
105
|
+
|
|
106
|
+
<h3>Multiple Topics</h3>
|
|
107
|
+
<p>Set <b>Topic Count</b> > 1 to generate multiple messages per interval:</p>
|
|
108
|
+
<ul>
|
|
109
|
+
<li>Name="Sensor", Count=3 → topics: Sensor1, Sensor2, Sensor3</li>
|
|
110
|
+
<li><b>Phase Spread</b>: Each topic gets an evenly-distributed phase offset</li>
|
|
111
|
+
<li>Useful for simulating multiple related sensors</li>
|
|
112
|
+
</ul>
|
|
113
|
+
|
|
114
|
+
<h3>Waveforms</h3>
|
|
115
|
+
<ul>
|
|
116
|
+
<li><b>Sinusoid</b> - Smooth sine wave</li>
|
|
117
|
+
<li><b>Cosine</b> - Cosine wave (90° phase shift)</li>
|
|
118
|
+
<li><b>Sawtooth</b> - Linear ramp up, instant drop</li>
|
|
119
|
+
<li><b>Triangle</b> - Linear ramp up and down</li>
|
|
120
|
+
<li><b>Rectangle</b> - Square wave (±amplitude)</li>
|
|
121
|
+
<li><b>Pulse</b> - Short pulse (10% duty cycle)</li>
|
|
122
|
+
<li><b>Random</b> - Random values each sample</li>
|
|
123
|
+
<li><b>Random Walk</b> - Brownian motion with mean reversion</li>
|
|
124
|
+
</ul>
|
|
125
|
+
|
|
126
|
+
<h3>Parameters</h3>
|
|
127
|
+
<ul>
|
|
128
|
+
<li><b>Topic Count</b> - Number of topics to generate (1-100)</li>
|
|
129
|
+
<li><b>Amplitude</b> - Peak value (signal swings ±amplitude around offset)</li>
|
|
130
|
+
<li><b>Frequency</b> - Cycles per second (Hz)</li>
|
|
131
|
+
<li><b>Offset</b> - DC offset (center value)</li>
|
|
132
|
+
<li><b>Interval</b> - Sample rate in milliseconds</li>
|
|
133
|
+
<li><b>Noise Level</b> - Random noise as fraction of amplitude</li>
|
|
134
|
+
<li><b>Phase Spread</b> - Distribute phase offsets across topics</li>
|
|
135
|
+
</ul>
|
|
136
|
+
|
|
137
|
+
<h3>Control</h3>
|
|
138
|
+
<p>Send messages to control the simulator:</p>
|
|
139
|
+
<ul>
|
|
140
|
+
<li><code>msg.payload = "start"</code> - Start generating</li>
|
|
141
|
+
<li><code>msg.payload = "stop"</code> - Stop generating</li>
|
|
142
|
+
<li><code>msg.payload = "reset"</code> - Reset time and restart</li>
|
|
143
|
+
</ul>
|
|
144
|
+
|
|
145
|
+
<h3>Dynamic Parameters</h3>
|
|
146
|
+
<p>Update parameters via message properties:</p>
|
|
147
|
+
<ul>
|
|
148
|
+
<li><code>msg.amplitude</code></li>
|
|
149
|
+
<li><code>msg.frequency</code></li>
|
|
150
|
+
<li><code>msg.offset</code></li>
|
|
151
|
+
<li><code>msg.interval</code></li>
|
|
152
|
+
<li><code>msg.waveform</code></li>
|
|
153
|
+
<li><code>msg.topicCount</code></li>
|
|
154
|
+
<li><code>msg.phaseSpread</code></li>
|
|
155
|
+
</ul>
|
|
156
|
+
</script>
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
function EventSimulatorNode(config) {
|
|
3
|
+
RED.nodes.createNode(this, config);
|
|
4
|
+
const node = this;
|
|
5
|
+
|
|
6
|
+
node.waveform = config.waveform || "sinusoid";
|
|
7
|
+
node.amplitude = parseFloat(config.amplitude) || 1;
|
|
8
|
+
node.frequency = parseFloat(config.frequency) || 1; // Hz
|
|
9
|
+
node.offset = parseFloat(config.offset) || 0;
|
|
10
|
+
node.interval = parseInt(config.interval) || 100; // ms
|
|
11
|
+
node.startOnDeploy = config.startOnDeploy !== false;
|
|
12
|
+
node.noiseLevel = parseFloat(config.noiseLevel) || 0;
|
|
13
|
+
node.topicCount = parseInt(config.topicCount) || 1;
|
|
14
|
+
node.phaseSpread = config.phaseSpread !== false; // Add phase offset between topics
|
|
15
|
+
|
|
16
|
+
const baseName = config.name || "simulator";
|
|
17
|
+
|
|
18
|
+
let timer = null;
|
|
19
|
+
let startTime = Date.now();
|
|
20
|
+
let sampleCount = 0;
|
|
21
|
+
|
|
22
|
+
// Waveform generators
|
|
23
|
+
const waveforms = {
|
|
24
|
+
sinusoid: (t, freq, amp, offset) => {
|
|
25
|
+
return amp * Math.sin(2 * Math.PI * freq * t) + offset;
|
|
26
|
+
},
|
|
27
|
+
cosine: (t, freq, amp, offset) => {
|
|
28
|
+
return amp * Math.cos(2 * Math.PI * freq * t) + offset;
|
|
29
|
+
},
|
|
30
|
+
sawtooth: (t, freq, amp, offset) => {
|
|
31
|
+
const period = 1 / freq;
|
|
32
|
+
const phase = (t % period) / period;
|
|
33
|
+
return amp * (2 * phase - 1) + offset;
|
|
34
|
+
},
|
|
35
|
+
triangle: (t, freq, amp, offset) => {
|
|
36
|
+
const period = 1 / freq;
|
|
37
|
+
const phase = (t % period) / period;
|
|
38
|
+
return amp * (4 * Math.abs(phase - 0.5) - 1) + offset;
|
|
39
|
+
},
|
|
40
|
+
rectangle: (t, freq, amp, offset) => {
|
|
41
|
+
const period = 1 / freq;
|
|
42
|
+
const phase = (t % period) / period;
|
|
43
|
+
return amp * (phase < 0.5 ? 1 : -1) + offset;
|
|
44
|
+
},
|
|
45
|
+
pulse: (t, freq, amp, offset) => {
|
|
46
|
+
const period = 1 / freq;
|
|
47
|
+
const phase = (t % period) / period;
|
|
48
|
+
return amp * (phase < 0.1 ? 1 : 0) + offset;
|
|
49
|
+
},
|
|
50
|
+
random: (t, freq, amp, offset) => {
|
|
51
|
+
return amp * (Math.random() * 2 - 1) + offset;
|
|
52
|
+
},
|
|
53
|
+
randomWalk: (t, freq, amp, offset, topicIndex) => {
|
|
54
|
+
// Random walk with mean reversion (per-topic state)
|
|
55
|
+
if (!node._lastValues) node._lastValues = {};
|
|
56
|
+
if (node._lastValues[topicIndex] === undefined) node._lastValues[topicIndex] = offset;
|
|
57
|
+
const step = (Math.random() * 2 - 1) * amp * 0.1;
|
|
58
|
+
const reversion = (offset - node._lastValues[topicIndex]) * 0.05;
|
|
59
|
+
node._lastValues[topicIndex] = Math.max(offset - amp, Math.min(offset + amp, node._lastValues[topicIndex] + step + reversion));
|
|
60
|
+
return node._lastValues[topicIndex];
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function generateSample() {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const t = (now - startTime) / 1000; // time in seconds
|
|
67
|
+
sampleCount++;
|
|
68
|
+
|
|
69
|
+
const generator = waveforms[node.waveform] || waveforms.sinusoid;
|
|
70
|
+
|
|
71
|
+
// Generate message for each topic
|
|
72
|
+
for (let i = 0; i < node.topicCount; i++) {
|
|
73
|
+
// Calculate phase offset for this topic (spread evenly across one period)
|
|
74
|
+
const phaseOffset = node.phaseSpread ? (i / node.topicCount) / node.frequency : 0;
|
|
75
|
+
const tWithPhase = t + phaseOffset;
|
|
76
|
+
|
|
77
|
+
let value = generator(tWithPhase, node.frequency, node.amplitude, node.offset, i);
|
|
78
|
+
|
|
79
|
+
// Add noise if configured
|
|
80
|
+
if (node.noiseLevel > 0) {
|
|
81
|
+
value += (Math.random() * 2 - 1) * node.noiseLevel * node.amplitude;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Topic name: baseName for count=1, baseName1/baseName2/... for count>1
|
|
85
|
+
const topic = node.topicCount === 1 ? baseName : `${baseName}${i + 1}`;
|
|
86
|
+
|
|
87
|
+
const msg = {
|
|
88
|
+
topic: topic,
|
|
89
|
+
payload: value,
|
|
90
|
+
timestamp: now,
|
|
91
|
+
_simulator: {
|
|
92
|
+
waveform: node.waveform,
|
|
93
|
+
frequency: node.frequency,
|
|
94
|
+
amplitude: node.amplitude,
|
|
95
|
+
sample: sampleCount,
|
|
96
|
+
time: tWithPhase,
|
|
97
|
+
topicIndex: i + 1
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
node.send(msg);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Status shows count and first value
|
|
105
|
+
const firstValue = generator(t, node.frequency, node.amplitude, node.offset, 0);
|
|
106
|
+
const statusText = node.topicCount > 1
|
|
107
|
+
? `${sampleCount}: ${node.topicCount} topics`
|
|
108
|
+
: `${sampleCount}: ${firstValue.toFixed(3)}`;
|
|
109
|
+
node.status({ fill: "green", shape: "dot", text: statusText });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function start() {
|
|
113
|
+
if (timer) return;
|
|
114
|
+
startTime = Date.now();
|
|
115
|
+
sampleCount = 0;
|
|
116
|
+
node._lastValues = {}; // Reset random walk state
|
|
117
|
+
timer = setInterval(generateSample, node.interval);
|
|
118
|
+
const statusText = node.topicCount > 1 ? `running (${node.topicCount} topics)` : "running";
|
|
119
|
+
node.status({ fill: "green", shape: "dot", text: statusText });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function stop() {
|
|
123
|
+
if (timer) {
|
|
124
|
+
clearInterval(timer);
|
|
125
|
+
timer = null;
|
|
126
|
+
}
|
|
127
|
+
node.status({ fill: "grey", shape: "ring", text: "stopped" });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function reset() {
|
|
131
|
+
stop();
|
|
132
|
+
start();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Start on deploy if configured
|
|
136
|
+
if (node.startOnDeploy) {
|
|
137
|
+
start();
|
|
138
|
+
} else {
|
|
139
|
+
node.status({ fill: "grey", shape: "ring", text: "stopped" });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
node.on('input', function(msg, send, done) {
|
|
143
|
+
const command = (msg.payload || "").toString().toLowerCase();
|
|
144
|
+
|
|
145
|
+
switch (command) {
|
|
146
|
+
case 'start':
|
|
147
|
+
start();
|
|
148
|
+
break;
|
|
149
|
+
case 'stop':
|
|
150
|
+
stop();
|
|
151
|
+
break;
|
|
152
|
+
case 'reset':
|
|
153
|
+
reset();
|
|
154
|
+
break;
|
|
155
|
+
default:
|
|
156
|
+
// Update parameters if provided
|
|
157
|
+
if (typeof msg.amplitude === 'number') node.amplitude = msg.amplitude;
|
|
158
|
+
if (typeof msg.frequency === 'number') node.frequency = msg.frequency;
|
|
159
|
+
if (typeof msg.offset === 'number') node.offset = msg.offset;
|
|
160
|
+
if (typeof msg.interval === 'number') {
|
|
161
|
+
node.interval = msg.interval;
|
|
162
|
+
if (timer) reset();
|
|
163
|
+
}
|
|
164
|
+
if (typeof msg.topicCount === 'number' && msg.topicCount >= 1) {
|
|
165
|
+
node.topicCount = Math.floor(msg.topicCount);
|
|
166
|
+
}
|
|
167
|
+
if (typeof msg.phaseSpread === 'boolean') {
|
|
168
|
+
node.phaseSpread = msg.phaseSpread;
|
|
169
|
+
}
|
|
170
|
+
if (msg.waveform && waveforms[msg.waveform]) {
|
|
171
|
+
node.waveform = msg.waveform;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (done) done();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
node.on('close', function(done) {
|
|
179
|
+
stop();
|
|
180
|
+
done();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
RED.nodes.registerType("event-simulator", EventSimulatorNode);
|
|
185
|
+
};
|
package/nodes/event-topic.html
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
defaults: {
|
|
11
11
|
name: { value: "" },
|
|
12
12
|
cache: { value: "", type: "event-cache", required: true },
|
|
13
|
-
|
|
13
|
+
topic: { value: "" },
|
|
14
14
|
outputFormat: { value: "value" },
|
|
15
15
|
outputOnStart: { value: false }
|
|
16
16
|
},
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
outputs: 1,
|
|
19
19
|
icon: "font-awesome/fa-filter",
|
|
20
20
|
label: function() {
|
|
21
|
-
return this.name || this.
|
|
21
|
+
return this.name || this.topic || "event topic";
|
|
22
22
|
},
|
|
23
23
|
paletteLabel: "event topic",
|
|
24
24
|
labelStyle: function() { return (this.name ? "node_label_italic" : "") + " event-calc-white-text"; },
|
|
@@ -30,18 +30,18 @@
|
|
|
30
30
|
const cacheId = $("#node-input-cache").val();
|
|
31
31
|
if (cacheId) {
|
|
32
32
|
$.getJSON("event-cache/" + cacheId + "/topics", function(topics) {
|
|
33
|
-
$("#node-input-
|
|
33
|
+
$("#node-input-topic").autocomplete("option", "source", topics || []);
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// Setup autocomplete on
|
|
39
|
-
$("#node-input-
|
|
38
|
+
// Setup autocomplete on topic input
|
|
39
|
+
$("#node-input-topic").autocomplete({
|
|
40
40
|
source: [],
|
|
41
41
|
minLength: 0,
|
|
42
42
|
delay: 0
|
|
43
43
|
}).on("focus", function() {
|
|
44
|
-
if (!$(this).val()
|
|
44
|
+
if (!$(this).val()) {
|
|
45
45
|
$(this).autocomplete("search", "");
|
|
46
46
|
}
|
|
47
47
|
});
|
|
@@ -65,19 +65,14 @@
|
|
|
65
65
|
<input type="text" id="node-input-cache">
|
|
66
66
|
</div>
|
|
67
67
|
<div class="form-row">
|
|
68
|
-
<label for="node-input-
|
|
69
|
-
<input type="text" id="node-input-
|
|
70
|
-
<div class="form-tips">
|
|
71
|
-
Wildcards: <code>?</code> = exactly one character, <code>*</code> = one or more characters.<br>
|
|
72
|
-
Examples: <code>sensor?</code> matches sensor1, <code>sensors/*</code> matches sensors/temp
|
|
73
|
-
</div>
|
|
68
|
+
<label for="node-input-topic"><i class="fa fa-bookmark"></i> Topic</label>
|
|
69
|
+
<input type="text" id="node-input-topic" placeholder="sensors/room1/temp">
|
|
74
70
|
</div>
|
|
75
71
|
<div class="form-row">
|
|
76
72
|
<label for="node-input-outputFormat"><i class="fa fa-sign-out"></i> Output Format</label>
|
|
77
73
|
<select id="node-input-outputFormat" style="width:70%;">
|
|
78
74
|
<option value="value">Value only (msg.payload = value)</option>
|
|
79
75
|
<option value="full">Full entry (msg.payload = {value, ts, metadata})</option>
|
|
80
|
-
<option value="all">All matching (msg.payload = {topic: value, ...})</option>
|
|
81
76
|
</select>
|
|
82
77
|
</div>
|
|
83
78
|
<div class="form-row">
|
|
@@ -88,32 +83,31 @@
|
|
|
88
83
|
</script>
|
|
89
84
|
|
|
90
85
|
<script type="text/html" data-help-name="event-topic">
|
|
91
|
-
<p>Subscribes to a topic
|
|
86
|
+
<p>Subscribes to a topic and outputs when that topic updates in the cache.</p>
|
|
92
87
|
|
|
93
88
|
<h3>Properties</h3>
|
|
94
89
|
<dl class="message-properties">
|
|
95
90
|
<dt>Cache</dt>
|
|
96
91
|
<dd>The event-cache config node to use</dd>
|
|
97
|
-
<dt>Topic
|
|
98
|
-
<dd>
|
|
92
|
+
<dt>Topic</dt>
|
|
93
|
+
<dd>The exact topic to subscribe to</dd>
|
|
99
94
|
<dt>Output Format</dt>
|
|
100
95
|
<dd>
|
|
101
96
|
<ul>
|
|
102
97
|
<li><b>Value only</b>: <code>msg.payload</code> contains just the value</li>
|
|
103
98
|
<li><b>Full entry</b>: <code>msg.payload</code> contains <code>{value, ts, metadata}</code></li>
|
|
104
|
-
<li><b>All matching</b>: <code>msg.payload</code> contains all cached values matching the pattern</li>
|
|
105
99
|
</ul>
|
|
106
100
|
</dd>
|
|
107
101
|
<dt>Output on deploy</dt>
|
|
108
|
-
<dd>If checked, outputs
|
|
102
|
+
<dd>If checked, outputs the current cached value when the flow starts</dd>
|
|
109
103
|
</dl>
|
|
110
104
|
|
|
111
105
|
<h3>Inputs</h3>
|
|
112
106
|
<dl class="message-properties">
|
|
113
|
-
<dt class="optional">
|
|
114
|
-
<dd>Dynamically change the subscription
|
|
115
|
-
<dt class="optional">payload
|
|
116
|
-
<dd>Output
|
|
107
|
+
<dt class="optional">topic + payload="subscribe"</dt>
|
|
108
|
+
<dd>Dynamically change the subscription to a new topic</dd>
|
|
109
|
+
<dt class="optional">payload = "refresh"</dt>
|
|
110
|
+
<dd>Output the current cached value</dd>
|
|
117
111
|
</dl>
|
|
118
112
|
|
|
119
113
|
<h3>Outputs</h3>
|
|
@@ -125,12 +119,4 @@
|
|
|
125
119
|
<dt>timestamp <span class="property-type">number</span></dt>
|
|
126
120
|
<dd>Unix timestamp when the value was cached</dd>
|
|
127
121
|
</dl>
|
|
128
|
-
|
|
129
|
-
<h3>Wildcard Examples</h3>
|
|
130
|
-
<ul>
|
|
131
|
-
<li><code>sensor?</code> - matches sensor1, sensorA (exactly one char)</li>
|
|
132
|
-
<li><code>sensors/*</code> - matches sensors/temp, sensors/room1 (one or more chars after /)</li>
|
|
133
|
-
<li><code>*/temp</code> - matches room1/temp, sensors/temp</li>
|
|
134
|
-
<li><code>*</code> - matches any topic with one or more characters</li>
|
|
135
|
-
</ul>
|
|
136
122
|
</script>
|
package/nodes/event-topic.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* event-topic - Subscription node for
|
|
2
|
+
* event-topic - Subscription node for exact topics
|
|
3
3
|
*
|
|
4
4
|
* Features:
|
|
5
|
-
* - Subscribes to cache
|
|
6
|
-
* - Outputs when
|
|
7
|
-
* - Multiple output formats: value only
|
|
8
|
-
* - Optional output of existing
|
|
9
|
-
* - Dynamic
|
|
5
|
+
* - Subscribes to cache for a specific topic
|
|
6
|
+
* - Outputs when the topic updates
|
|
7
|
+
* - Multiple output formats: value only or full entry
|
|
8
|
+
* - Optional output of existing value on start
|
|
9
|
+
* - Dynamic topic change via input message
|
|
10
10
|
*/
|
|
11
11
|
module.exports = function(RED) {
|
|
12
12
|
function EventTopicNode(config) {
|
|
@@ -14,7 +14,8 @@ module.exports = function(RED) {
|
|
|
14
14
|
const node = this;
|
|
15
15
|
|
|
16
16
|
node.cacheConfig = RED.nodes.getNode(config.cache);
|
|
17
|
-
|
|
17
|
+
// Support both old 'pattern' and new 'topic' config
|
|
18
|
+
node.topic = config.topic || config.pattern || '';
|
|
18
19
|
node.outputFormat = config.outputFormat || 'value';
|
|
19
20
|
node.outputOnStart = config.outputOnStart || false;
|
|
20
21
|
|
|
@@ -25,6 +26,11 @@ module.exports = function(RED) {
|
|
|
25
26
|
return;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
if (!node.topic) {
|
|
30
|
+
node.status({ fill: "yellow", shape: "ring", text: "no topic" });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
/**
|
|
29
35
|
* Build output message based on configured format
|
|
30
36
|
*/
|
|
@@ -45,21 +51,6 @@ module.exports = function(RED) {
|
|
|
45
51
|
metadata: entry.metadata
|
|
46
52
|
}
|
|
47
53
|
};
|
|
48
|
-
case 'all':
|
|
49
|
-
const all = node.cacheConfig.getMatching(node.pattern);
|
|
50
|
-
const values = {};
|
|
51
|
-
for (const [t, e] of all) {
|
|
52
|
-
values[t] = e.value;
|
|
53
|
-
}
|
|
54
|
-
return {
|
|
55
|
-
topic: topic,
|
|
56
|
-
payload: values,
|
|
57
|
-
trigger: {
|
|
58
|
-
topic: topic,
|
|
59
|
-
value: entry.value
|
|
60
|
-
},
|
|
61
|
-
timestamp: entry.ts
|
|
62
|
-
};
|
|
63
54
|
default:
|
|
64
55
|
return {
|
|
65
56
|
topic: topic,
|
|
@@ -69,10 +60,10 @@ module.exports = function(RED) {
|
|
|
69
60
|
}
|
|
70
61
|
|
|
71
62
|
/**
|
|
72
|
-
* Subscribe to the current
|
|
63
|
+
* Subscribe to the current topic
|
|
73
64
|
*/
|
|
74
65
|
function subscribe() {
|
|
75
|
-
subscriptionId = node.cacheConfig.subscribe(node.
|
|
66
|
+
subscriptionId = node.cacheConfig.subscribe(node.topic, (topic, entry) => {
|
|
76
67
|
const msg = buildOutputMessage(topic, entry);
|
|
77
68
|
node.send(msg);
|
|
78
69
|
|
|
@@ -84,43 +75,45 @@ module.exports = function(RED) {
|
|
|
84
75
|
|
|
85
76
|
// Initial subscription
|
|
86
77
|
subscribe();
|
|
87
|
-
node.
|
|
78
|
+
const displayTopic = node.topic.length > 20 ? node.topic.substring(0, 17) + '...' : node.topic;
|
|
79
|
+
node.status({ fill: "green", shape: "dot", text: displayTopic });
|
|
88
80
|
|
|
89
|
-
// Output existing
|
|
81
|
+
// Output existing value on start if configured
|
|
90
82
|
if (node.outputOnStart) {
|
|
91
83
|
setImmediate(() => {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
const msg = buildOutputMessage(topic, entry);
|
|
84
|
+
const entry = node.cacheConfig.getValue(node.topic);
|
|
85
|
+
if (entry) {
|
|
86
|
+
const msg = buildOutputMessage(node.topic, entry);
|
|
95
87
|
node.send(msg);
|
|
96
88
|
}
|
|
97
89
|
});
|
|
98
90
|
}
|
|
99
91
|
|
|
100
|
-
// Handle input messages for dynamic
|
|
92
|
+
// Handle input messages for dynamic topic change
|
|
101
93
|
node.on('input', function(msg, send, done) {
|
|
102
94
|
// For Node-RED 0.x compatibility
|
|
103
95
|
send = send || function() { node.send.apply(node, arguments); };
|
|
104
96
|
done = done || function(err) { if (err) node.error(err, msg); };
|
|
105
97
|
|
|
106
|
-
if (msg.
|
|
107
|
-
// Unsubscribe from old
|
|
98
|
+
if (msg.topic && typeof msg.topic === 'string' && msg.payload === 'subscribe') {
|
|
99
|
+
// Unsubscribe from old topic
|
|
108
100
|
if (subscriptionId && node.cacheConfig) {
|
|
109
101
|
node.cacheConfig.unsubscribe(subscriptionId);
|
|
110
102
|
}
|
|
111
103
|
|
|
112
|
-
// Update
|
|
113
|
-
node.
|
|
104
|
+
// Update topic and resubscribe
|
|
105
|
+
node.topic = msg.topic;
|
|
114
106
|
subscribe();
|
|
115
107
|
|
|
116
|
-
node.
|
|
108
|
+
const dt = node.topic.length > 20 ? node.topic.substring(0, 17) + '...' : node.topic;
|
|
109
|
+
node.status({ fill: "blue", shape: "dot", text: dt });
|
|
117
110
|
}
|
|
118
111
|
|
|
119
|
-
// Allow manual trigger to output
|
|
120
|
-
if (msg.
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
const outMsg = buildOutputMessage(topic, entry);
|
|
112
|
+
// Allow manual trigger to output current value
|
|
113
|
+
if (msg.payload === 'refresh') {
|
|
114
|
+
const entry = node.cacheConfig.getValue(node.topic);
|
|
115
|
+
if (entry) {
|
|
116
|
+
const outMsg = buildOutputMessage(node.topic, entry);
|
|
124
117
|
send(outMsg);
|
|
125
118
|
}
|
|
126
119
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-event-calc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Node-RED nodes for event caching and calculations with topic wildcard patterns",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Holger Amort"
|
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
"event-cache": "nodes/event-cache.js",
|
|
30
30
|
"event-in": "nodes/event-in.js",
|
|
31
31
|
"event-topic": "nodes/event-topic.js",
|
|
32
|
-
"event-calc": "nodes/event-calc.js"
|
|
32
|
+
"event-calc": "nodes/event-calc.js",
|
|
33
|
+
"event-simulator": "nodes/event-simulator.js",
|
|
34
|
+
"event-chart": "nodes/event-chart.js"
|
|
33
35
|
}
|
|
34
36
|
},
|
|
35
37
|
"engines": {
|