@bldgblocks/node-red-contrib-control 0.1.27 → 0.1.28

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,110 @@
1
+ <script type="text/html" data-template-name="rate-of-change-block">
2
+ <div class="form-row">
3
+ <label for="node-input-name" title="Display name shown on the canvas"><i class="fa fa-tag"></i> Name</label>
4
+ <input type="text" id="node-input-name" placeholder="Name">
5
+ </div>
6
+ <div class="form-row">
7
+ <label for="node-input-sampleSize" title="Number of samples to track (minimum 2)"><i class="fa fa-list-ol"></i> Sample Size</label>
8
+ <input type="number" id="node-input-sampleSize" placeholder="10" min="2" step="1">
9
+ </div>
10
+ <div class="form-row">
11
+ <label for="node-input-units" title="Time units for rate calculation"><i class="fa fa-clock-o"></i> Rate Units</label>
12
+ <select id="node-input-units">
13
+ <option value="seconds">Seconds</option>
14
+ <option value="minutes" selected>Minutes</option>
15
+ <option value="hours">Hours</option>
16
+ </select>
17
+ </div>
18
+ <div class="form-row">
19
+ <label for="node-input-minValid"><i class="fa fa-arrow-down"></i> Minimum Valid Temp</label>
20
+ <input type="text" id="node-input-minValid" placeholder="-40">
21
+ <input type="hidden" id="node-input-minValidType">
22
+ </div>
23
+ <div class="form-row">
24
+ <label for="node-input-maxValid"><i class="fa fa-arrow-up"></i> Maximum Valid Temp</label>
25
+ <input type="text" id="node-input-maxValid" placeholder="150">
26
+ <input type="hidden" id="node-input-maxValidType">
27
+ </div>
28
+ </script>
29
+
30
+ <script type="text/javascript">
31
+ RED.nodes.registerType("rate-of-change-block", {
32
+ category: "control",
33
+ color: "#301934",
34
+ defaults: {
35
+ name: { value: "" },
36
+ sampleSize: {
37
+ value: 10,
38
+ required: true,
39
+ validate: function(v) { return !isNaN(parseInt(v)) && parseInt(v) >= 2; }
40
+ },
41
+ units: { value: "minutes" },
42
+ minValid: { value: -40, required: true },
43
+ minValidType: { value: "num" },
44
+ maxValid: { value: 150, required: true },
45
+ maxValidType: { value: "num" }
46
+ },
47
+ inputs: 1,
48
+ outputs: 1,
49
+ inputLabels: ["input"],
50
+ outputLabels: ["rate"],
51
+ icon: "font-awesome/fa-bar-chart",
52
+ paletteLabel: "rate of change",
53
+ label: function() {
54
+ return this.name ? `${this.name} (${this.sampleSize} samples)` : `RoC (${this.sampleSize})`;
55
+ },
56
+ oneditprepare: function() {
57
+ const node = this;
58
+
59
+ try {
60
+ // Initialize typed inputs
61
+ $("#node-input-minValid").typedInput({
62
+ default: "num",
63
+ types: ["num", "msg", "flow", "global"],
64
+ typeField: "#node-input-minValidType"
65
+ }).typedInput("type", node.minValidType || "num").typedInput("value", node.minValid || "-40");
66
+
67
+ $("#node-input-maxValid").typedInput({
68
+ default: "num",
69
+ types: ["num", "msg", "flow", "global"],
70
+ typeField: "#node-input-maxValidType"
71
+ }).typedInput("type", node.maxValidType || "num").typedInput("value", node.maxValid || "150");
72
+
73
+ // Set units dropdown
74
+ $("#node-input-units").val(node.units || "minutes");
75
+ } catch (err) {
76
+ console.error("Error in oneditprepare:", err);
77
+ }
78
+ }
79
+ });
80
+ </script>
81
+
82
+ <script type="text/markdown" data-help-name="rate-of-change-block">
83
+ Calculates the rate of temperature change over time for HVAC applications.
84
+
85
+ ### Inputs
86
+ : context (string) : Configures reset (`"reset"`), sample size (`"sampleSize"`), or units (`"units"`).
87
+ : payload (number) : Temperature value for rate calculation.
88
+ : timestamp (optional) : Custom timestamp for the reading.
89
+
90
+ ### Outputs
91
+ : payload (number | null) : Rate of change in temperature per time unit.
92
+ : samples (number) : Current number of samples in buffer.
93
+ : units (string) : Rate units ("°/s", "°/min", "°/hr").
94
+ : currentValue (number) : Most recent temperature value.
95
+ : timeSpan (number) : Time span of sample buffer in seconds.
96
+
97
+ ### Details
98
+ Tracks temperature changes over a rolling window of samples. Calculates rate as (last_value - first_value) / time_difference.
99
+
100
+ Useful for detecting HVAC issues like temperature droop, defrost cycles, or rapid changes.
101
+
102
+ ### Configuration
103
+ - Reset via `msg.context = "reset"` with `msg.payload = true`
104
+ - Change sample size via `msg.context = "sampleSize"` with numeric payload
105
+ - Change units via `msg.context = "units"` with "seconds", "minutes", or "hours"
106
+
107
+ ### Status
108
+ - Shows current rate with units
109
+ - Color indicates state change
110
+ </script>
@@ -0,0 +1,233 @@
1
+ module.exports = function(RED) {
2
+ const utils = require('./utils')(RED);
3
+
4
+ function RateOfChangeNode(config) {
5
+ RED.nodes.createNode(this, config);
6
+ const node = this;
7
+
8
+ // Initialize runtime state
9
+ node.runtime = {
10
+ maxSamples: parseInt(config.sampleSize),
11
+ samples: [], // Array of {timestamp: Date, value: number}
12
+ units: config.units || "minutes", // minutes, seconds, hours
13
+ lastRate: null
14
+ };
15
+
16
+ // Evaluate typed-input properties
17
+ try {
18
+ node.runtime.minValid = parseFloat(RED.util.evaluateNodeProperty( config.minValid, config.minValidType, node ));
19
+ node.runtime.maxValid = parseFloat(RED.util.evaluateNodeProperty( config.maxValid, config.maxValidType, node ));
20
+ } catch (err) {
21
+ node.error(`Error evaluating properties: ${err.message}`);
22
+ }
23
+
24
+ node.on("input", function(msg, send, done) {
25
+ send = send || function() { node.send.apply(node, arguments); };
26
+
27
+ // Guard against invalid msg
28
+ if (!msg) {
29
+ node.status({ fill: "red", shape: "ring", text: "invalid message" });
30
+ if (done) done();
31
+ return;
32
+ }
33
+
34
+ // Update typed-input properties if needed
35
+ try {
36
+ if (utils.requiresEvaluation(config.minValidType)) {
37
+ node.runtime.minValid = parseFloat(RED.util.evaluateNodeProperty( config.minValid, config.minValidType, node, msg ));
38
+ }
39
+ if (utils.requiresEvaluation(config.maxValidType)) {
40
+ node.runtime.maxValid = parseFloat(RED.util.evaluateNodeProperty( config.maxValid, config.maxValidType, node, msg ));
41
+ }
42
+ } catch (err) {
43
+ node.error(`Error evaluating properties: ${err.message}`);
44
+ if (done) done();
45
+ return;
46
+ }
47
+
48
+ // Acceptable fallbacks
49
+ if (isNaN(node.runtime.maxSamples) || node.runtime.maxSamples < 2) {
50
+ node.runtime.maxSamples = 10;
51
+ node.status({ fill: "red", shape: "ring", text: "invalid sample size, using 10" });
52
+ }
53
+
54
+ // Validate values
55
+ if (isNaN(node.runtime.maxValid) || isNaN(node.runtime.minValid) || node.runtime.maxValid <= node.runtime.minValid ) {
56
+ node.status({ fill: "red", shape: "ring", text: `invalid evaluated values ${node.runtime.minValid}, ${node.runtime.maxValid}` });
57
+ if (done) done();
58
+ return;
59
+ }
60
+
61
+ // Handle configuration messages
62
+ if (msg.hasOwnProperty("context")) {
63
+ if (!msg.hasOwnProperty("payload")) {
64
+ node.status({ fill: "red", shape: "ring", text: "missing payload" });
65
+ if (done) done();
66
+ return;
67
+ }
68
+
69
+ switch (msg.context) {
70
+ case "reset":
71
+ if (typeof msg.payload !== "boolean") {
72
+ node.status({ fill: "red", shape: "ring", text: "invalid reset" });
73
+ if (done) done();
74
+ return;
75
+ }
76
+ if (msg.payload === true) {
77
+ node.runtime.samples = [];
78
+ node.runtime.lastRate = null;
79
+ node.status({ fill: "green", shape: "dot", text: "state reset" });
80
+ }
81
+ break;
82
+
83
+ case "sampleSize":
84
+ let newMaxSamples = parseInt(msg.payload);
85
+ if (isNaN(newMaxSamples) || newMaxSamples < 2) {
86
+ node.status({ fill: "red", shape: "ring", text: "sample size must be at least 2" });
87
+ if (done) done();
88
+ return;
89
+ }
90
+ node.runtime.maxSamples = newMaxSamples;
91
+ // Trim samples if new window is smaller
92
+ if (node.runtime.samples.length > newMaxSamples) {
93
+ node.runtime.samples = node.runtime.samples.slice(-newMaxSamples);
94
+ }
95
+ node.status({ fill: "green", shape: "dot", text: `samples: ${newMaxSamples}` });
96
+ break;
97
+
98
+ case "units":
99
+ const validUnits = ["seconds", "minutes", "hours"];
100
+ if (typeof msg.payload === "string" && validUnits.includes(msg.payload.toLowerCase())) {
101
+ node.runtime.units = msg.payload.toLowerCase();
102
+ node.status({ fill: "green", shape: "dot", text: `units: ${msg.payload}` });
103
+ } else {
104
+ node.status({ fill: "red", shape: "ring", text: "invalid units" });
105
+ }
106
+ break;
107
+
108
+ default:
109
+ node.status({ fill: "yellow", shape: "ring", text: "unknown context" });
110
+ break;
111
+ }
112
+ if (done) done();
113
+ return;
114
+ }
115
+
116
+ // Check for missing payload
117
+ if (!msg.hasOwnProperty("payload")) {
118
+ node.status({ fill: "red", shape: "ring", text: "missing payload" });
119
+ if (done) done();
120
+ return;
121
+ }
122
+
123
+ // Process input
124
+ const inputValue = parseFloat(msg.payload);
125
+ const timestamp = msg.timestamp ? new Date(msg.timestamp) : new Date();
126
+
127
+ if (isNaN(inputValue) || inputValue < node.runtime.minValid || inputValue > node.runtime.maxValid) {
128
+ node.status({ fill: "yellow", shape: "ring", text: "out of range" });
129
+ if (done) done();
130
+ return;
131
+ }
132
+
133
+ // Add new sample
134
+ node.runtime.samples.push({ timestamp: timestamp, value: inputValue });
135
+
136
+ // Maintain sample window
137
+ if (node.runtime.samples.length > node.runtime.maxSamples + 1) {
138
+ node.runtime.samples = node.runtime.samples.slice(-node.runtime.maxSamples);
139
+ } else if (node.runtime.samples.length > node.runtime.maxSamples) {
140
+ node.runtime.samples.shift();
141
+ }
142
+
143
+ // Calculate rate of change (temperature per time unit)
144
+ let rate = null;
145
+ if (node.runtime.samples.length >= node.runtime.maxSamples) { // Need at least 3 points for good regression
146
+ const n = node.runtime.samples.length;
147
+ let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
148
+
149
+ // Convert timestamps to relative time in the selected units
150
+ const baseTime = node.runtime.samples[0].timestamp;
151
+ let timeScale; // Conversion factor from ms to selected units
152
+
153
+ switch (node.runtime.units) {
154
+ case "seconds":
155
+ timeScale = 1000; // ms to seconds
156
+ break;
157
+ case "minutes":
158
+ timeScale = 1000 * 60; // ms to minutes
159
+ break;
160
+ case "hours":
161
+ timeScale = 1000 * 60 * 60; // ms to hours
162
+ break;
163
+ default:
164
+ timeScale = 1000 * 60; // default to minutes
165
+ }
166
+
167
+ // Calculate regression sums
168
+ node.runtime.samples.forEach((sample, i) => {
169
+ const x = (sample.timestamp - baseTime) / timeScale; // time in selected units
170
+ const y = sample.value;
171
+
172
+ sumX += x;
173
+ sumY += y;
174
+ sumXY += x * y;
175
+ sumXX += x * x;
176
+ });
177
+
178
+ // Calculate slope (rate of change) using linear regression formula
179
+ const denominator = n * sumXX - sumX * sumX;
180
+
181
+ // Avoid division by zero - use original endpoint method if regression fails
182
+ if (Math.abs(denominator) > 1e-10) { // Small tolerance for floating point
183
+ rate = (n * sumXY - sumX * sumY) / denominator;
184
+ } else {
185
+ // Fallback to original endpoint method if regression is unstable
186
+ const firstSample = node.runtime.samples[0];
187
+ const lastSample = node.runtime.samples[node.runtime.samples.length - 1];
188
+ const timeDiff = (lastSample.timestamp - firstSample.timestamp) / timeScale;
189
+ rate = timeDiff > 0 ? (lastSample.value - firstSample.value) / timeDiff : 0;
190
+ }
191
+ }
192
+
193
+ const isUnchanged = rate === node.runtime.lastRate;
194
+
195
+ // Send new message
196
+ const unitsDisplay = {
197
+ seconds: "/sec",
198
+ minutes: "/min",
199
+ hours: "/hr"
200
+ };
201
+
202
+ node.status({
203
+ fill: "blue",
204
+ shape: isUnchanged ? "ring" : "dot",
205
+ text: `rate: ${rate !== null ? rate.toFixed(2) : "not ready"} ${unitsDisplay[node.runtime.units] || "/min"}`
206
+ });
207
+
208
+ node.runtime.lastRate = rate;
209
+
210
+ // Enhanced output with metadata
211
+ const outputMsg = {
212
+ payload: rate,
213
+ samples: node.runtime.samples.length,
214
+ units: `${unitsDisplay[node.runtime.units] || "/min"}`,
215
+ currentValue: inputValue,
216
+ timeSpan: node.runtime.samples.length >= 2 ?
217
+ (node.runtime.samples[node.runtime.samples.length - 1].timestamp - node.runtime.samples[0].timestamp) / 1000 : 0
218
+ };
219
+
220
+ if (node.runtime.samples.length >= node.runtime.maxSamples) {
221
+ send(outputMsg);
222
+ }
223
+
224
+ if (done) done();
225
+ });
226
+
227
+ node.on("close", function(done) {
228
+ done();
229
+ });
230
+ }
231
+
232
+ RED.nodes.registerType("rate-of-change-block", RateOfChangeNode);
233
+ };
@@ -0,0 +1,112 @@
1
+ <!-- UI Template Section: Defines the edit dialog -->
2
+ <script type="text/html" data-template-name="string-builder-block">
3
+ <div class="form-row">
4
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
5
+ <input type="text" id="node-input-name" placeholder="Name">
6
+ </div>
7
+ <div class="form-row">
8
+ <label for="node-input-in1"><i class="fa fa-bars"></i> in1</label>
9
+ <input type="text" id="node-input-in1" placeholder="">
10
+ <input type="hidden" id="node-input-in1Type">
11
+ </div>
12
+ <div class="form-row">
13
+ <label for="node-input-in2"><i class="fa fa-bars"></i> in2</label>
14
+ <input type="text" id="node-input-in2" placeholder="">
15
+ <input type="hidden" id="node-input-in2Type">
16
+ </div>
17
+ <div class="form-row">
18
+ <label for="node-input-in3"><i class="fa fa-bars"></i> in3</label>
19
+ <input type="text" id="node-input-in3" placeholder="">
20
+ <input type="hidden" id="node-input-in3Type">
21
+ </div>
22
+ <div class="form-row">
23
+ <label for="node-input-in4"><i class="fa fa-bars"></i> in4</label>
24
+ <input type="text" id="node-input-in4" placeholder="">
25
+ <input type="hidden" id="node-input-in4Type">
26
+ </div>
27
+ </script>
28
+
29
+
30
+ <!-- JavaScript Section: Registers the node and handles editor logic -->
31
+ <script type="text/javascript">
32
+ RED.nodes.registerType("string-builder-block", {
33
+ category: "control",
34
+ color: "#301934",
35
+ defaults: {
36
+ name: { value: "" },
37
+ in1: { value: "", required: false },
38
+ in1Type: { value: "str" },
39
+ in2: { value: "", required: false },
40
+ in2Type: { value: "str" },
41
+ in3: { value: "", required: false },
42
+ in3Type: { value: "str" },
43
+ in4: { value: "", required: false },
44
+ in4Type: { value: "str" },
45
+ },
46
+ inputs: 1,
47
+ outputs: 1,
48
+ inputLabels: ["input"],
49
+ outputLabels: ["output"],
50
+ icon: "font-awesome/fa-bars",
51
+ paletteLabel: "string builder",
52
+ label: function() {
53
+ return this.name || "string builder";
54
+ },
55
+ oneditprepare: function() {
56
+ const node = this;
57
+
58
+ try {
59
+ // Initialize typed inputs
60
+ $("#node-input-in1").typedInput({
61
+ default: "str",
62
+ types: ["str", "msg", "flow", "global"],
63
+ typeField: "#node-input-in1Type"
64
+ }).typedInput("type", node.in1Type || "str").typedInput("value", node.in1);
65
+
66
+ $("#node-input-in2").typedInput({
67
+ default: "str",
68
+ types: ["str", "msg", "flow", "global"],
69
+ typeField: "#node-input-in2Type"
70
+ }).typedInput("type", node.in2Type || "str").typedInput("value", node.in2);
71
+
72
+ $("#node-input-in3").typedInput({
73
+ default: "str",
74
+ types: ["str", "msg", "flow", "global"],
75
+ typeField: "#node-input-in3Type"
76
+ }).typedInput("type", node.in3Type || "str").typedInput("value", node.in3);
77
+
78
+ $("#node-input-in4").typedInput({
79
+ default: "str",
80
+ types: ["str", "msg", "flow", "global"],
81
+ typeField: "#node-input-in4Type"
82
+ }).typedInput("type", node.in4Type || "str").typedInput("value", node.in4);
83
+
84
+ } catch (err) {
85
+ console.error("Error in oneditprepare:", err);
86
+ }
87
+ }
88
+ });
89
+ </script>
90
+
91
+ <!-- Help Section -->
92
+ <script type="text/markdown" data-help-name="string-builder-block">
93
+ Concatenates multiple string inputs into a single output string on any input.
94
+
95
+ ### Inputs
96
+ : payload (number) : Input value to evaluate
97
+ : context (string) : Configure `inX` string inputs via context variables
98
+
99
+ ### Outputs
100
+ : output (string) : Each string input concatenated in order
101
+
102
+ ### Status
103
+ - Green (dot): Configuration update
104
+ - Blue (dot): State changed
105
+ - Blue (ring): State unchanged
106
+ - Red (ring): Error
107
+ - Yellow (ring): Warning
108
+
109
+ ### References
110
+ - [Node-RED Documentation](https://nodered.org/docs/)
111
+ - [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
112
+ </script>
@@ -0,0 +1,89 @@
1
+ module.exports = function(RED) {
2
+ const utils = require('./utils')(RED);
3
+
4
+ function StringBuilderBlockNode(config) {
5
+ RED.nodes.createNode(this, config);
6
+ const node = this;
7
+ node.name = config.name;
8
+
9
+ // Evaluate typed-input properties
10
+ try {
11
+ node.in1 = RED.util.evaluateNodeProperty( config.in1, config.in1Type, node );
12
+ node.in2 = RED.util.evaluateNodeProperty( config.in2, config.in2Type, node );
13
+ node.in3 = RED.util.evaluateNodeProperty( config.in3, config.in3Type, node );
14
+ node.in4 = RED.util.evaluateNodeProperty( config.in4, config.in4Type, node );
15
+ } catch (err) {
16
+ node.error(`Error evaluating properties: ${err.message}`);
17
+ }
18
+
19
+ node.on("input", function(msg, send, done) {
20
+ send = send || function() { node.send.apply(node, arguments); };
21
+
22
+ if (!msg) {
23
+ node.status({ fill: "red", shape: "ring", text: "invalid message" });
24
+ if (done) done();
25
+ return;
26
+ }
27
+
28
+ // Update typed-input properties if needed
29
+ try {
30
+ if (utils.requiresEvaluation(config.in1Type)) {
31
+ node.in1 = RED.util.evaluateNodeProperty( config.in1, config.in1Type, node, msg );
32
+ }
33
+ if (utils.requiresEvaluation(config.in2Type)) {
34
+ node.in2 = RED.util.evaluateNodeProperty( config.in2, config.in2Type, node, msg );
35
+ }
36
+ if (utils.requiresEvaluation(config.in3Type)) {
37
+ node.in3 = RED.util.evaluateNodeProperty( config.in3, config.in3Type, node, msg );
38
+ }
39
+ if (utils.requiresEvaluation(config.in4Type)) {
40
+ node.in4 = RED.util.evaluateNodeProperty( config.in4, config.in4Type, node, msg );
41
+ }
42
+ } catch (err) {
43
+ node.error(`Error evaluating properties: ${err.message}`);
44
+ if (done) done();
45
+ return;
46
+ }
47
+
48
+ // Check required properties
49
+ if (msg.hasOwnProperty("context")) {
50
+
51
+ if (!msg.hasOwnProperty("payload")) {
52
+ node.status({ fill: "red", shape: "ring", text: "missing payload" });
53
+ if (done) done();
54
+ return;
55
+ }
56
+
57
+ // Process input slot
58
+ if (msg.context.startsWith("in")) {
59
+ let index = parseInt(msg.context.slice(2), 10);
60
+ if (!isNaN(index) && index >= 1 && index <= 4) {
61
+ if (config[`in${index}Type`] === "str") {
62
+ node[`in${index}`] = msg.payload;
63
+ } else {
64
+ node.status({ fill: "red", shape: "ring", text: `Field type is ${config[`in${index}Type`]}` });
65
+ if (done) done();
66
+ return;
67
+ }
68
+ } else {
69
+ node.status({ fill: "red", shape: "ring", text: `invalid input index ${index || "NaN"}` });
70
+ if (done) done();
71
+ return;
72
+ }
73
+ }
74
+ }
75
+
76
+ const output = { payload: `${node.in1}${node.in2}${node.in3}${node.in4}` };
77
+ node.status({ fill: "blue", shape: "dot", text: `${ output.payload }` });
78
+ send(output);
79
+
80
+ if (done) done();
81
+ });
82
+
83
+ node.on("close", function(done) {
84
+ done();
85
+ });
86
+ }
87
+
88
+ RED.nodes.registerType("string-builder-block", StringBuilderBlockNode);
89
+ };