@bldgblocks/node-red-contrib-control 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/README.md +43 -0
- package/nodes/accumulate-block.html +71 -0
- package/nodes/accumulate-block.js +104 -0
- package/nodes/add-block.html +67 -0
- package/nodes/add-block.js +97 -0
- package/nodes/analog-switch-block.html +65 -0
- package/nodes/analog-switch-block.js +129 -0
- package/nodes/and-block.html +64 -0
- package/nodes/and-block.js +73 -0
- package/nodes/average-block.html +97 -0
- package/nodes/average-block.js +137 -0
- package/nodes/boolean-switch-block.html +59 -0
- package/nodes/boolean-switch-block.js +88 -0
- package/nodes/boolean-to-number-block.html +59 -0
- package/nodes/boolean-to-number-block.js +45 -0
- package/nodes/cache-block.html +69 -0
- package/nodes/cache-block.js +106 -0
- package/nodes/call-status-block.html +111 -0
- package/nodes/call-status-block.js +274 -0
- package/nodes/changeover-block.html +234 -0
- package/nodes/changeover-block.js +392 -0
- package/nodes/comment-block.html +83 -0
- package/nodes/comment-block.js +53 -0
- package/nodes/compare-block.html +64 -0
- package/nodes/compare-block.js +84 -0
- package/nodes/contextual-label-block.html +67 -0
- package/nodes/contextual-label-block.js +52 -0
- package/nodes/convert-block.html +179 -0
- package/nodes/convert-block.js +289 -0
- package/nodes/count-block.html +57 -0
- package/nodes/count-block.js +92 -0
- package/nodes/debounce-block.html +64 -0
- package/nodes/debounce-block.js +140 -0
- package/nodes/delay-block.html +104 -0
- package/nodes/delay-block.js +180 -0
- package/nodes/divide-block.html +65 -0
- package/nodes/divide-block.js +123 -0
- package/nodes/edge-block.html +71 -0
- package/nodes/edge-block.js +120 -0
- package/nodes/frequency-block.html +55 -0
- package/nodes/frequency-block.js +140 -0
- package/nodes/hysteresis-block.html +131 -0
- package/nodes/hysteresis-block.js +142 -0
- package/nodes/interpolate-block.html +74 -0
- package/nodes/interpolate-block.js +141 -0
- package/nodes/load-sequence-block.html +134 -0
- package/nodes/load-sequence-block.js +272 -0
- package/nodes/max-block.html +76 -0
- package/nodes/max-block.js +103 -0
- package/nodes/memory-block.html +90 -0
- package/nodes/memory-block.js +241 -0
- package/nodes/min-block.html +77 -0
- package/nodes/min-block.js +106 -0
- package/nodes/minmax-block.html +89 -0
- package/nodes/minmax-block.js +119 -0
- package/nodes/modulo-block.html +73 -0
- package/nodes/modulo-block.js +126 -0
- package/nodes/multiply-block.html +63 -0
- package/nodes/multiply-block.js +115 -0
- package/nodes/negate-block.html +55 -0
- package/nodes/negate-block.js +91 -0
- package/nodes/nullify-block.html +111 -0
- package/nodes/nullify-block.js +78 -0
- package/nodes/on-change-block.html +79 -0
- package/nodes/on-change-block.js +191 -0
- package/nodes/oneshot-block.html +96 -0
- package/nodes/oneshot-block.js +169 -0
- package/nodes/or-block.html +64 -0
- package/nodes/or-block.js +73 -0
- package/nodes/pid-block.html +205 -0
- package/nodes/pid-block.js +407 -0
- package/nodes/priority-block.html +66 -0
- package/nodes/priority-block.js +239 -0
- package/nodes/rate-limit-block.html +99 -0
- package/nodes/rate-limit-block.js +221 -0
- package/nodes/round-block.html +73 -0
- package/nodes/round-block.js +89 -0
- package/nodes/saw-tooth-wave-block.html +87 -0
- package/nodes/saw-tooth-wave-block.js +161 -0
- package/nodes/scale-range-block.html +90 -0
- package/nodes/scale-range-block.js +137 -0
- package/nodes/sine-wave-block.html +88 -0
- package/nodes/sine-wave-block.js +142 -0
- package/nodes/subtract-block.html +64 -0
- package/nodes/subtract-block.js +103 -0
- package/nodes/thermistor-block.html +81 -0
- package/nodes/thermistor-block.js +146 -0
- package/nodes/tick-tock-block.html +66 -0
- package/nodes/tick-tock-block.js +110 -0
- package/nodes/time-sequence-block.html +67 -0
- package/nodes/time-sequence-block.js +144 -0
- package/nodes/triangle-wave-block.html +86 -0
- package/nodes/triangle-wave-block.js +154 -0
- package/nodes/tstat-block.html +311 -0
- package/nodes/tstat-block.js +499 -0
- package/nodes/units-block.html +150 -0
- package/nodes/units-block.js +106 -0
- package/package.json +73 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<!-- UI Template Section: Defines the edit dialog -->
|
|
2
|
+
<script type="text/html" data-template-name="frequency-block">
|
|
3
|
+
<div class="form-row">
|
|
4
|
+
<label for="node-input-name" title="Display name shown on the canvas"><i class="fa fa-tag"></i> Name</label>
|
|
5
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
6
|
+
</div>
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<!-- JavaScript Section: Registers the node and handles editor logic -->
|
|
10
|
+
<script type="text/javascript">
|
|
11
|
+
RED.nodes.registerType("frequency-block", {
|
|
12
|
+
category: "control",
|
|
13
|
+
color: "#301934",
|
|
14
|
+
defaults: {
|
|
15
|
+
name: { value: "" }
|
|
16
|
+
},
|
|
17
|
+
inputs: 1,
|
|
18
|
+
outputs: 1,
|
|
19
|
+
inputLabels: ["input"],
|
|
20
|
+
outputLabels: ["stats"],
|
|
21
|
+
icon: "font-awesome/fa-tachometer",
|
|
22
|
+
paletteLabel: "frequency",
|
|
23
|
+
label: function() {
|
|
24
|
+
return this.name || "frequency";
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<!-- Help Section -->
|
|
30
|
+
<script type="text/markdown" data-help-name="frequency-block">
|
|
31
|
+
Measures pulse frequency from boolean rising edges.
|
|
32
|
+
|
|
33
|
+
### Inputs
|
|
34
|
+
: context (string) : Resets state (`"reset"`). Unmatched values trigger error.
|
|
35
|
+
: payload (boolean) : Input boolean to detect rising edges (`true` for pulse).
|
|
36
|
+
|
|
37
|
+
### Outputs
|
|
38
|
+
: payload (object) : Pulse rates `{ ppm, pph, ppd }` (pulses per minute, hour, day).
|
|
39
|
+
|
|
40
|
+
### Details
|
|
41
|
+
Measures pulse frequency from rising edges in `msg.payload` (boolean, `true` for pulse), outputting a message with
|
|
42
|
+
`msg.payload = { ppm, pph, ppd }` (pulses per minute, hour, day) on the second and subsequent rising edges
|
|
43
|
+
(first edge sets baseline). Resets state via `msg.context = "reset"` with `msg.payload = true`.
|
|
44
|
+
|
|
45
|
+
### Status
|
|
46
|
+
- Green (dot): Configuration
|
|
47
|
+
- Blue (dot): Output, no alarm
|
|
48
|
+
- Red (dot): Output with alarm
|
|
49
|
+
- Red (ring): Errors
|
|
50
|
+
- Yellow (ring): Unknown context
|
|
51
|
+
|
|
52
|
+
### References
|
|
53
|
+
- [Node-RED Documentation](https://nodered.org/docs/)
|
|
54
|
+
- [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
|
|
55
|
+
</script>
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
function FrequencyBlockNode(config) {
|
|
3
|
+
RED.nodes.createNode(this, config);
|
|
4
|
+
const node = this;
|
|
5
|
+
|
|
6
|
+
// Initialize runtime state
|
|
7
|
+
node.runtime = {
|
|
8
|
+
name: config.name || "",
|
|
9
|
+
lastIn: false,
|
|
10
|
+
lastEdge: 0,
|
|
11
|
+
completeCycle: false,
|
|
12
|
+
ppm: 0,
|
|
13
|
+
pph: 0,
|
|
14
|
+
ppd: 0
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
node.status({
|
|
18
|
+
fill: "green",
|
|
19
|
+
shape: "dot",
|
|
20
|
+
text: "awaiting first pulse"
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// FEATURE: I want a runtime percentage per hour duty cycle
|
|
24
|
+
|
|
25
|
+
node.on("input", function(msg, send, done) {
|
|
26
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
27
|
+
|
|
28
|
+
// Guard against invalid message
|
|
29
|
+
if (!msg) {
|
|
30
|
+
node.status({ fill: "red", shape: "ring", text: "invalid message" });
|
|
31
|
+
if (done) done();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Handle context updates
|
|
36
|
+
if (msg.hasOwnProperty("context")) {
|
|
37
|
+
if (!msg.hasOwnProperty("payload")) {
|
|
38
|
+
node.status({ fill: "red", shape: "ring", text: "missing payload for reset" });
|
|
39
|
+
if (done) done();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (msg.context === "reset") {
|
|
43
|
+
if (typeof msg.payload !== "boolean") {
|
|
44
|
+
node.status({ fill: "red", shape: "ring", text: "invalid reset" });
|
|
45
|
+
if (done) done();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (msg.payload === true) {
|
|
49
|
+
node.runtime.lastIn = false;
|
|
50
|
+
node.runtime.lastEdge = 0;
|
|
51
|
+
node.runtime.completeCycle = false;
|
|
52
|
+
node.runtime.ppm = 0;
|
|
53
|
+
node.runtime.pph = 0;
|
|
54
|
+
node.runtime.ppd = 0;
|
|
55
|
+
node.status({ fill: "green", shape: "dot", text: "reset" });
|
|
56
|
+
}
|
|
57
|
+
if (done) done();
|
|
58
|
+
return;
|
|
59
|
+
} else {
|
|
60
|
+
node.status({ fill: "yellow", shape: "ring", text: "unknown context" });
|
|
61
|
+
if (done) done("Unknown context");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Validate input payload
|
|
67
|
+
if (!msg.hasOwnProperty("payload")) {
|
|
68
|
+
node.status({ fill: "red", shape: "ring", text: "missing payload" });
|
|
69
|
+
if (done) done();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const inputValue = msg.payload;
|
|
74
|
+
if (typeof inputValue !== "boolean") {
|
|
75
|
+
node.status({ fill: "red", shape: "ring", text: "invalid payload" });
|
|
76
|
+
if (done) done();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Initialize output
|
|
81
|
+
let output = {
|
|
82
|
+
ppm: node.runtime.ppm,
|
|
83
|
+
pph: node.runtime.pph,
|
|
84
|
+
ppd: node.runtime.ppd
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Detect rising edge
|
|
88
|
+
if (inputValue && !node.runtime.lastIn) { // Rising edge: true and lastIn was false
|
|
89
|
+
let now = Date.now();
|
|
90
|
+
if (!node.runtime.completeCycle) {
|
|
91
|
+
node.runtime.completeCycle = true;
|
|
92
|
+
} else {
|
|
93
|
+
// Compute period in minutes
|
|
94
|
+
let periodMs = now - node.runtime.lastEdge;
|
|
95
|
+
let periodMin = periodMs / 60000;
|
|
96
|
+
if (periodMin > 0.001) {
|
|
97
|
+
// Minimum 0.6ms period (1000 pulses/sec)
|
|
98
|
+
output.ppm = 1 / periodMin; // Pulses per minute
|
|
99
|
+
output.pph = output.ppm * 60; // Pulses per hour
|
|
100
|
+
output.ppd = output.ppm * 1440; // Pulses per day
|
|
101
|
+
} else {
|
|
102
|
+
// Handle ultra-high frequency
|
|
103
|
+
output.ppm = 1000;
|
|
104
|
+
output.pph = 60000;
|
|
105
|
+
output.ppd = 1440000;
|
|
106
|
+
}
|
|
107
|
+
node.runtime.ppm = output.ppm;
|
|
108
|
+
node.runtime.pph = output.pph;
|
|
109
|
+
node.runtime.ppd = output.ppd;
|
|
110
|
+
}
|
|
111
|
+
node.runtime.lastEdge = now;
|
|
112
|
+
node.runtime.completeCycle = true;
|
|
113
|
+
|
|
114
|
+
node.status({
|
|
115
|
+
fill: "blue",
|
|
116
|
+
shape: "dot",
|
|
117
|
+
text: `ppm: ${output.ppm.toFixed(2)}, pph: ${output.pph.toFixed(2)}, ppd: ${output.ppd.toFixed(2)}`
|
|
118
|
+
});
|
|
119
|
+
send({ payload: output });
|
|
120
|
+
} else {
|
|
121
|
+
node.status({
|
|
122
|
+
fill: "blue",
|
|
123
|
+
shape: "ring",
|
|
124
|
+
text: `input: ${inputValue}, ppm: ${node.runtime.ppm.toFixed(2)}, pph: ${node.runtime.pph.toFixed(2)}, ppd: ${node.runtime.ppd.toFixed(2)}`
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Update lastIn
|
|
129
|
+
node.runtime.lastIn = inputValue;
|
|
130
|
+
|
|
131
|
+
if (done) done();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
node.on("close", function(done) {
|
|
135
|
+
done();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
RED.nodes.registerType("frequency-block", FrequencyBlockNode);
|
|
140
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<!-- UI Template Section: Defines the edit dialog -->
|
|
2
|
+
<script type="text/html" data-template-name="hysteresis-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-upperLimit"><i class="fa fa-arrow-up"></i> Upper Limit (turn on)</label>
|
|
9
|
+
<input type="text" id="node-input-upperLimit" placeholder="50">
|
|
10
|
+
<input type="hidden" id="node-input-upperLimitType">
|
|
11
|
+
</div>
|
|
12
|
+
<div class="form-row">
|
|
13
|
+
<label for="node-input-upperLimitThreshold"><i class="fa fa-arrows-h"></i> Upper Differential (turn off)</label>
|
|
14
|
+
<input type="text" id="node-input-upperLimitThreshold" placeholder="2">
|
|
15
|
+
<input type="hidden" id="node-input-upperLimitThresholdType">
|
|
16
|
+
</div>
|
|
17
|
+
<div class="form-row">
|
|
18
|
+
<label for="node-input-lowerLimit"><i class="fa fa-arrow-down"></i> Lower Limit (turn on)</label>
|
|
19
|
+
<input type="text" id="node-input-lowerLimit" placeholder="30">
|
|
20
|
+
<input type="hidden" id="node-input-lowerLimitType">
|
|
21
|
+
</div>
|
|
22
|
+
<div class="form-row">
|
|
23
|
+
<label for="node-input-lowerLimitThreshold"><i class="fa fa-arrows-h"></i> Lower Differential (turn off)</label>
|
|
24
|
+
<input type="text" id="node-input-lowerLimitThreshold" placeholder="2">
|
|
25
|
+
<input type="hidden" id="node-input-lowerLimitThresholdType">
|
|
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("hysteresis-block", {
|
|
33
|
+
category: "control",
|
|
34
|
+
color: "#301934",
|
|
35
|
+
defaults: {
|
|
36
|
+
name: { value: "" },
|
|
37
|
+
upperLimit: { value: 50, required: true },
|
|
38
|
+
upperLimitType: { value: "num" },
|
|
39
|
+
lowerLimit: { value: 30, required: true },
|
|
40
|
+
lowerLimitType: { value: "num" },
|
|
41
|
+
upperLimitThreshold: { value: 2, required: true },
|
|
42
|
+
upperLimitThresholdType: { value: "num" },
|
|
43
|
+
lowerLimitThreshold: { value: 2, required: true },
|
|
44
|
+
lowerLimitThresholdType: { value: "num" }
|
|
45
|
+
},
|
|
46
|
+
inputs: 1,
|
|
47
|
+
outputs: 3,
|
|
48
|
+
inputLabels: ["input"],
|
|
49
|
+
outputLabels: ["above", "within", "below"],
|
|
50
|
+
icon: "font-awesome/fa-toggle-on",
|
|
51
|
+
paletteLabel: "hysteresis",
|
|
52
|
+
label: function() {
|
|
53
|
+
return this.name || "hysteresis";
|
|
54
|
+
},
|
|
55
|
+
oneditprepare: function() {
|
|
56
|
+
const node = this;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// Initialize typed inputs
|
|
60
|
+
$("#node-input-upperLimit").typedInput({
|
|
61
|
+
default: "num",
|
|
62
|
+
types: ["num", "msg", "flow", "global"],
|
|
63
|
+
typeField: "#node-input-upperLimitType"
|
|
64
|
+
}).typedInput("type", node.upperLimitType || "num").typedInput("value", node.upperLimit);
|
|
65
|
+
|
|
66
|
+
$("#node-input-lowerLimit").typedInput({
|
|
67
|
+
default: "num",
|
|
68
|
+
types: ["num", "msg", "flow", "global"],
|
|
69
|
+
typeField: "#node-input-lowerLimitType"
|
|
70
|
+
}).typedInput("type", node.lowerLimitType || "num").typedInput("value", node.lowerLimit);
|
|
71
|
+
|
|
72
|
+
$("#node-input-upperLimitThreshold").typedInput({
|
|
73
|
+
default: "num",
|
|
74
|
+
types: ["num", "msg", "flow", "global"],
|
|
75
|
+
typeField: "#node-input-upperLimitThresholdType"
|
|
76
|
+
}).typedInput("type", node.upperLimitThresholdType || "num").typedInput("value", node.upperLimitThreshold);
|
|
77
|
+
|
|
78
|
+
$("#node-input-lowerLimitThreshold").typedInput({
|
|
79
|
+
default: "num",
|
|
80
|
+
types: ["num", "msg", "flow", "global"],
|
|
81
|
+
typeField: "#node-input-lowerLimitThresholdType"
|
|
82
|
+
}).typedInput("type", node.lowerLimitThresholdType || "num").typedInput("value", node.lowerLimitThreshold);
|
|
83
|
+
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error("Error in hysteresis-block oneditprepare:", err);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<!-- Help Section -->
|
|
92
|
+
<script type="text/markdown" data-help-name="hysteresis-block">
|
|
93
|
+
Hysteresis controller with separate turn-on limits and turn-off differentials.
|
|
94
|
+
|
|
95
|
+
### Inputs
|
|
96
|
+
: payload (number) : Input value to evaluate
|
|
97
|
+
: context (string) : Configure `upperLimit`, `lowerLimit`, `upperLimitThreshold`, `lowerLimitThreshold`
|
|
98
|
+
|
|
99
|
+
### Outputs
|
|
100
|
+
: above (boolean) : Input > upperLimit
|
|
101
|
+
: within (boolean) : lowerLimit ≤ input ≤ upperLimit
|
|
102
|
+
: below (boolean) : Input < lowerLimit
|
|
103
|
+
|
|
104
|
+
### Properties
|
|
105
|
+
: upperLimit (number) : Turn on "above" state when input exceeds this value
|
|
106
|
+
: upperLimitThreshold (number) : Turn off "above" state when input drops below (upperLimit - this value)
|
|
107
|
+
: lowerLimit (number) : Turn on "below" state when input drops below this value
|
|
108
|
+
: lowerLimitThreshold (number) : Turn off "below" state when input rises above (lowerLimit + this value)
|
|
109
|
+
|
|
110
|
+
### Hysteresis Behavior
|
|
111
|
+
- **Above**: Turns on at upperLimit, turns off at (upperLimit - upperLimitThreshold)
|
|
112
|
+
- **Below**: Turns on at lowerLimit, turns off at (lowerLimit + lowerLimitThreshold)
|
|
113
|
+
- **Within**: State between turn-off points
|
|
114
|
+
|
|
115
|
+
### Example: Temperature Control
|
|
116
|
+
- upperLimit: 75°F (turn on cooling)
|
|
117
|
+
- upperLimitThreshold: 2°F (turn off cooling at 73°F)
|
|
118
|
+
- lowerLimit: 65°F (turn on heating)
|
|
119
|
+
- lowerLimitThreshold: 2°F (turn off heating at 67°F)
|
|
120
|
+
|
|
121
|
+
### Status
|
|
122
|
+
- Green (dot): Configuration update
|
|
123
|
+
- Blue (dot): State changed
|
|
124
|
+
- Blue (ring): State unchanged
|
|
125
|
+
- Red (ring): Error
|
|
126
|
+
- Yellow (ring): Warning
|
|
127
|
+
|
|
128
|
+
### References
|
|
129
|
+
- [Node-RED Documentation](https://nodered.org/docs/)
|
|
130
|
+
- [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
|
|
131
|
+
</script>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
function HysteresisBlockNode(config) {
|
|
3
|
+
RED.nodes.createNode(this, config);
|
|
4
|
+
const node = this;
|
|
5
|
+
node.name = config.name;
|
|
6
|
+
node.state = "within";
|
|
7
|
+
|
|
8
|
+
node.on("input", function(msg, send, done) {
|
|
9
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
10
|
+
|
|
11
|
+
if (!msg) {
|
|
12
|
+
node.status({ fill: "red", shape: "ring", text: "invalid message" });
|
|
13
|
+
if (done) done();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Evaluate typed-inputs
|
|
18
|
+
try {
|
|
19
|
+
node.upperLimit = RED.util.evaluateNodeProperty(
|
|
20
|
+
config.upperLimit, config.upperLimitType, node, msg
|
|
21
|
+
);
|
|
22
|
+
node.lowerLimit = RED.util.evaluateNodeProperty(
|
|
23
|
+
config.lowerLimit, config.lowerLimitType, node, msg
|
|
24
|
+
);
|
|
25
|
+
node.upperLimitThreshold = RED.util.evaluateNodeProperty(
|
|
26
|
+
config.upperLimitThreshold, config.upperLimitThresholdType, node, msg
|
|
27
|
+
);
|
|
28
|
+
node.lowerLimitThreshold = RED.util.evaluateNodeProperty(
|
|
29
|
+
config.lowerLimitThreshold, config.lowerLimitThresholdType, node, msg
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Validate values
|
|
33
|
+
if (isNaN(node.upperLimit) || isNaN(node.lowerLimit) ||
|
|
34
|
+
isNaN(node.upperLimitThreshold) || isNaN(node.lowerLimitThreshold) ||
|
|
35
|
+
node.upperLimit <= node.lowerLimit ||
|
|
36
|
+
node.upperLimitThreshold < 0 || node.lowerLimitThreshold < 0) {
|
|
37
|
+
node.status({ fill: "red", shape: "ring", text: "invalid evaluated values" });
|
|
38
|
+
if (done) done();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
} catch(err) {
|
|
42
|
+
node.status({ fill: "red", shape: "ring", text: "error evaluating properties" });
|
|
43
|
+
if (done) done(err);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (msg.hasOwnProperty("context")) {
|
|
48
|
+
if (msg.context === "upperLimitThreshold") {
|
|
49
|
+
const value = parseFloat(msg.payload);
|
|
50
|
+
if (!isNaN(value) && value >= 0) {
|
|
51
|
+
node.upperLimitThreshold = value;
|
|
52
|
+
node.status({ fill: "green", shape: "dot", text: `upperLimitThreshold: ${value}` });
|
|
53
|
+
}
|
|
54
|
+
} else if (msg.context === "lowerLimitThreshold") {
|
|
55
|
+
const value = parseFloat(msg.payload);
|
|
56
|
+
if (!isNaN(value) && value >= 0) {
|
|
57
|
+
node.lowerLimitThreshold = value;
|
|
58
|
+
node.status({ fill: "green", shape: "dot", text: `lowerLimitThreshold: ${value}` });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (done) done();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!msg.hasOwnProperty("payload")) {
|
|
66
|
+
node.status({ fill: "red", shape: "ring", text: "missing payload" });
|
|
67
|
+
if (done) done();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const inputValue = parseFloat(msg.payload);
|
|
71
|
+
if (isNaN(inputValue)) {
|
|
72
|
+
node.status({ fill: "red", shape: "ring", text: "invalid payload" });
|
|
73
|
+
if (done) done();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Calculate all boundary points - ensure numeric values
|
|
78
|
+
const upperTurnOn = node.upperLimit;
|
|
79
|
+
const upperTurnOff = node.upperLimit - node.upperLimitThreshold;
|
|
80
|
+
const lowerTurnOn = node.lowerLimit;
|
|
81
|
+
const lowerTurnOff = node.lowerLimit + node.lowerLimitThreshold;
|
|
82
|
+
|
|
83
|
+
// Add validation to ensure numbers
|
|
84
|
+
if (isNaN(upperTurnOn) || isNaN(upperTurnOff) || isNaN(lowerTurnOn) || isNaN(lowerTurnOff)) {
|
|
85
|
+
node.status({ fill: "red", shape: "ring", text: "invalid boundary calculation" });
|
|
86
|
+
if (done) done();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Apply comprehensive hysteresis logic
|
|
90
|
+
let newState = node.state;
|
|
91
|
+
|
|
92
|
+
switch (node.state) {
|
|
93
|
+
case "above":
|
|
94
|
+
if (inputValue <= upperTurnOff) {
|
|
95
|
+
newState = "within";
|
|
96
|
+
if (inputValue <= lowerTurnOn) {
|
|
97
|
+
newState = "below";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
case "below":
|
|
102
|
+
if (inputValue >= lowerTurnOff) {
|
|
103
|
+
newState = "within";
|
|
104
|
+
if (inputValue >= upperTurnOn) {
|
|
105
|
+
newState = "above";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
case "within":
|
|
110
|
+
if (inputValue >= upperTurnOn) {
|
|
111
|
+
newState = "above";
|
|
112
|
+
} else if (inputValue <= lowerTurnOn) {
|
|
113
|
+
newState = "below";
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const output = [
|
|
119
|
+
{ payload: newState === "above" },
|
|
120
|
+
{ payload: newState === "within" },
|
|
121
|
+
{ payload: newState === "below" }
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
node.status({
|
|
125
|
+
fill: "blue",
|
|
126
|
+
shape: "dot",
|
|
127
|
+
text: `in: ${inputValue.toFixed(2)}, state: ${newState}`
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
node.state = newState;
|
|
131
|
+
send(output);
|
|
132
|
+
|
|
133
|
+
if (done) done();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
node.on("close", function(done) {
|
|
137
|
+
done();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
RED.nodes.registerType("hysteresis-block", HysteresisBlockNode);
|
|
142
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script type="text/html" data-template-name="interpolate-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-points" title="Default points table for interpolation (array of {x, y} objects, ≥2 points)"><i class="fa fa-table"></i> Points</label>
|
|
8
|
+
<textarea id="node-input-points" placeholder='[{"x": 0, "y": 0}, {"x": 100, "y": 100}]' style="height: 100px;"></textarea>
|
|
9
|
+
</div>
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script type="text/javascript">
|
|
13
|
+
RED.nodes.registerType("interpolate-block", {
|
|
14
|
+
category: "control",
|
|
15
|
+
color: "#301934",
|
|
16
|
+
defaults: {
|
|
17
|
+
name: { value: "" },
|
|
18
|
+
points: {
|
|
19
|
+
value: JSON.stringify([{ x: 0, y: 0 }, { x: 100, y: 100 }], null, 2),
|
|
20
|
+
required: true,
|
|
21
|
+
validate: function(v) {
|
|
22
|
+
try {
|
|
23
|
+
const points = JSON.parse(v);
|
|
24
|
+
return Array.isArray(points) && points.length >= 2 &&
|
|
25
|
+
points.every(p => typeof p.x === "number" && !isNaN(p.x) &&
|
|
26
|
+
typeof p.y === "number" && !isNaN(p.y));
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
inputs: 1,
|
|
34
|
+
outputs: 1,
|
|
35
|
+
inputLabels: ["input"],
|
|
36
|
+
outputLabels: ["output"],
|
|
37
|
+
icon: "font-awesome/fa-line-chart",
|
|
38
|
+
paletteLabel: "interpolate",
|
|
39
|
+
label: function() {
|
|
40
|
+
return this.name ? `${this.name} (${this.points ? JSON.parse(this.points).length : 2})` : `interpolate (${this.points ? JSON.parse(this.points).length : 2})`;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<script type="text/markdown" data-help-name="interpolate-block">
|
|
46
|
+
Linearly interpolates a numeric input using a configurable points table.
|
|
47
|
+
|
|
48
|
+
### Inputs
|
|
49
|
+
: context (string) : Configures points table (`"points"`).
|
|
50
|
+
: payload (number | array) : Number for interpolation, or array of `{x, y}` objects for points configuration.
|
|
51
|
+
|
|
52
|
+
### Outputs
|
|
53
|
+
: payload (number) : Interpolated output value.
|
|
54
|
+
|
|
55
|
+
### Details
|
|
56
|
+
Interpolates a numeric `msg.payload` using a table of `{x, y}` points.
|
|
57
|
+
|
|
58
|
+
Points are set via editor or `msg.context = "points"` with an array of ≥2 `{x, y}` objects (x and y as numbers).
|
|
59
|
+
|
|
60
|
+
Outputs only when the interpolated value changes.
|
|
61
|
+
|
|
62
|
+
Input must be within the x-range of points; out-of-range inputs are rejected.
|
|
63
|
+
|
|
64
|
+
### Status
|
|
65
|
+
- Green (dot): Configuration
|
|
66
|
+
- Blue (dot): Output, no alarm
|
|
67
|
+
- Red (dot): Output with alarm
|
|
68
|
+
- Red (ring): Errors
|
|
69
|
+
- Yellow (ring): Unknown context
|
|
70
|
+
|
|
71
|
+
### References
|
|
72
|
+
- [Node-RED Documentation](https://nodered.org/docs/)
|
|
73
|
+
- [GitHub Repository](https://github.com/BldgBlocks/node-red-contrib-buildingblocks-control.git)
|
|
74
|
+
</script>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
function InterpolateBlockNode(config) {
|
|
3
|
+
RED.nodes.createNode(this, config);
|
|
4
|
+
|
|
5
|
+
const node = this;
|
|
6
|
+
|
|
7
|
+
// Initialize runtime state
|
|
8
|
+
node.runtime = {
|
|
9
|
+
name: config.name || "interpolate",
|
|
10
|
+
points: null,
|
|
11
|
+
lastOutput: null
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Initialize points
|
|
15
|
+
try {
|
|
16
|
+
node.runtime.points = config.points ? JSON.parse(config.points) : [{ x: 0, y: 0 }, { x: 100, y: 100 }];
|
|
17
|
+
if (!Array.isArray(node.runtime.points) || node.runtime.points.length < 2 ||
|
|
18
|
+
!node.runtime.points.every(p => typeof p.x === "number" && !isNaN(p.x) &&
|
|
19
|
+
typeof p.y === "number" && !isNaN(p.y))) {
|
|
20
|
+
node.runtime.points = [{ x: 0, y: 0 }, { x: 100, y: 100 }];
|
|
21
|
+
node.status({ fill: "red", shape: "ring", text: "invalid points, using default" });
|
|
22
|
+
} else {
|
|
23
|
+
node.status({
|
|
24
|
+
fill: "green",
|
|
25
|
+
shape: "dot",
|
|
26
|
+
text: `name: ${node.runtime.name}, points: ${node.runtime.points.length}`
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
} catch (e) {
|
|
30
|
+
node.runtime.points = [{ x: 0, y: 0 }, { x: 100, y: 100 }];
|
|
31
|
+
node.status({ fill: "red", shape: "ring", text: "invalid points, using default" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
node.on("input", function(msg, send, done) {
|
|
35
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
36
|
+
|
|
37
|
+
// Guard against invalid msg
|
|
38
|
+
if (!msg) {
|
|
39
|
+
node.status({ fill: "red", shape: "ring", text: "invalid message" });
|
|
40
|
+
if (done) done();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Handle configuration messages
|
|
45
|
+
if (msg.context) {
|
|
46
|
+
if (typeof msg.context !== "string" || !msg.context.trim()) {
|
|
47
|
+
node.status({ fill: "yellow", shape: "ring", text: "unknown context" });
|
|
48
|
+
if (done) done();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!msg.hasOwnProperty("payload")) {
|
|
52
|
+
node.status({ fill: "red", shape: "ring", text: "missing payload" });
|
|
53
|
+
if (done) done();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (msg.context === "points") {
|
|
57
|
+
try {
|
|
58
|
+
const newPoints = Array.isArray(msg.payload) ? msg.payload : JSON.parse(msg.payload);
|
|
59
|
+
if (Array.isArray(newPoints) && newPoints.length >= 2 &&
|
|
60
|
+
newPoints.every(p => typeof p.x === "number" && !isNaN(p.x) &&
|
|
61
|
+
typeof p.y === "number" && !isNaN(p.y))) {
|
|
62
|
+
node.runtime.points = newPoints;
|
|
63
|
+
node.status({
|
|
64
|
+
fill: "green",
|
|
65
|
+
shape: "dot",
|
|
66
|
+
text: `points: ${newPoints.length}`
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
node.status({ fill: "red", shape: "ring", text: "invalid points" });
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
node.status({ fill: "red", shape: "ring", text: "invalid points" });
|
|
73
|
+
}
|
|
74
|
+
if (done) done();
|
|
75
|
+
return;
|
|
76
|
+
} else {
|
|
77
|
+
node.status({ fill: "yellow", shape: "ring", text: "unknown context" });
|
|
78
|
+
if (done) done();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check for missing payload
|
|
84
|
+
if (!msg.hasOwnProperty("payload")) {
|
|
85
|
+
node.status({ fill: "red", shape: "ring", text: "missing payload" });
|
|
86
|
+
if (done) done();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Process input
|
|
91
|
+
const inputValue = parseFloat(msg.payload);
|
|
92
|
+
if (isNaN(inputValue)) {
|
|
93
|
+
node.status({ fill: "red", shape: "ring", text: "invalid input" });
|
|
94
|
+
if (done) done();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Linear interpolation
|
|
99
|
+
let outputValue = NaN;
|
|
100
|
+
const isPositiveSlope = node.runtime.points.length >= 2 && node.runtime.points[1].x > node.runtime.points[0].x;
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < node.runtime.points.length - 1; i++) {
|
|
103
|
+
let x1 = node.runtime.points[i].x, y1 = node.runtime.points[i].y;
|
|
104
|
+
let x2 = node.runtime.points[i + 1].x, y2 = node.runtime.points[i + 1].y;
|
|
105
|
+
if (isPositiveSlope ? (inputValue >= x1 && inputValue <= x2) : (inputValue <= x1 && inputValue >= x2)) {
|
|
106
|
+
let m = (y2 - y1) / (x2 - x1);
|
|
107
|
+
let b = y1 - (m * x1);
|
|
108
|
+
outputValue = (m * inputValue) + b;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (isNaN(outputValue)) {
|
|
114
|
+
node.status({ fill: "red", shape: "ring", text: "input out of range" });
|
|
115
|
+
if (done) done();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check if output value has changed
|
|
120
|
+
const isUnchanged = outputValue === node.runtime.lastOutput;
|
|
121
|
+
node.status({
|
|
122
|
+
fill: "blue",
|
|
123
|
+
shape: isUnchanged ? "ring" : "dot",
|
|
124
|
+
text: `in: ${inputValue.toFixed(2)}, out: ${outputValue.toFixed(2)}`
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!isUnchanged) {
|
|
128
|
+
node.runtime.lastOutput = outputValue;
|
|
129
|
+
send({ payload: outputValue });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (done) done();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
node.on("close", function(done) {
|
|
136
|
+
done();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
RED.nodes.registerType("interpolate-block", InterpolateBlockNode);
|
|
141
|
+
};
|