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.
- package/LICENSE +25 -0
- package/README.md +50 -51
- package/nodes/event-cache.js +84 -139
- package/nodes/event-calc.html +46 -22
- package/nodes/event-calc.js +82 -26
- package/nodes/event-chart.html +239 -0
- package/nodes/event-chart.js +106 -0
- package/nodes/event-json.html +58 -0
- package/nodes/event-json.js +69 -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 +24 -10
- package/playwright.config.js +22 -0
- package/tests/external-trigger.spec.js +141 -0
|
@@ -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
|
+
};
|