node-red-contrib-event-calc 0.1.2 → 2.0.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,106 @@
1
+ module.exports = function(RED) {
2
+ function EventChartNode(config) {
3
+ RED.nodes.createNode(this, config);
4
+ const node = this;
5
+
6
+ node.title = config.title || 'Event Chart';
7
+ node.maxPoints = parseInt(config.maxPoints) || 200;
8
+ node.timestampField = config.timestampField || 'timestamp';
9
+ node.valueField = config.valueField || 'payload';
10
+ node.seriesField = config.seriesField || 'topic';
11
+
12
+ // Store data per series
13
+ node.chartData = {};
14
+
15
+ node.clearChart = function() {
16
+ node.chartData = {};
17
+ node.status({ fill: "grey", shape: "ring", text: "cleared" });
18
+ emitData();
19
+ };
20
+
21
+ node.on('input', function(msg) {
22
+ // Handle clear command
23
+ if (msg.payload === '_clear' || msg.topic === '_clear') {
24
+ node.clearChart();
25
+ return;
26
+ }
27
+
28
+ const series = RED.util.getMessageProperty(msg, node.seriesField) || 'default';
29
+ let timestamp = RED.util.getMessageProperty(msg, node.timestampField);
30
+ let value = RED.util.getMessageProperty(msg, node.valueField);
31
+
32
+ // Handle timestamp
33
+ if (timestamp === undefined || timestamp === null) {
34
+ timestamp = Date.now();
35
+ } else if (typeof timestamp !== 'number') {
36
+ timestamp = new Date(timestamp).getTime();
37
+ }
38
+
39
+ // Handle value
40
+ if (value === undefined || value === null) {
41
+ return;
42
+ }
43
+
44
+ if (typeof value !== 'number') {
45
+ value = parseFloat(value);
46
+ if (isNaN(value)) return;
47
+ }
48
+
49
+ if (!node.chartData[series]) {
50
+ node.chartData[series] = [];
51
+ }
52
+
53
+ node.chartData[series].push({
54
+ x: timestamp,
55
+ y: value
56
+ });
57
+
58
+ // Limit points per series
59
+ if (node.chartData[series].length > node.maxPoints) {
60
+ node.chartData[series].shift();
61
+ }
62
+
63
+ const totalPoints = Object.values(node.chartData).reduce((sum, arr) => sum + arr.length, 0);
64
+ const seriesCount = Object.keys(node.chartData).length;
65
+ node.status({ fill: "green", shape: "dot", text: `${seriesCount} series, ${totalPoints} pts` });
66
+
67
+ emitData();
68
+ });
69
+
70
+ function emitData() {
71
+ RED.comms.publish("event-chart-data-" + node.id, {
72
+ id: node.id,
73
+ title: node.title,
74
+ data: node.chartData
75
+ });
76
+ }
77
+
78
+ node.on('close', function() {
79
+ node.chartData = {};
80
+ node.status({});
81
+ });
82
+ }
83
+
84
+ RED.nodes.registerType("event-chart", EventChartNode);
85
+
86
+ // Clear chart data endpoint
87
+ RED.httpAdmin.post("/event-chart/:id/clear", function(req, res) {
88
+ const node = RED.nodes.getNode(req.params.id);
89
+ if (node && node.clearChart) {
90
+ node.clearChart();
91
+ res.sendStatus(200);
92
+ } else {
93
+ res.status(404).send("Node not found");
94
+ }
95
+ });
96
+
97
+ // Get chart data endpoint
98
+ RED.httpAdmin.get("/event-chart/:id/data", function(req, res) {
99
+ const node = RED.nodes.getNode(req.params.id);
100
+ if (node) {
101
+ res.json({ title: node.title, data: node.chartData || {} });
102
+ } else {
103
+ res.status(404).send("Node not found");
104
+ }
105
+ });
106
+ };
@@ -0,0 +1,58 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('event-json', {
3
+ category: 'event calc',
4
+ color: '#758467',
5
+ defaults: {
6
+ name: { value: "" }
7
+ },
8
+ inputs: 1,
9
+ outputs: 1,
10
+ icon: "font-awesome/fa-exchange",
11
+ label: function() {
12
+ return this.name || "event json";
13
+ },
14
+ paletteLabel: "event json"
15
+ });
16
+ </script>
17
+
18
+ <script type="text/html" data-template-name="event-json">
19
+ <div class="form-row">
20
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
21
+ <input type="text" id="node-input-name" placeholder="Name">
22
+ </div>
23
+ </script>
24
+
25
+ <script type="text/html" data-help-name="event-json">
26
+ <p>Bidirectional JSON envelope converter. Automatically detects direction.</p>
27
+
28
+ <h3>Behavior</h3>
29
+ <dl class="message-properties">
30
+ <dt>Unwrap (JSON to Message)</dt>
31
+ <dd>If payload is <code>{value, topic?, timestamp?}</code>, extracts value to <code>msg.payload</code> and copies topic/timestamp to msg.</dd>
32
+ <dt>Wrap (Message to JSON)</dt>
33
+ <dd>If payload is any other value, wraps it as <code>{timestamp, topic, value}</code>.</dd>
34
+ </dl>
35
+
36
+ <h3>Examples</h3>
37
+ <h4>Wrap (before MQTT publish)</h4>
38
+ <pre>
39
+ Input: msg.topic = "sensor/temp", msg.payload = 25.5
40
+ Output: msg.payload = {
41
+ timestamp: 1704900000000,
42
+ topic: "sensor/temp",
43
+ value: 25.5
44
+ }</pre>
45
+
46
+ <h4>Unwrap (after MQTT subscribe)</h4>
47
+ <pre>
48
+ Input: msg.payload = '{"timestamp":1704900000000,"topic":"sensor/temp","value":25.5}'
49
+ Output: msg.topic = "sensor/temp"
50
+ msg.payload = 25.5
51
+ msg.timestamp = 1704900000000</pre>
52
+
53
+ <h3>Status</h3>
54
+ <ul>
55
+ <li><b>Green "wrapped"</b> - Created JSON envelope</li>
56
+ <li><b>Blue "unwrapped"</b> - Extracted from JSON envelope</li>
57
+ </ul>
58
+ </script>
@@ -0,0 +1,69 @@
1
+ /**
2
+ * event-json - Bidirectional JSON envelope converter
3
+ *
4
+ * Automatically detects direction:
5
+ * - Object with {value, topic?, timestamp?} -> extracts to msg
6
+ * - Any other payload -> wraps in {timestamp, topic, value}
7
+ */
8
+ module.exports = function(RED) {
9
+ function EventJsonNode(config) {
10
+ RED.nodes.createNode(this, config);
11
+ const node = this;
12
+
13
+ node.on('input', function(msg, send, done) {
14
+ send = send || function() { node.send.apply(node, arguments); };
15
+ done = done || function(err) { if (err) node.error(err, msg); };
16
+
17
+ try {
18
+ let data = msg.payload;
19
+
20
+ // If string, try to parse as JSON
21
+ if (typeof data === 'string') {
22
+ try {
23
+ data = JSON.parse(data);
24
+ } catch (e) {
25
+ // Not JSON string - wrap it
26
+ msg.payload = {
27
+ timestamp: Date.now(),
28
+ topic: msg.topic,
29
+ value: msg.payload
30
+ };
31
+ node.status({ fill: "green", shape: "dot", text: "wrapped" });
32
+ send(msg);
33
+ done();
34
+ return;
35
+ }
36
+ }
37
+
38
+ // Check if it's an envelope object (has 'value' property)
39
+ if (typeof data === 'object' && data !== null && 'value' in data) {
40
+ // Unwrap: extract from envelope
41
+ if (data.topic) {
42
+ msg.topic = data.topic;
43
+ }
44
+ if (data.timestamp) {
45
+ msg.timestamp = data.timestamp;
46
+ }
47
+ msg.payload = data.value;
48
+ node.status({ fill: "blue", shape: "dot", text: "unwrapped" });
49
+ } else {
50
+ // Wrap: create envelope
51
+ msg.payload = {
52
+ timestamp: Date.now(),
53
+ topic: msg.topic,
54
+ value: data
55
+ };
56
+ node.status({ fill: "green", shape: "dot", text: "wrapped" });
57
+ }
58
+
59
+ send(msg);
60
+ done();
61
+ } catch (err) {
62
+ node.status({ fill: "red", shape: "ring", text: "error" });
63
+ done(err);
64
+ }
65
+ });
66
+ }
67
+
68
+ RED.nodes.registerType("event-json", EventJsonNode);
69
+ };
@@ -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
+ };