@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.
Files changed (98) hide show
  1. package/README.md +43 -0
  2. package/nodes/accumulate-block.html +71 -0
  3. package/nodes/accumulate-block.js +104 -0
  4. package/nodes/add-block.html +67 -0
  5. package/nodes/add-block.js +97 -0
  6. package/nodes/analog-switch-block.html +65 -0
  7. package/nodes/analog-switch-block.js +129 -0
  8. package/nodes/and-block.html +64 -0
  9. package/nodes/and-block.js +73 -0
  10. package/nodes/average-block.html +97 -0
  11. package/nodes/average-block.js +137 -0
  12. package/nodes/boolean-switch-block.html +59 -0
  13. package/nodes/boolean-switch-block.js +88 -0
  14. package/nodes/boolean-to-number-block.html +59 -0
  15. package/nodes/boolean-to-number-block.js +45 -0
  16. package/nodes/cache-block.html +69 -0
  17. package/nodes/cache-block.js +106 -0
  18. package/nodes/call-status-block.html +111 -0
  19. package/nodes/call-status-block.js +274 -0
  20. package/nodes/changeover-block.html +234 -0
  21. package/nodes/changeover-block.js +392 -0
  22. package/nodes/comment-block.html +83 -0
  23. package/nodes/comment-block.js +53 -0
  24. package/nodes/compare-block.html +64 -0
  25. package/nodes/compare-block.js +84 -0
  26. package/nodes/contextual-label-block.html +67 -0
  27. package/nodes/contextual-label-block.js +52 -0
  28. package/nodes/convert-block.html +179 -0
  29. package/nodes/convert-block.js +289 -0
  30. package/nodes/count-block.html +57 -0
  31. package/nodes/count-block.js +92 -0
  32. package/nodes/debounce-block.html +64 -0
  33. package/nodes/debounce-block.js +140 -0
  34. package/nodes/delay-block.html +104 -0
  35. package/nodes/delay-block.js +180 -0
  36. package/nodes/divide-block.html +65 -0
  37. package/nodes/divide-block.js +123 -0
  38. package/nodes/edge-block.html +71 -0
  39. package/nodes/edge-block.js +120 -0
  40. package/nodes/frequency-block.html +55 -0
  41. package/nodes/frequency-block.js +140 -0
  42. package/nodes/hysteresis-block.html +131 -0
  43. package/nodes/hysteresis-block.js +142 -0
  44. package/nodes/interpolate-block.html +74 -0
  45. package/nodes/interpolate-block.js +141 -0
  46. package/nodes/load-sequence-block.html +134 -0
  47. package/nodes/load-sequence-block.js +272 -0
  48. package/nodes/max-block.html +76 -0
  49. package/nodes/max-block.js +103 -0
  50. package/nodes/memory-block.html +90 -0
  51. package/nodes/memory-block.js +241 -0
  52. package/nodes/min-block.html +77 -0
  53. package/nodes/min-block.js +106 -0
  54. package/nodes/minmax-block.html +89 -0
  55. package/nodes/minmax-block.js +119 -0
  56. package/nodes/modulo-block.html +73 -0
  57. package/nodes/modulo-block.js +126 -0
  58. package/nodes/multiply-block.html +63 -0
  59. package/nodes/multiply-block.js +115 -0
  60. package/nodes/negate-block.html +55 -0
  61. package/nodes/negate-block.js +91 -0
  62. package/nodes/nullify-block.html +111 -0
  63. package/nodes/nullify-block.js +78 -0
  64. package/nodes/on-change-block.html +79 -0
  65. package/nodes/on-change-block.js +191 -0
  66. package/nodes/oneshot-block.html +96 -0
  67. package/nodes/oneshot-block.js +169 -0
  68. package/nodes/or-block.html +64 -0
  69. package/nodes/or-block.js +73 -0
  70. package/nodes/pid-block.html +205 -0
  71. package/nodes/pid-block.js +407 -0
  72. package/nodes/priority-block.html +66 -0
  73. package/nodes/priority-block.js +239 -0
  74. package/nodes/rate-limit-block.html +99 -0
  75. package/nodes/rate-limit-block.js +221 -0
  76. package/nodes/round-block.html +73 -0
  77. package/nodes/round-block.js +89 -0
  78. package/nodes/saw-tooth-wave-block.html +87 -0
  79. package/nodes/saw-tooth-wave-block.js +161 -0
  80. package/nodes/scale-range-block.html +90 -0
  81. package/nodes/scale-range-block.js +137 -0
  82. package/nodes/sine-wave-block.html +88 -0
  83. package/nodes/sine-wave-block.js +142 -0
  84. package/nodes/subtract-block.html +64 -0
  85. package/nodes/subtract-block.js +103 -0
  86. package/nodes/thermistor-block.html +81 -0
  87. package/nodes/thermistor-block.js +146 -0
  88. package/nodes/tick-tock-block.html +66 -0
  89. package/nodes/tick-tock-block.js +110 -0
  90. package/nodes/time-sequence-block.html +67 -0
  91. package/nodes/time-sequence-block.js +144 -0
  92. package/nodes/triangle-wave-block.html +86 -0
  93. package/nodes/triangle-wave-block.js +154 -0
  94. package/nodes/tstat-block.html +311 -0
  95. package/nodes/tstat-block.js +499 -0
  96. package/nodes/units-block.html +150 -0
  97. package/nodes/units-block.js +106 -0
  98. 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
+ };