node-red-contrib-3dm-space 1.0.0 → 1.0.2

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,236 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("3dm-config-store", {
3
+ category: "3DM.space",
4
+ color: "#6D6D6D",
5
+ defaults: {
6
+ name: { value: "" },
7
+ configName: { value: "" },
8
+ tickSeconds: {
9
+ value: 20,
10
+ validate: function (v) {
11
+ var n = Number(v);
12
+ return !isNaN(n) && n >= 1 && n <= 60;
13
+ }
14
+ },
15
+ outputMode: { value: "change" },
16
+ configOutput: { value: 0 },
17
+ emitConfigOnUpdate: { value: false }
18
+ },
19
+ inputs: 1,
20
+ outputs: 24,
21
+ icon: "font-awesome/fa-database",
22
+ label: function () {
23
+ return this.name || "3DM config store";
24
+ },
25
+ paletteLabel: "3DM config store",
26
+ outputLabels: function (index) {
27
+ return "Output " + (index + 1);
28
+ }
29
+ });
30
+ </script>
31
+
32
+ <script type="text/html" data-template-name="3dm-config-store">
33
+ <div class="form-row">
34
+ <label for="node-input-name">
35
+ <i class="fa fa-tag"></i> Node Name
36
+ </label>
37
+ <input type="text" id="node-input-name" placeholder="Optional, e.g. Pump Config Store">
38
+ </div>
39
+
40
+ <div class="form-row">
41
+ <label for="node-input-configName">
42
+ <i class="fa fa-folder-open"></i> Config Name
43
+ </label>
44
+ <input type="text" id="node-input-configName" placeholder="Optional, e.g. mainScheduler">
45
+ </div>
46
+
47
+ <div class="form-row">
48
+ <label for="node-input-tickSeconds">
49
+ <i class="fa fa-clock-o"></i> Check Every
50
+ </label>
51
+ <input type="number" id="node-input-tickSeconds" min="1" max="60" placeholder="20" style="width:100px;">
52
+ <span> seconds</span>
53
+ </div>
54
+
55
+ <div class="form-row">
56
+ <label for="node-input-outputMode">
57
+ <i class="fa fa-sign-out"></i> Output Mode
58
+ </label>
59
+ <select id="node-input-outputMode" style="width:70%;">
60
+ <option value="change">Only when output changes</option>
61
+ <option value="always">Every check</option>
62
+ </select>
63
+ </div>
64
+
65
+ <div class="form-row">
66
+ <label for="node-input-configOutput">
67
+ <i class="fa fa-share-square-o"></i> Config Output
68
+ </label>
69
+ <select id="node-input-configOutput" style="width:70%;">
70
+ <option value="0">Disabled</option>
71
+ <option value="1">Output 1</option>
72
+ <option value="2">Output 2</option>
73
+ <option value="3">Output 3</option>
74
+ <option value="4">Output 4</option>
75
+ <option value="5">Output 5</option>
76
+ <option value="6">Output 6</option>
77
+ <option value="7">Output 7</option>
78
+ <option value="8">Output 8</option>
79
+ <option value="9">Output 9</option>
80
+ <option value="10">Output 10</option>
81
+ <option value="11">Output 11</option>
82
+ <option value="12">Output 12</option>
83
+ <option value="13">Output 13</option>
84
+ <option value="14">Output 14</option>
85
+ <option value="15">Output 15</option>
86
+ <option value="16">Output 16</option>
87
+ <option value="17">Output 17</option>
88
+ <option value="18">Output 18</option>
89
+ <option value="19">Output 19</option>
90
+ <option value="20">Output 20</option>
91
+ <option value="21">Output 21</option>
92
+ <option value="22">Output 22</option>
93
+ <option value="23">Output 23</option>
94
+ <option value="24">Output 24</option>
95
+ </select>
96
+ </div>
97
+
98
+ <div class="form-row">
99
+ <label for="node-input-emitConfigOnUpdate">
100
+ <i class="fa fa-refresh"></i> Send Config
101
+ </label>
102
+ <input type="checkbox" id="node-input-emitConfigOnUpdate" style="display:inline-block; width:auto; vertical-align:top;">
103
+ <span> send stored config when updated</span>
104
+ </div>
105
+
106
+ <div class="form-tips">
107
+ <b>Tip:</b> Leave Config Name blank to allow this node to store and use all config blocks sent under <code>configStore</code>.
108
+ </div>
109
+ </script>
110
+
111
+ <script type="text/html" data-help-name="3dm-config-store">
112
+ <p>
113
+ Stores configuration sent from 3DM.space and runs local schedule/control logic.
114
+ </p>
115
+
116
+ <p>
117
+ This node is normally placed after a <b>3DM In</b> node.
118
+ </p>
119
+
120
+ <pre>3DM In → 3DM Config Store → 24 outputs</pre>
121
+
122
+ <h3>Config Store Format</h3>
123
+
124
+ <p>
125
+ Incoming messages should contain a main <code>configStore</code> object.
126
+ Each config block inside it should have its own name.
127
+ </p>
128
+
129
+ <pre>{
130
+ "configStore": {
131
+ "mainScheduler": {
132
+ "type": "scheduler",
133
+ "data": []
134
+ },
135
+ "pumpSetpoints": {
136
+ "type": "setpoints",
137
+ "data": {
138
+ "startLevel": 30,
139
+ "stopLevel": 80
140
+ }
141
+ }
142
+ }
143
+ }</pre>
144
+
145
+ <h3>Scheduler Config</h3>
146
+
147
+ <p>
148
+ Scheduler blocks use <code>type: "scheduler"</code>.
149
+ </p>
150
+
151
+ <pre>{
152
+ "configStore": {
153
+ "mainScheduler": {
154
+ "type": "scheduler",
155
+ "data": [
156
+ {
157
+ "name": "Pump 1 Morning Run",
158
+ "output": "output1",
159
+ "controlType": "schedule",
160
+ "mode": "Auto Schedule",
161
+ "days": ["mon", "tue", "wed", "thu", "fri"],
162
+ "startHour": 6,
163
+ "startMinute": 0,
164
+ "runHour": 1,
165
+ "runMinute": 30
166
+ }
167
+ ]
168
+ }
169
+ }
170
+ }</pre>
171
+
172
+ <h3>Control Config</h3>
173
+
174
+ <p>
175
+ Control entries can use live input values and stored setpoints.
176
+ </p>
177
+
178
+ <pre>{
179
+ "name": "Tank Level Control",
180
+ "output": "output1",
181
+ "controlType": "control",
182
+ "mode": "AUTO",
183
+ "variable": "tank_level",
184
+ "lowSetpointKey": "pumpSetpoints.data.startLevel",
185
+ "highSetpointKey": "pumpSetpoints.data.stopLevel",
186
+ "direction": "in"
187
+ }</pre>
188
+
189
+ <h3>Config Name</h3>
190
+
191
+ <p>
192
+ Leave <b>Config Name</b> blank to allow all config blocks.
193
+ </p>
194
+
195
+ <p>
196
+ Enter a name such as <code>mainScheduler</code> if this node should only use one config block.
197
+ </p>
198
+
199
+ <h3>Outputs</h3>
200
+
201
+ <p>
202
+ The node has 24 outputs.
203
+ </p>
204
+
205
+ <p>
206
+ Scheduler and control items use output names such as:
207
+ </p>
208
+
209
+ <pre>output1
210
+ output2
211
+ output3</pre>
212
+
213
+ <p>
214
+ If multiple schedule/control entries use the same output, they are combined using OR logic.
215
+ If any entry wants the output ON, the final output is ON.
216
+ </p>
217
+
218
+ <h3>Local Storage</h3>
219
+
220
+ <p>
221
+ Config is saved locally on the gateway. If Node-RED restarts, the last saved config is loaded again.
222
+ </p>
223
+
224
+ <h3>Output Mode</h3>
225
+
226
+ <ul>
227
+ <li><b>Only when output changes</b>: sends an output only when the final state changes.</li>
228
+ <li><b>Every check</b>: sends outputs every check cycle.</li>
229
+ </ul>
230
+
231
+ <h3>Config Output</h3>
232
+
233
+ <p>
234
+ Optional. Select an output if you want the full stored config sent out as a message.
235
+ </p>
236
+ </script>
@@ -0,0 +1,883 @@
1
+ module.exports = function (RED) {
2
+ "use strict";
3
+
4
+ var fs = require("fs");
5
+ var path = require("path");
6
+
7
+ function asBool(value) {
8
+ return value === true || value === "true" || value === 1 || value === "1";
9
+ }
10
+
11
+ function asNumber(value) {
12
+ var n = parseFloat(value);
13
+ return Number.isNaN(n) ? NaN : n;
14
+ }
15
+
16
+ function clampNumber(value, min, max, fallback) {
17
+ var n = parseInt(value, 10);
18
+
19
+ if (Number.isNaN(n)) {
20
+ return fallback;
21
+ }
22
+
23
+ return Math.max(min, Math.min(n, max));
24
+ }
25
+
26
+ function safeParseJson(value) {
27
+ if (typeof value !== "string") {
28
+ return value;
29
+ }
30
+
31
+ try {
32
+ return JSON.parse(value);
33
+ } catch (err) {
34
+ return value;
35
+ }
36
+ }
37
+
38
+ function hasOwn(obj, key) {
39
+ return Object.prototype.hasOwnProperty.call(obj, key);
40
+ }
41
+
42
+ function getNestedValue(obj, key) {
43
+ if (!obj || key === null || key === undefined || key === "") {
44
+ return undefined;
45
+ }
46
+
47
+ if (typeof obj !== "object") {
48
+ return undefined;
49
+ }
50
+
51
+ if (hasOwn(obj, key)) {
52
+ return obj[key];
53
+ }
54
+
55
+ var current = obj;
56
+ var parts = String(key).split(".");
57
+
58
+ for (var i = 0; i < parts.length; i += 1) {
59
+ var part = parts[i];
60
+
61
+ if (!current || typeof current !== "object" || !hasOwn(current, part)) {
62
+ return undefined;
63
+ }
64
+
65
+ current = current[part];
66
+ }
67
+
68
+ return current;
69
+ }
70
+
71
+ function makeDir(dir) {
72
+ try {
73
+ if (!fs.existsSync(dir)) {
74
+ fs.mkdirSync(dir, { recursive: true });
75
+ }
76
+ } catch (err) {
77
+ try {
78
+ fs.mkdirSync(dir);
79
+ } catch (err2) {
80
+ throw err;
81
+ }
82
+ }
83
+ }
84
+
85
+ function dateKey(date) {
86
+ return (
87
+ date.getFullYear() + "-" +
88
+ (date.getMonth() + 1) + "-" +
89
+ date.getDate()
90
+ );
91
+ }
92
+
93
+ function outputNumberFromKey(outputKey, fallbackIndex) {
94
+ var match = String(outputKey || "").match(/output(\d+)/i);
95
+
96
+ if (match) {
97
+ var number = parseInt(match[1], 10);
98
+
99
+ if (number >= 1 && number <= 24) {
100
+ return number;
101
+ }
102
+ }
103
+
104
+ return Math.max(1, Math.min(fallbackIndex + 1, 24));
105
+ }
106
+
107
+ function normaliseMode(value, fallback) {
108
+ if (value === null || value === undefined || value === "") {
109
+ return fallback;
110
+ }
111
+
112
+ return String(value);
113
+ }
114
+
115
+ function isManualOn(mode) {
116
+ return String(mode).toUpperCase() === "ON";
117
+ }
118
+
119
+ function isManualOff(mode) {
120
+ return String(mode).toUpperCase() === "OFF";
121
+ }
122
+
123
+ function isAutoSchedule(mode) {
124
+ var text = String(mode || "").toLowerCase();
125
+ return text === "auto schedule" || text === "auto" || text === "schedule";
126
+ }
127
+
128
+ function isSchedulerType(type) {
129
+ var text = String(type || "").toLowerCase();
130
+ return text === "scheduler" || text === "schedule" || text === "consched" || text === "control-schedule";
131
+ }
132
+
133
+ function ThreeDMConfigStoreNode(config) {
134
+ RED.nodes.createNode(this, config);
135
+
136
+ var node = this;
137
+
138
+ node.name = config.name;
139
+ node.configName = String(config.configName || "").trim();
140
+ node.tickSeconds = clampNumber(config.tickSeconds || 20, 1, 60, 20);
141
+ node.outputMode = config.outputMode || "change";
142
+ node.configOutput = clampNumber(config.configOutput || 0, 0, 24, 0);
143
+ node.emitConfigOnUpdate = asBool(config.emitConfigOnUpdate);
144
+
145
+ var userDir = RED.settings && RED.settings.userDir
146
+ ? RED.settings.userDir
147
+ : process.cwd();
148
+
149
+ var storeDir = path.join(userDir, "3dm-config-store");
150
+ var storeFile = path.join(storeDir, "3dm-config-store-" + node.id + ".json");
151
+
152
+ var closed = false;
153
+ var latestPayload = {};
154
+ var latestStore = {};
155
+ var outputStates = {};
156
+ var stoppedScheduleRuns = new Map();
157
+ var controlItemStates = new Map();
158
+ var lastStatusText = "";
159
+ var lastWarnText = "";
160
+ var lastWarnTime = 0;
161
+
162
+ function warnOncePerMinute(text) {
163
+ var now = Date.now();
164
+
165
+ if (text !== lastWarnText || (now - lastWarnTime) > 60000) {
166
+ lastWarnText = text;
167
+ lastWarnTime = now;
168
+ node.warn(text);
169
+ }
170
+ }
171
+
172
+ function setStatus(fill, shape, text) {
173
+ var shortText = String(text || "");
174
+
175
+ if (shortText.length > 90) {
176
+ shortText = shortText.slice(0, 87) + "...";
177
+ }
178
+
179
+ var key = fill + "|" + shape + "|" + shortText;
180
+
181
+ if (lastStatusText !== key) {
182
+ lastStatusText = key;
183
+ node.status({ fill: fill, shape: shape, text: shortText });
184
+ }
185
+ }
186
+
187
+ function saveToDisk() {
188
+ try {
189
+ makeDir(storeDir);
190
+
191
+ fs.writeFileSync(
192
+ storeFile,
193
+ JSON.stringify({
194
+ version: 1,
195
+ updatedAt: new Date().toISOString(),
196
+ configStore: latestStore
197
+ }, null, 2)
198
+ );
199
+ } catch (err) {
200
+ warnOncePerMinute("Failed to write 3DM config store to disk: " + (err.message || err));
201
+ }
202
+ }
203
+
204
+ function loadFromDisk() {
205
+ try {
206
+ if (!fs.existsSync(storeFile)) {
207
+ latestStore = {};
208
+ return;
209
+ }
210
+
211
+ var data = JSON.parse(fs.readFileSync(storeFile, "utf8"));
212
+
213
+ if (data && typeof data === "object" && data.configStore && typeof data.configStore === "object") {
214
+ latestStore = data.configStore;
215
+ } else if (data && typeof data === "object") {
216
+ latestStore = data;
217
+ } else {
218
+ latestStore = {};
219
+ }
220
+ } catch (err) {
221
+ latestStore = {};
222
+ warnOncePerMinute("Failed to load saved 3DM config store: " + (err.message || err));
223
+ }
224
+ }
225
+
226
+ function shouldAcceptConfigName(configName) {
227
+ if (!node.configName) {
228
+ return true;
229
+ }
230
+
231
+ return String(configName) === node.configName;
232
+ }
233
+
234
+ function inferBlockType(configName, block) {
235
+ if (block && typeof block === "object" && !Array.isArray(block) && block.type) {
236
+ return String(block.type);
237
+ }
238
+
239
+ if (Array.isArray(block)) {
240
+ return "scheduler";
241
+ }
242
+
243
+ if (block && typeof block === "object" && Array.isArray(block.schedule)) {
244
+ return "scheduler";
245
+ }
246
+
247
+ if (String(configName || "").toLowerCase().indexOf("sched") !== -1) {
248
+ return "scheduler";
249
+ }
250
+
251
+ return "settings";
252
+ }
253
+
254
+ function normaliseConfigBlock(configName, block) {
255
+ var type = inferBlockType(configName, block);
256
+ var data = block;
257
+
258
+ if (block && typeof block === "object" && !Array.isArray(block) && hasOwn(block, "data")) {
259
+ data = block.data;
260
+ } else if (block && typeof block === "object" && !Array.isArray(block) && Array.isArray(block.schedule)) {
261
+ data = block.schedule;
262
+ }
263
+
264
+ return {
265
+ name: configName,
266
+ type: type,
267
+ data: data,
268
+ updatedAt: new Date().toISOString()
269
+ };
270
+ }
271
+
272
+ function extractConfigStore(msg) {
273
+ var payload = safeParseJson(msg.payload);
274
+
275
+ if (payload && typeof payload === "object" && hasOwn(payload, "configStore")) {
276
+ return payload.configStore;
277
+ }
278
+
279
+ if (msg.key === "configStore" || msg.topic === "configStore") {
280
+ return payload;
281
+ }
282
+
283
+ if (payload && typeof payload === "object" && payload.shared && hasOwn(payload.shared, "configStore")) {
284
+ return payload.shared.configStore;
285
+ }
286
+
287
+ if (payload && typeof payload === "object" && payload.client && hasOwn(payload.client, "configStore")) {
288
+ return payload.client.configStore;
289
+ }
290
+
291
+ return null;
292
+ }
293
+
294
+ function storeConfig(configStore) {
295
+ var savedNames = [];
296
+
297
+ if (!configStore || typeof configStore !== "object" || Array.isArray(configStore)) {
298
+ return savedNames;
299
+ }
300
+
301
+ Object.keys(configStore).forEach(function (configName) {
302
+ if (!shouldAcceptConfigName(configName)) {
303
+ return;
304
+ }
305
+
306
+ latestStore[configName] = normaliseConfigBlock(configName, configStore[configName]);
307
+ savedNames.push(configName);
308
+ });
309
+
310
+ if (savedNames.length > 0) {
311
+ saveToDisk();
312
+ }
313
+
314
+ return savedNames;
315
+ }
316
+
317
+ function storedConfigValue(key) {
318
+ if (!key) {
319
+ return undefined;
320
+ }
321
+
322
+ var cleanKey = String(key);
323
+
324
+ var direct = getNestedValue({ configStore: latestStore }, cleanKey);
325
+
326
+ if (direct !== undefined) {
327
+ return direct;
328
+ }
329
+
330
+ direct = getNestedValue(latestStore, cleanKey);
331
+
332
+ if (direct !== undefined) {
333
+ return direct;
334
+ }
335
+
336
+ var names = Object.keys(latestStore);
337
+
338
+ for (var i = 0; i < names.length; i += 1) {
339
+ var block = latestStore[names[i]];
340
+
341
+ if (!block) {
342
+ continue;
343
+ }
344
+
345
+ direct = getNestedValue(block, cleanKey);
346
+
347
+ if (direct !== undefined) {
348
+ return direct;
349
+ }
350
+
351
+ direct = getNestedValue(block.data, cleanKey);
352
+
353
+ if (direct !== undefined) {
354
+ return direct;
355
+ }
356
+ }
357
+
358
+ return undefined;
359
+ }
360
+
361
+ function resolveValue(key) {
362
+ var value = getNestedValue(latestPayload, key);
363
+
364
+ if (value !== undefined) {
365
+ return value;
366
+ }
367
+
368
+ return storedConfigValue(key);
369
+ }
370
+
371
+ function resolveSetpoint(item, valueKeys, fixedKeys) {
372
+ for (var i = 0; i < valueKeys.length; i += 1) {
373
+ var valueKeyName = valueKeys[i];
374
+
375
+ if (item[valueKeyName] !== undefined && item[valueKeyName] !== null && item[valueKeyName] !== "") {
376
+ var resolved = resolveValue(item[valueKeyName]);
377
+
378
+ if (resolved !== undefined) {
379
+ return resolved;
380
+ }
381
+ }
382
+ }
383
+
384
+ for (var j = 0; j < fixedKeys.length; j += 1) {
385
+ var fixedKeyName = fixedKeys[j];
386
+
387
+ if (item[fixedKeyName] !== undefined && item[fixedKeyName] !== null && item[fixedKeyName] !== "") {
388
+ return item[fixedKeyName];
389
+ }
390
+ }
391
+
392
+ return undefined;
393
+ }
394
+
395
+ function itemMemoryKey(item, index, outputKey, configName) {
396
+ var explicitId = item.id || item.scheduleId || item.controlId || item.uid || item.uuid;
397
+
398
+ if (explicitId !== undefined && explicitId !== null && String(explicitId) !== "") {
399
+ return String(configName || "config") + "::" + String(item.controlType || "schedule") + "::" + String(explicitId);
400
+ }
401
+
402
+ var variableName = item.scheduleVariable || item.variable || item.key || item.keyName || "";
403
+
404
+ return JSON.stringify({
405
+ configName: configName || "",
406
+ type: item.controlType || "schedule",
407
+ output: outputKey,
408
+ name: item.name || "",
409
+ days: item.days || [],
410
+ startHour: item.startHour || "",
411
+ startMinute: item.startMinute || "",
412
+ runHour: item.runHour || "",
413
+ runMinute: item.runMinute || "",
414
+ variable: variableName,
415
+ direction: item.direction || "",
416
+ lowSetpoint: item.lowSetpoint || "",
417
+ highSetpoint: item.highSetpoint || "",
418
+ scheduleHighSetpoint: item.scheduleHighSetpoint || "",
419
+ index: index
420
+ });
421
+ }
422
+
423
+ function currentScheduleRun(item, now) {
424
+ var days = item.days || [];
425
+
426
+ if (typeof days === "string") {
427
+ days = days.split(",").map(function (day) {
428
+ return day.trim().toLowerCase();
429
+ });
430
+ }
431
+
432
+ var startHour = parseInt(item.startHour || 0, 10);
433
+ var startMinute = parseInt(item.startMinute || 0, 10);
434
+ var runHour = parseInt(item.runHour || 0, 10);
435
+ var runMinute = parseInt(item.runMinute || 0, 10);
436
+
437
+ var startMinutes = startHour * 60 + startMinute;
438
+ var runMinutes = runHour * 60 + runMinute;
439
+
440
+ if (runMinutes <= 0) {
441
+ return null;
442
+ }
443
+
444
+ var dayNames = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
445
+ var candidates = [new Date(now), new Date(now)];
446
+
447
+ candidates[1].setDate(candidates[1].getDate() - 1);
448
+
449
+ for (var i = 0; i < candidates.length; i += 1) {
450
+ var startDay = candidates[i];
451
+ var dayName = dayNames[startDay.getDay()];
452
+
453
+ if (days.length > 0 && days.indexOf(dayName) === -1) {
454
+ continue;
455
+ }
456
+
457
+ var start = new Date(startDay);
458
+ start.setHours(Math.floor(startMinutes / 60), startMinutes % 60, 0, 0);
459
+
460
+ var end = new Date(start.getTime() + runMinutes * 60000);
461
+
462
+ if (now >= start && now < end) {
463
+ return dateKey(start) + "@" + startMinutes;
464
+ }
465
+ }
466
+
467
+ return null;
468
+ }
469
+
470
+ function scheduleHighStopEnabled(item) {
471
+ return asBool(item.highStopEnabled);
472
+ }
473
+
474
+ function calculateScheduleState(item, itemKey) {
475
+ var mode = normaliseMode(item.mode, "Auto Schedule");
476
+
477
+ if (isManualOn(mode)) {
478
+ return 1;
479
+ }
480
+
481
+ if (isManualOff(mode)) {
482
+ return 0;
483
+ }
484
+
485
+ if (!isAutoSchedule(mode)) {
486
+ return null;
487
+ }
488
+
489
+ var runId = currentScheduleRun(item, new Date());
490
+
491
+ if (!runId) {
492
+ stoppedScheduleRuns.delete(itemKey);
493
+ return 0;
494
+ }
495
+
496
+ if (!scheduleHighStopEnabled(item)) {
497
+ return 1;
498
+ }
499
+
500
+ if (stoppedScheduleRuns.get(itemKey) === runId) {
501
+ return 0;
502
+ }
503
+
504
+ var keyName = item.scheduleVariable || item.variable || item.key || item.keyName;
505
+ var value = asNumber(resolveValue(keyName));
506
+
507
+ var highStopValue = resolveSetpoint(
508
+ item,
509
+ ["scheduleHighSetpointKey", "highStopKey", "highSetpointKey"],
510
+ ["scheduleHighSetpoint", "highStop", "highSetpoint"]
511
+ );
512
+
513
+ var highStop = asNumber(highStopValue);
514
+
515
+ if (Number.isNaN(value) || Number.isNaN(highStop)) {
516
+ return 1;
517
+ }
518
+
519
+ if (value >= highStop) {
520
+ stoppedScheduleRuns.set(itemKey, runId);
521
+ return 0;
522
+ }
523
+
524
+ return 1;
525
+ }
526
+
527
+ function calculateControlState(item, itemKey, outputKey) {
528
+ var mode = normaliseMode(item.mode, "AUTO");
529
+
530
+ if (isManualOn(mode)) {
531
+ controlItemStates.set(itemKey, 1);
532
+ return 1;
533
+ }
534
+
535
+ if (isManualOff(mode)) {
536
+ controlItemStates.set(itemKey, 0);
537
+ return 0;
538
+ }
539
+
540
+ var keyName = item.variable || item.key || item.keyName;
541
+ var value = asNumber(resolveValue(keyName));
542
+
543
+ var low = asNumber(resolveSetpoint(
544
+ item,
545
+ ["lowSetpointKey", "lowKey", "startSetpointKey"],
546
+ ["lowSetpoint", "low", "startSetpoint"]
547
+ ));
548
+
549
+ var high = asNumber(resolveSetpoint(
550
+ item,
551
+ ["highSetpointKey", "highKey", "stopSetpointKey"],
552
+ ["highSetpoint", "high", "stopSetpoint"]
553
+ ));
554
+
555
+ if (Number.isNaN(value) || Number.isNaN(low) || Number.isNaN(high)) {
556
+ return null;
557
+ }
558
+
559
+ var lastState = controlItemStates.has(itemKey)
560
+ ? controlItemStates.get(itemKey)
561
+ : (outputStates[outputKey] || 0);
562
+
563
+ var newState = lastState;
564
+ var direction = item.direction || "in";
565
+
566
+ if (direction === "in" || direction === "fill" || direction === "normal") {
567
+ if (value <= low) {
568
+ newState = 1;
569
+ }
570
+
571
+ if (value >= high) {
572
+ newState = 0;
573
+ }
574
+ } else {
575
+ if (value >= high) {
576
+ newState = 1;
577
+ }
578
+
579
+ if (value <= low) {
580
+ newState = 0;
581
+ }
582
+ }
583
+
584
+ controlItemStates.set(itemKey, newState);
585
+ return newState;
586
+ }
587
+
588
+ function getSchedulerItems() {
589
+ var items = [];
590
+ var configNames = Object.keys(latestStore);
591
+
592
+ configNames.forEach(function (configName) {
593
+ if (!shouldAcceptConfigName(configName)) {
594
+ return;
595
+ }
596
+
597
+ var block = latestStore[configName];
598
+
599
+ if (!block || !isSchedulerType(block.type)) {
600
+ return;
601
+ }
602
+
603
+ var list = [];
604
+
605
+ if (Array.isArray(block.data)) {
606
+ list = block.data;
607
+ } else if (block.data && typeof block.data === "object" && Array.isArray(block.data.schedule)) {
608
+ list = block.data.schedule;
609
+ }
610
+
611
+ list.forEach(function (item) {
612
+ if (item && typeof item === "object") {
613
+ items.push({
614
+ configName: configName,
615
+ item: item
616
+ });
617
+ }
618
+ });
619
+ });
620
+
621
+ return items;
622
+ }
623
+
624
+ function sendConfigSnapshot(eventName) {
625
+ if (node.configOutput < 1 || node.configOutput > 24) {
626
+ return;
627
+ }
628
+
629
+ var outputs = new Array(24).fill(null);
630
+
631
+ outputs[node.configOutput - 1] = {
632
+ topic: "configStore",
633
+ event: eventName || "configStore",
634
+ payload: {
635
+ configStore: latestStore
636
+ },
637
+ configStore: latestStore
638
+ };
639
+
640
+ node.send(outputs);
641
+ }
642
+
643
+ function runConfigStore(isOffline, forceOutput) {
644
+ if (closed) {
645
+ return;
646
+ }
647
+
648
+ var outputs = new Array(24).fill(null);
649
+ var statusSummary = [];
650
+ var outputGroups = new Map();
651
+ var activeItemKeys = new Set();
652
+ var schedulerItems = getSchedulerItems();
653
+
654
+ schedulerItems.forEach(function (entry, index) {
655
+ var item = entry.item;
656
+ var configName = entry.configName;
657
+ var outputKey = item.output || ("output" + (index + 1));
658
+ var outputNumber = outputNumberFromKey(outputKey, index);
659
+ var itemKey = itemMemoryKey(item, index, outputKey, configName);
660
+
661
+ item.output = outputKey;
662
+ activeItemKeys.add(itemKey);
663
+
664
+ var newState = item.controlType === "control"
665
+ ? calculateControlState(item, itemKey, outputKey)
666
+ : calculateScheduleState(item, itemKey);
667
+
668
+ if (newState === null || newState === undefined) {
669
+ statusSummary.push(outputKey + ": " + (item.name || "item") + " skipped");
670
+ return;
671
+ }
672
+
673
+ if (!outputGroups.has(outputKey)) {
674
+ outputGroups.set(outputKey, {
675
+ outputKey: outputKey,
676
+ outputNumber: outputNumber,
677
+ states: [],
678
+ names: [],
679
+ controlTypes: new Set()
680
+ });
681
+ }
682
+
683
+ var group = outputGroups.get(outputKey);
684
+
685
+ group.states.push(Number(newState) === 1 ? 1 : 0);
686
+ group.names.push(item.name || outputKey);
687
+ group.controlTypes.add(item.controlType || "schedule");
688
+ });
689
+
690
+ stoppedScheduleRuns.forEach(function (_value, key) {
691
+ if (!activeItemKeys.has(key)) {
692
+ stoppedScheduleRuns.delete(key);
693
+ }
694
+ });
695
+
696
+ controlItemStates.forEach(function (_value, key) {
697
+ if (!activeItemKeys.has(key)) {
698
+ controlItemStates.delete(key);
699
+ }
700
+ });
701
+
702
+ outputGroups.forEach(function (group) {
703
+ var activeRequests = group.states.filter(function (state) {
704
+ return state === 1;
705
+ }).length;
706
+
707
+ var combinedState = activeRequests > 0 ? 1 : 0;
708
+ var lastState = outputStates[group.outputKey];
709
+
710
+ if (node.outputMode === "always" || forceOutput || lastState !== combinedState) {
711
+ outputStates[group.outputKey] = combinedState;
712
+
713
+ var controlType = group.controlTypes.size === 1
714
+ ? Array.from(group.controlTypes)[0]
715
+ : "combined";
716
+
717
+ outputs[group.outputNumber - 1] = {
718
+ payload: Boolean(combinedState),
719
+ topic: group.outputKey,
720
+ output: group.outputKey,
721
+ outputNumber: group.outputNumber,
722
+ controlType: controlType,
723
+ activeRequests: activeRequests,
724
+ totalRequests: group.states.length,
725
+ name: group.names.length === 1
726
+ ? group.names[0]
727
+ : group.outputKey + " (" + group.names.length + " commands)"
728
+ };
729
+ }
730
+
731
+ statusSummary.push(
732
+ group.outputKey + ": " +
733
+ (combinedState ? "ON" : "OFF") +
734
+ " (" + activeRequests + "/" + group.states.length + " active)"
735
+ );
736
+ });
737
+
738
+ if (closed) {
739
+ return;
740
+ }
741
+
742
+ if (statusSummary.length === 0) {
743
+ var names = Object.keys(latestStore);
744
+
745
+ if (names.length === 0) {
746
+ setStatus("yellow", "ring", "waiting for configStore");
747
+ } else {
748
+ setStatus("blue", "dot", "config stored: " + names.join(", "));
749
+ }
750
+
751
+ return;
752
+ }
753
+
754
+ node.send(outputs);
755
+
756
+ setStatus(
757
+ isOffline ? "yellow" : "blue",
758
+ "dot",
759
+ (isOffline ? "Offline: " : "") + statusSummary.join(" | ")
760
+ );
761
+ }
762
+
763
+ function storeLatestPayload(msg) {
764
+ var payload = safeParseJson(msg.payload);
765
+
766
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
767
+ latestPayload = payload;
768
+ } else {
769
+ latestPayload = {};
770
+ }
771
+ }
772
+
773
+ function handleGetRequest(msg) {
774
+ var payload = safeParseJson(msg.payload);
775
+
776
+ if (msg.getConfigStore === true) {
777
+ sendConfigSnapshot("requested");
778
+ return true;
779
+ }
780
+
781
+ if (payload && typeof payload === "object") {
782
+ if (payload.getConfigStore === true || payload.configStoreGet === true) {
783
+ sendConfigSnapshot("requested");
784
+ return true;
785
+ }
786
+ }
787
+
788
+ return false;
789
+ }
790
+
791
+ loadFromDisk();
792
+
793
+ if (Object.keys(latestStore).length > 0) {
794
+ setStatus("blue", "dot", "loaded saved config");
795
+ } else {
796
+ setStatus("yellow", "ring", "waiting for configStore");
797
+ }
798
+
799
+ var timer = setInterval(function () {
800
+ runConfigStore(false, false);
801
+ }, node.tickSeconds * 1000);
802
+
803
+ setTimeout(function () {
804
+ runConfigStore(false, true);
805
+ }, 500);
806
+
807
+ node.on("input", function (msg, send, done) {
808
+ send = send || function () {
809
+ node.send.apply(node, arguments);
810
+ };
811
+
812
+ if (closed) {
813
+ if (done) {
814
+ done();
815
+ }
816
+
817
+ return;
818
+ }
819
+
820
+ try {
821
+ if (handleGetRequest(msg)) {
822
+ if (done) {
823
+ done();
824
+ }
825
+
826
+ return;
827
+ }
828
+
829
+ var configStore = extractConfigStore(msg);
830
+
831
+ if (configStore) {
832
+ var savedNames = storeConfig(configStore);
833
+
834
+ if (savedNames.length > 0) {
835
+ setStatus("blue", "dot", "saved: " + savedNames.join(", "));
836
+
837
+ if (node.emitConfigOnUpdate) {
838
+ sendConfigSnapshot("updated");
839
+ }
840
+
841
+ runConfigStore(false, true);
842
+ } else {
843
+ setStatus("yellow", "ring", "configStore ignored");
844
+ }
845
+
846
+ if (done) {
847
+ done();
848
+ }
849
+
850
+ return;
851
+ }
852
+
853
+ storeLatestPayload(msg);
854
+ runConfigStore(false, false);
855
+
856
+ if (done) {
857
+ done();
858
+ }
859
+ } catch (err) {
860
+ setStatus("red", "ring", "config store error");
861
+ node.error("3DM Config Store error: " + (err.message || err), msg);
862
+
863
+ if (done) {
864
+ done(err);
865
+ }
866
+ }
867
+ });
868
+
869
+ node.on("close", function (removed, done) {
870
+ if (typeof removed === "function") {
871
+ done = removed;
872
+ }
873
+
874
+ done = done || function () {};
875
+
876
+ closed = true;
877
+ clearInterval(timer);
878
+ done();
879
+ });
880
+ }
881
+
882
+ RED.nodes.registerType("3dm-config-store", ThreeDMConfigStoreNode);
883
+ };
package/README.md CHANGED
@@ -2,52 +2,238 @@
2
2
 
3
3
  Node-RED nodes for connecting Smithtek PassPort gateways to **3DM.space SCADA Cloud**.
4
4
 
5
- This package is designed to make 3DM.space cloud communication simple for users. The cloud connection, formatting, timing, storage and recovery handling are managed inside the nodes.
6
-
7
- Users only need to copy the device credentials from 3DM.space into Node-RED.
8
-
9
- ---
10
-
11
- ## What This Package Does
12
-
13
- This package allows a PassPort gateway to:
14
-
15
- - Send telemetry values to 3DM.space
16
- - Receive cloud commands or control updates from 3DM.space
17
- - Store telemetry locally if the cloud connection is unavailable
18
- - Automatically send stored telemetry when the connection returns
19
-
20
- ---
5
+ This package is designed to keep setup simple. Users copy the device details from 3DM.space into Node-RED, then use the nodes to send data, receive commands, store settings, and run local control logic.
21
6
 
22
7
  ## Nodes Included
23
8
 
24
9
  ### 3DM Cloud Login
25
10
 
26
- The shared login node used by the 3DM input and output nodes.
11
+ Stores the 3DM.space device login used by the 3DM nodes.
27
12
 
28
- The settings match the credential order shown in 3DM.space:
13
+ The fields match the order shown in 3DM.space:
29
14
 
30
15
  1. **Client ID**
31
16
  2. **User Name***
32
17
  3. **Password**
33
18
  4. **Node Name**
34
19
 
35
- The **Node Name** is only used inside Node-RED to make the login easier to identify.
36
-
37
- ---
20
+ **Node Name** is optional and is only used inside Node-RED to make the login easier to identify.
38
21
 
39
22
  ### 3DM Out
40
23
 
41
- Sends telemetry data from Node-RED to 3DM.space.
24
+ Sends telemetry from Node-RED to 3DM.space.
25
+
26
+ Use this node when the PassPort needs to send live values such as:
27
+
28
+ - Tank level
29
+ - Pump status
30
+ - Flow rate
31
+ - Pressure
32
+ - Battery voltage
33
+ - Fault status
34
+ - Run hours
35
+ - Totaliser values
36
+
37
+ The node automatically prepares the data for 3DM.space.
38
+
39
+ If the connection is unavailable, valid data is stored locally and sent later when the connection returns.
40
+
41
+ ### 3DM In
42
+
43
+ Receives values sent from 3DM.space to the PassPort.
44
+
45
+ Use this node when 3DM.space needs to send values down to Node-RED, such as:
46
+
47
+ - Pump commands
48
+ - Mode changes
49
+ - Reset commands
50
+ - Setpoints
51
+ - Scheduler updates
52
+ - Control settings
53
+ - Remote configuration values
54
+
55
+ The node can receive all values, or it can be filtered to only pass through one selected key.
56
+
57
+ ### 3DM Config Store
58
+
59
+ Stores settings sent from 3DM.space and uses them locally on the PassPort.
60
+
61
+ This node is normally placed after a **3DM In** node.
62
+
63
+ 3DM In → 3DM Config Store → Local Outputs
64
+
65
+ Use this node for settings that need to be remembered and used by the PassPort, such as:
66
+
67
+ - Pump schedules
68
+ - Irrigation schedules
69
+ - Start and stop levels
70
+ - Pressure setpoints
71
+ - Alarm settings
72
+ - Control limits
73
+ - Run time settings
74
+ - Site configuration values
75
+
76
+ The Config Store saves the latest settings locally.
77
+
78
+ If Node-RED restarts, the last saved settings are loaded again automatically.
79
+
80
+ The node has **24 outputs** that can be used for local control.
81
+
82
+ ## Basic Setup
83
+
84
+ ### 1. Create or Open a Device in 3DM.space
85
+
86
+ Log in to **3DM.space**.
87
+
88
+ Create a new device, or open the existing device you want the PassPort to connect to.
89
+
90
+ Open the device credentials section.
91
+
92
+ Copy these values:
93
+
94
+ - Client ID
95
+ - User Name*
96
+ - Password
97
+
98
+ ### 2. Add a 3DM Cloud Login in Node-RED
99
+
100
+ Open a **3DM Out** or **3DM In** node.
101
+
102
+ Create a new **3DM Cloud Login**.
103
+
104
+ Paste the values from 3DM.space into the matching fields.
105
+
106
+ | 3DM.space | Node-RED |
107
+ |---|---|
108
+ | Client ID | Client ID |
109
+ | User Name* | User Name* |
110
+ | Password | Password |
111
+
112
+ Give it a simple Node Name if needed, such as:
113
+
114
+ Main PassPort
115
+
116
+ ### 3. Send Telemetry
117
+
118
+ Add a **3DM Out** node.
119
+
120
+ Select the **3DM Cloud Login**.
121
+
122
+ Connect your live data into the node.
123
+
124
+ The values will appear against the selected device in 3DM.space.
125
+
126
+ ### 4. Receive Commands
127
+
128
+ Add a **3DM In** node.
129
+
130
+ Select the same **3DM Cloud Login**.
131
+
132
+ Leave **Attribute Key** blank to receive all incoming values.
133
+
134
+ Enter an **Attribute Key** if you only want one value to pass through.
135
+
136
+ For example, you may use a key such as:
137
+
138
+ pump_cmd
139
+
140
+ Only that value will then be passed through the node.
141
+
142
+ ### 5. Store Settings
143
+
144
+ Add a **3DM Config Store** node after a **3DM In** node.
145
+
146
+ Use this when settings from 3DM.space need to be saved on the PassPort.
147
+
148
+ This is useful for:
149
+
150
+ - Scheduler widgets
151
+ - Setpoint widgets
152
+ - Control widgets
153
+ - Site setup widgets
154
+ - Remote configuration screens
155
+
156
+ The Config Store saves the settings locally and keeps using them even after a restart.
157
+
158
+ ### 6. Run Local Outputs
159
+
160
+ The **3DM Config Store** node has 24 outputs.
161
+
162
+ These outputs can be used to control local logic in Node-RED.
163
+
164
+ Typical uses include:
165
+
166
+ - Starting a pump on schedule
167
+ - Stopping a pump on level
168
+ - Running irrigation zones
169
+ - Enabling or disabling control logic
170
+ - Sending commands to a PLC
171
+ - Triggering local relays or outputs
172
+
173
+ ## Store and Forward
174
+
175
+ The **3DM Out** node includes built-in store and forward.
176
+
177
+ If the cloud connection is unavailable, valid telemetry is stored locally on the gateway.
178
+
179
+ When the connection returns, stored telemetry is sent automatically.
180
+
181
+ This helps prevent data loss during temporary connection outages.
182
+
183
+ ## Safe Sending Limits
184
+
185
+ The **3DM Out** node includes safe sending protection.
186
+
187
+ This helps prevent users from accidentally sending data too quickly.
188
+
189
+ Stored data is handled automatically when the connection returns.
190
+
191
+ ## Typical Flows
192
+
193
+ Sending telemetry:
194
+
195
+ Sensor / PLC Data
196
+
197
+ Function Node
198
+
199
+ 3DM Out
200
+
201
+ 3DM.space
202
+
203
+ Receiving commands:
204
+
205
+ 3DM.space
206
+
207
+ 3DM In
208
+
209
+ Function Node
210
+
211
+ PLC / Output Logic
212
+
213
+ Receiving saved settings:
214
+
215
+ 3DM.space
216
+
217
+ 3DM In
218
+
219
+ 3DM Config Store
220
+
221
+ 24 Local Outputs
222
+
223
+ ## Notes
224
+
225
+ - Use **3DM Out** to send live data to 3DM.space.
226
+ - Use **3DM In** to receive commands and settings from 3DM.space.
227
+ - Use **3DM Config Store** to save settings and run local control logic.
228
+ - Device credentials should be copied directly from 3DM.space.
229
+ - Node Name fields are optional and are only used inside Node-RED.
230
+ - Store and forward is handled automatically.
231
+ - Saved settings are stored locally on the PassPort.
232
+
233
+ ## Author
42
234
 
43
- The input must be a JSON object.
235
+ Smithtek
44
236
 
45
- Example:
237
+ ## License
46
238
 
47
- ```json
48
- {
49
- "pump_run": true,
50
- "flow_lpm": 125.5,
51
- "tank_level_percent": 64,
52
- "dc_voltage": 24.1
53
- }
239
+ GPL-3.0-or-later
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "node-red-contrib-3dm-space",
3
- "version": "1.0.0",
4
- "description": "Node-RED nodes for 3DM.space SCADA Cloud telemetry and attributes",
3
+ "version": "1.0.2",
4
+ "description": "Node-RED nodes for 3DM.space SCADA Cloud telemetry, attributes, and local config storage",
5
5
  "main": "3dm-cloud-config.js",
6
6
  "license": "GPL-3.0-or-later",
7
7
  "keywords": [
@@ -12,13 +12,16 @@
12
12
  "smithtek",
13
13
  "scada",
14
14
  "telemetry",
15
- "store-and-forward"
15
+ "store-and-forward",
16
+ "config-store",
17
+ "scheduler"
16
18
  ],
17
19
  "node-red": {
18
20
  "nodes": {
19
21
  "3dm-cloud-config": "3dm-cloud-config.js",
20
22
  "3dm-cloud-out": "3dm-cloud-out.js",
21
- "3dm-cloud-in": "3dm-cloud-in.js"
23
+ "3dm-cloud-in": "3dm-cloud-in.js",
24
+ "3dm-config-store": "3dm-config-store.js"
22
25
  }
23
26
  },
24
27
  "dependencies": {