node-red-contrib-3dm-space 1.0.5 → 1.0.10

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.
@@ -1,887 +1,163 @@
1
1
  module.exports = function (RED) {
2
2
  "use strict";
3
3
 
4
- var fs = require("fs");
5
- var path = require("path");
4
+ var mqtt = require("mqtt");
6
5
 
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
- }
6
+ var CLOUD_HOST = "3dm.space";
7
+ var CLOUD_PORT = 1883;
8
+ var CLOUD_URL = "mqtt://" + CLOUD_HOST + ":" + CLOUD_PORT;
22
9
 
23
- return Math.max(min, Math.min(n, max));
24
- }
10
+ var MQTT_KEEPALIVE = 60;
11
+ var MQTT_CLEAN_SESSION = true;
12
+ var MQTT_RECONNECT_MS = 5000;
13
+ var MQTT_CONNECT_TIMEOUT_MS = 15000;
14
+ var REPEAT_ERROR_LOG_MS = 60000;
25
15
 
26
- function safeParseJson(value) {
27
- if (typeof value !== "string") {
28
- return value;
16
+ function cleanText(value) {
17
+ if (value === null || value === undefined) {
18
+ return "";
29
19
  }
30
20
 
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);
21
+ return String(value).trim();
40
22
  }
41
23
 
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
- }
24
+ function ThreeDMCloudConfigNode(config) {
25
+ RED.nodes.createNode(this, config);
92
26
 
93
- function outputNumberFromKey(outputKey, fallbackIndex, maxOutputs) {
94
- maxOutputs = clampNumber(maxOutputs || 24, 1, 24, 24);
27
+ var node = this;
95
28
 
96
- var match = String(outputKey || "").match(/output(\d+)/i);
29
+ node.name = cleanText(config.name);
30
+ node.clientid = cleanText(config.clientid);
97
31
 
98
- if (match) {
99
- var number = parseInt(match[1], 10);
32
+ node.username = cleanText(config.username);
100
33
 
101
- if (number >= 1 && number <= maxOutputs) {
102
- return number;
103
- }
104
-
105
- return null;
34
+ if (!node.username && node.credentials && node.credentials.username) {
35
+ node.username = cleanText(node.credentials.username);
106
36
  }
107
37
 
108
- var fallbackNumber = fallbackIndex + 1;
38
+ node.password = node.credentials && node.credentials.password
39
+ ? cleanText(node.credentials.password)
40
+ : "";
109
41
 
110
- if (fallbackNumber >= 1 && fallbackNumber <= maxOutputs) {
111
- return fallbackNumber;
112
- }
42
+ node.connected = false;
43
+ node.client = null;
44
+ node.subscriptions = {};
113
45
 
114
- return null;
115
- }
116
-
117
- function normaliseMode(value, fallback) {
118
- if (value === null || value === undefined || value === "") {
119
- return fallback;
120
- }
46
+ node.lastErrorText = "";
47
+ node.lastErrorTime = 0;
121
48
 
122
- return String(value);
123
- }
124
-
125
- function isManualOn(mode) {
126
- return String(mode).toUpperCase() === "ON";
127
- }
128
-
129
- function isManualOff(mode) {
130
- return String(mode).toUpperCase() === "OFF";
131
- }
132
-
133
- function isAutoSchedule(mode) {
134
- var text = String(mode || "").toLowerCase();
135
- return text === "auto schedule" || text === "auto" || text === "schedule";
136
- }
137
-
138
- function isSchedulerType(type) {
139
- var text = String(type || "").toLowerCase();
140
- return text === "scheduler" || text === "schedule" || text === "consched" || text === "control-schedule";
141
- }
142
-
143
- function ThreeDMConfigStoreNode(config) {
144
- RED.nodes.createNode(this, config);
145
-
146
- var node = this;
147
-
148
- node.name = config.name;
149
- node.configName = String(config.configName || "").trim();
150
- node.outputCount = clampNumber(config.outputs || 24, 1, 24, 24);
151
- node.tickSeconds = clampNumber(config.tickSeconds || 20, 1, 60, 20);
152
- node.outputMode = config.outputMode || "change";
153
- node.configOutput = clampNumber(config.configOutput || 0, 0, node.outputCount, 0);
154
- node.emitConfigOnUpdate = asBool(config.emitConfigOnUpdate);
155
-
156
- var userDir = RED.settings && RED.settings.userDir
157
- ? RED.settings.userDir
158
- : process.cwd();
159
-
160
- var storeDir = path.join(userDir, "3dm-config-store");
161
- var storeFile = path.join(storeDir, "3dm-config-store-" + node.id + ".json");
162
-
163
- var closed = false;
164
- var latestPayload = {};
165
- var latestStore = {};
166
- var outputStates = {};
167
- var stoppedScheduleRuns = new Map();
168
- var controlItemStates = new Map();
169
- var lastStatusText = "";
170
- var lastWarnText = "";
171
- var lastWarnTime = 0;
49
+ node.setMaxListeners(0);
172
50
 
173
51
  function warnOncePerMinute(text) {
174
52
  var now = Date.now();
175
53
 
176
- if (text !== lastWarnText || (now - lastWarnTime) > 60000) {
177
- lastWarnText = text;
178
- lastWarnTime = now;
54
+ if (text !== node.lastErrorText || (now - node.lastErrorTime) > REPEAT_ERROR_LOG_MS) {
55
+ node.lastErrorText = text;
56
+ node.lastErrorTime = now;
179
57
  node.warn(text);
180
58
  }
181
59
  }
182
60
 
183
- function setStatus(fill, shape, text) {
184
- var shortText = String(text || "");
185
-
186
- if (shortText.length > 90) {
187
- shortText = shortText.slice(0, 87) + "...";
188
- }
189
-
190
- var key = fill + "|" + shape + "|" + shortText;
191
-
192
- if (lastStatusText !== key) {
193
- lastStatusText = key;
194
- node.status({ fill: fill, shape: shape, text: shortText });
195
- }
196
- }
197
-
198
- function saveToDisk() {
199
- try {
200
- makeDir(storeDir);
201
-
202
- fs.writeFileSync(
203
- storeFile,
204
- JSON.stringify({
205
- version: 1,
206
- updatedAt: new Date().toISOString(),
207
- configStore: latestStore
208
- }, null, 2)
209
- );
210
- } catch (err) {
211
- warnOncePerMinute("Failed to write 3DM config store to disk: " + (err.message || err));
212
- }
213
- }
214
-
215
- function loadFromDisk() {
216
- try {
217
- if (!fs.existsSync(storeFile)) {
218
- latestStore = {};
219
- return;
220
- }
221
-
222
- var data = JSON.parse(fs.readFileSync(storeFile, "utf8"));
223
-
224
- if (data && typeof data === "object" && data.configStore && typeof data.configStore === "object") {
225
- latestStore = data.configStore;
226
- } else if (data && typeof data === "object") {
227
- latestStore = data;
228
- } else {
229
- latestStore = {};
230
- }
231
- } catch (err) {
232
- latestStore = {};
233
- warnOncePerMinute("Failed to load saved 3DM config store: " + (err.message || err));
234
- }
235
- }
236
-
237
- function shouldAcceptConfigName(configName) {
238
- if (!node.configName) {
239
- return true;
240
- }
241
-
242
- return String(configName) === node.configName;
243
- }
244
-
245
- function inferBlockType(configName, block) {
246
- if (block && typeof block === "object" && !Array.isArray(block) && block.type) {
247
- return String(block.type);
248
- }
249
-
250
- if (Array.isArray(block)) {
251
- return "scheduler";
252
- }
253
-
254
- if (block && typeof block === "object" && Array.isArray(block.schedule)) {
255
- return "scheduler";
256
- }
257
-
258
- if (String(configName || "").toLowerCase().indexOf("sched") !== -1) {
259
- return "scheduler";
260
- }
261
-
262
- return "settings";
263
- }
264
-
265
- function normaliseConfigBlock(configName, block) {
266
- var type = inferBlockType(configName, block);
267
- var data = block;
268
-
269
- if (block && typeof block === "object" && !Array.isArray(block) && hasOwn(block, "data")) {
270
- data = block.data;
271
- } else if (block && typeof block === "object" && !Array.isArray(block) && Array.isArray(block.schedule)) {
272
- data = block.schedule;
273
- }
274
-
275
- return {
276
- name: configName,
277
- type: type,
278
- data: data,
279
- updatedAt: new Date().toISOString()
280
- };
281
- }
282
-
283
- function extractConfigStore(msg) {
284
- var payload = safeParseJson(msg.payload);
285
-
286
- if (payload && typeof payload === "object" && hasOwn(payload, "configStore")) {
287
- return payload.configStore;
288
- }
289
-
290
- if (msg.key === "configStore" || msg.topic === "configStore") {
291
- return payload;
292
- }
293
-
294
- if (payload && typeof payload === "object" && payload.shared && hasOwn(payload.shared, "configStore")) {
295
- return payload.shared.configStore;
296
- }
297
-
298
- if (payload && typeof payload === "object" && payload.client && hasOwn(payload.client, "configStore")) {
299
- return payload.client.configStore;
300
- }
301
-
302
- return null;
303
- }
304
-
305
- function storeConfig(configStore) {
306
- var savedNames = [];
307
-
308
- if (!configStore || typeof configStore !== "object" || Array.isArray(configStore)) {
309
- return savedNames;
310
- }
311
-
312
- Object.keys(configStore).forEach(function (configName) {
313
- if (!shouldAcceptConfigName(configName)) {
314
- return;
315
- }
316
-
317
- latestStore[configName] = normaliseConfigBlock(configName, configStore[configName]);
318
- savedNames.push(configName);
319
- });
320
-
321
- if (savedNames.length > 0) {
322
- saveToDisk();
323
- }
324
-
325
- return savedNames;
326
- }
327
-
328
- function storedConfigValue(key) {
329
- if (!key) {
330
- return undefined;
331
- }
332
-
333
- var cleanKey = String(key);
334
-
335
- var direct = getNestedValue({ configStore: latestStore }, cleanKey);
336
-
337
- if (direct !== undefined) {
338
- return direct;
339
- }
340
-
341
- direct = getNestedValue(latestStore, cleanKey);
342
-
343
- if (direct !== undefined) {
344
- return direct;
345
- }
346
-
347
- var names = Object.keys(latestStore);
348
-
349
- for (var i = 0; i < names.length; i += 1) {
350
- var block = latestStore[names[i]];
351
-
352
- if (!block) {
353
- continue;
354
- }
355
-
356
- direct = getNestedValue(block, cleanKey);
357
-
358
- if (direct !== undefined) {
359
- return direct;
360
- }
361
-
362
- direct = getNestedValue(block.data, cleanKey);
363
-
364
- if (direct !== undefined) {
365
- return direct;
366
- }
367
- }
368
-
369
- return undefined;
370
- }
371
-
372
- function resolveValue(key) {
373
- var value = getNestedValue(latestPayload, key);
374
-
375
- if (value !== undefined) {
376
- return value;
377
- }
378
-
379
- return storedConfigValue(key);
380
- }
381
-
382
- function resolveSetpoint(item, valueKeys, fixedKeys) {
383
- for (var i = 0; i < valueKeys.length; i += 1) {
384
- var valueKeyName = valueKeys[i];
385
-
386
- if (item[valueKeyName] !== undefined && item[valueKeyName] !== null && item[valueKeyName] !== "") {
387
- var resolved = resolveValue(item[valueKeyName]);
388
-
389
- if (resolved !== undefined) {
390
- return resolved;
391
- }
392
- }
393
- }
394
-
395
- for (var j = 0; j < fixedKeys.length; j += 1) {
396
- var fixedKeyName = fixedKeys[j];
397
-
398
- if (item[fixedKeyName] !== undefined && item[fixedKeyName] !== null && item[fixedKeyName] !== "") {
399
- return item[fixedKeyName];
400
- }
401
- }
402
-
403
- return undefined;
404
- }
405
-
406
- function itemMemoryKey(item, index, outputKey, configName) {
407
- var explicitId = item.id || item.scheduleId || item.controlId || item.uid || item.uuid;
408
-
409
- if (explicitId !== undefined && explicitId !== null && String(explicitId) !== "") {
410
- return String(configName || "config") + "::" + String(item.controlType || "schedule") + "::" + String(explicitId);
411
- }
412
-
413
- var variableName = item.scheduleVariable || item.variable || item.key || item.keyName || "";
414
-
415
- return JSON.stringify({
416
- configName: configName || "",
417
- type: item.controlType || "schedule",
418
- output: outputKey,
419
- name: item.name || "",
420
- days: item.days || [],
421
- startHour: item.startHour || "",
422
- startMinute: item.startMinute || "",
423
- runHour: item.runHour || "",
424
- runMinute: item.runMinute || "",
425
- variable: variableName,
426
- direction: item.direction || "",
427
- lowSetpoint: item.lowSetpoint || "",
428
- highSetpoint: item.highSetpoint || "",
429
- scheduleHighSetpoint: item.scheduleHighSetpoint || "",
430
- index: index
431
- });
432
- }
433
-
434
- function currentScheduleRun(item, now) {
435
- var days = item.days || [];
436
-
437
- if (typeof days === "string") {
438
- days = days.split(",").map(function (day) {
439
- return day.trim().toLowerCase();
440
- });
441
- }
442
-
443
- var startHour = parseInt(item.startHour || 0, 10);
444
- var startMinute = parseInt(item.startMinute || 0, 10);
445
- var runHour = parseInt(item.runHour || 0, 10);
446
- var runMinute = parseInt(item.runMinute || 0, 10);
447
-
448
- var startMinutes = startHour * 60 + startMinute;
449
- var runMinutes = runHour * 60 + runMinute;
450
-
451
- if (runMinutes <= 0) {
452
- return null;
453
- }
454
-
455
- var dayNames = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
456
- var candidates = [new Date(now), new Date(now)];
457
-
458
- candidates[1].setDate(candidates[1].getDate() - 1);
459
-
460
- for (var i = 0; i < candidates.length; i += 1) {
461
- var startDay = candidates[i];
462
- var dayName = dayNames[startDay.getDay()];
463
-
464
- if (days.length > 0 && days.indexOf(dayName) === -1) {
465
- continue;
466
- }
467
-
468
- var start = new Date(startDay);
469
- start.setHours(Math.floor(startMinutes / 60), startMinutes % 60, 0, 0);
470
-
471
- var end = new Date(start.getTime() + runMinutes * 60000);
472
-
473
- if (now >= start && now < end) {
474
- return dateKey(start) + "@" + startMinutes;
475
- }
476
- }
477
-
478
- return null;
479
- }
480
-
481
- function scheduleHighStopEnabled(item) {
482
- return asBool(item.highStopEnabled);
483
- }
484
-
485
- function calculateScheduleState(item, itemKey) {
486
- var mode = normaliseMode(item.mode, "Auto Schedule");
487
-
488
- if (isManualOn(mode)) {
489
- return 1;
490
- }
491
-
492
- if (isManualOff(mode)) {
493
- return 0;
494
- }
495
-
496
- if (!isAutoSchedule(mode)) {
497
- return null;
498
- }
499
-
500
- var runId = currentScheduleRun(item, new Date());
501
-
502
- if (!runId) {
503
- stoppedScheduleRuns.delete(itemKey);
504
- return 0;
505
- }
506
-
507
- if (!scheduleHighStopEnabled(item)) {
508
- return 1;
509
- }
510
-
511
- if (stoppedScheduleRuns.get(itemKey) === runId) {
512
- return 0;
513
- }
514
-
515
- var keyName = item.scheduleVariable || item.variable || item.key || item.keyName;
516
- var value = asNumber(resolveValue(keyName));
517
-
518
- var highStopValue = resolveSetpoint(
519
- item,
520
- ["scheduleHighSetpointKey", "highStopKey", "highSetpointKey"],
521
- ["scheduleHighSetpoint", "highStop", "highSetpoint"]
522
- );
523
-
524
- var highStop = asNumber(highStopValue);
525
-
526
- if (Number.isNaN(value) || Number.isNaN(highStop)) {
527
- return 1;
528
- }
529
-
530
- if (value >= highStop) {
531
- stoppedScheduleRuns.set(itemKey, runId);
532
- return 0;
533
- }
534
-
535
- return 1;
536
- }
537
-
538
- function calculateControlState(item, itemKey, outputKey) {
539
- var mode = normaliseMode(item.mode, "AUTO");
540
-
541
- if (isManualOn(mode)) {
542
- controlItemStates.set(itemKey, 1);
543
- return 1;
544
- }
545
-
546
- if (isManualOff(mode)) {
547
- controlItemStates.set(itemKey, 0);
548
- return 0;
549
- }
550
-
551
- var keyName = item.variable || item.key || item.keyName;
552
- var value = asNumber(resolveValue(keyName));
553
-
554
- var low = asNumber(resolveSetpoint(
555
- item,
556
- ["lowSetpointKey", "lowKey", "startSetpointKey"],
557
- ["lowSetpoint", "low", "startSetpoint"]
558
- ));
559
-
560
- var high = asNumber(resolveSetpoint(
561
- item,
562
- ["highSetpointKey", "highKey", "stopSetpointKey"],
563
- ["highSetpoint", "high", "stopSetpoint"]
564
- ));
565
-
566
- if (Number.isNaN(value) || Number.isNaN(low) || Number.isNaN(high)) {
567
- return null;
568
- }
569
-
570
- var lastState = controlItemStates.has(itemKey)
571
- ? controlItemStates.get(itemKey)
572
- : (outputStates[outputKey] || 0);
573
-
574
- var newState = lastState;
575
- var direction = item.direction || "in";
576
-
577
- if (direction === "in" || direction === "fill" || direction === "normal") {
578
- if (value <= low) {
579
- newState = 1;
580
- }
581
-
582
- if (value >= high) {
583
- newState = 0;
584
- }
585
- } else {
586
- if (value >= high) {
587
- newState = 1;
588
- }
589
-
590
- if (value <= low) {
591
- newState = 0;
592
- }
593
- }
594
-
595
- controlItemStates.set(itemKey, newState);
596
- return newState;
597
- }
598
-
599
- function getSchedulerItems() {
600
- var items = [];
601
- var configNames = Object.keys(latestStore);
602
-
603
- configNames.forEach(function (configName) {
604
- if (!shouldAcceptConfigName(configName)) {
605
- return;
606
- }
607
-
608
- var block = latestStore[configName];
609
-
610
- if (!block || !isSchedulerType(block.type)) {
611
- return;
612
- }
613
-
614
- var list = [];
615
-
616
- if (Array.isArray(block.data)) {
617
- list = block.data;
618
- } else if (block.data && typeof block.data === "object" && Array.isArray(block.data.schedule)) {
619
- list = block.data.schedule;
620
- }
621
-
622
- list.forEach(function (item) {
623
- if (item && typeof item === "object") {
624
- items.push({
625
- configName: configName,
626
- item: item
627
- });
628
- }
629
- });
630
- });
631
-
632
- return items;
633
- }
634
-
635
- function sendConfigSnapshot(eventName) {
636
- if (node.configOutput < 1 || node.configOutput > node.outputCount) {
61
+ node.connect = function () {
62
+ if (!node.username) {
63
+ node.connected = false;
64
+ node.emit("state", false);
65
+ warnOncePerMinute("3DM Cloud username is missing");
637
66
  return;
638
67
  }
639
68
 
640
- var outputs = new Array(node.outputCount).fill(null);
641
-
642
- outputs[node.configOutput - 1] = {
643
- topic: "configStore",
644
- event: eventName || "configStore",
645
- payload: {
646
- configStore: latestStore
647
- },
648
- configStore: latestStore
69
+ var options = {
70
+ username: node.username,
71
+ password: node.password,
72
+ clean: MQTT_CLEAN_SESSION,
73
+ keepalive: MQTT_KEEPALIVE,
74
+ reconnectPeriod: MQTT_RECONNECT_MS,
75
+ connectTimeout: MQTT_CONNECT_TIMEOUT_MS
649
76
  };
650
77
 
651
- node.send(outputs);
652
- }
653
-
654
- function runConfigStore(isOffline, forceOutput) {
655
- if (closed) {
656
- return;
78
+ if (node.clientid) {
79
+ options.clientId = node.clientid;
657
80
  }
658
81
 
659
- var outputs = new Array(node.outputCount).fill(null);
660
- var statusSummary = [];
661
- var outputGroups = new Map();
662
- var activeItemKeys = new Set();
663
- var schedulerItems = getSchedulerItems();
664
-
665
- schedulerItems.forEach(function (entry, index) {
666
- var item = entry.item;
667
- var configName = entry.configName;
668
- var outputKey = item.output || ("output" + (index + 1));
669
- var outputNumber = outputNumberFromKey(outputKey, index, node.outputCount);
670
-
671
- if (outputNumber === null) {
672
- statusSummary.push(outputKey + ": " + (item.name || "item") + " skipped - output disabled");
673
- return;
674
- }
82
+ node.client = mqtt.connect(CLOUD_URL, options);
675
83
 
676
- var itemKey = itemMemoryKey(item, index, outputKey, configName);
84
+ node.client.on("connect", function () {
85
+ node.connected = true;
86
+ node.lastErrorText = "";
87
+ node.lastErrorTime = 0;
677
88
 
678
- item.output = outputKey;
679
- activeItemKeys.add(itemKey);
89
+ node.emit("state", true);
680
90
 
681
- var newState = item.controlType === "control"
682
- ? calculateControlState(item, itemKey, outputKey)
683
- : calculateScheduleState(item, itemKey);
684
-
685
- if (newState === null || newState === undefined) {
686
- statusSummary.push(outputKey + ": " + (item.name || "item") + " skipped");
687
- return;
688
- }
689
-
690
- if (!outputGroups.has(outputKey)) {
691
- outputGroups.set(outputKey, {
692
- outputKey: outputKey,
693
- outputNumber: outputNumber,
694
- states: [],
695
- names: [],
696
- controlTypes: new Set()
91
+ Object.keys(node.subscriptions).forEach(function (topic) {
92
+ node.client.subscribe(topic, node.subscriptions[topic], function (err) {
93
+ if (err) {
94
+ warnOncePerMinute("3DM Cloud subscribe failed: " + (err.message || err));
95
+ }
697
96
  });
698
- }
699
-
700
- var group = outputGroups.get(outputKey);
701
-
702
- group.states.push(Number(newState) === 1 ? 1 : 0);
703
- group.names.push(item.name || outputKey);
704
- group.controlTypes.add(item.controlType || "schedule");
97
+ });
705
98
  });
706
99
 
707
- stoppedScheduleRuns.forEach(function (_value, key) {
708
- if (!activeItemKeys.has(key)) {
709
- stoppedScheduleRuns.delete(key);
710
- }
100
+ node.client.on("reconnect", function () {
101
+ node.emit("reconnect");
711
102
  });
712
103
 
713
- controlItemStates.forEach(function (_value, key) {
714
- if (!activeItemKeys.has(key)) {
715
- controlItemStates.delete(key);
716
- }
104
+ node.client.on("offline", function () {
105
+ node.connected = false;
106
+ node.emit("state", false);
717
107
  });
718
108
 
719
- outputGroups.forEach(function (group) {
720
- var activeRequests = group.states.filter(function (state) {
721
- return state === 1;
722
- }).length;
723
-
724
- var combinedState = activeRequests > 0 ? 1 : 0;
725
- var lastState = outputStates[group.outputKey];
726
-
727
- if (node.outputMode === "always" || forceOutput || lastState !== combinedState) {
728
- outputStates[group.outputKey] = combinedState;
729
-
730
- var controlType = group.controlTypes.size === 1
731
- ? Array.from(group.controlTypes)[0]
732
- : "combined";
733
-
734
- outputs[group.outputNumber - 1] = {
735
- payload: Boolean(combinedState),
736
- topic: group.outputKey,
737
- output: group.outputKey,
738
- outputNumber: group.outputNumber,
739
- controlType: controlType,
740
- activeRequests: activeRequests,
741
- totalRequests: group.states.length,
742
- name: group.names.length === 1
743
- ? group.names[0]
744
- : group.outputKey + " (" + group.names.length + " commands)"
745
- };
746
- }
747
-
748
- statusSummary.push(
749
- group.outputKey + ": " +
750
- (combinedState ? "ON" : "OFF") +
751
- " (" + activeRequests + "/" + group.states.length + " active)"
752
- );
109
+ node.client.on("close", function () {
110
+ node.connected = false;
111
+ node.emit("state", false);
753
112
  });
754
113
 
755
- if (closed) {
756
- return;
757
- }
114
+ node.client.on("error", function (err) {
115
+ node.connected = false;
116
+ node.emit("state", false);
758
117
 
759
- if (statusSummary.length === 0) {
760
- var names = Object.keys(latestStore);
118
+ var text = err && err.message ? err.message : String(err);
761
119
 
762
- if (names.length === 0) {
763
- setStatus("yellow", "ring", "waiting for configStore");
120
+ if (text.indexOf("Not authorized") !== -1 || text.indexOf("not authorized") !== -1) {
121
+ warnOncePerMinute("3DM Cloud login rejected. Check username, password, and Client ID.");
764
122
  } else {
765
- setStatus("blue", "dot", "config stored: " + names.join(", "));
766
- }
767
-
768
- return;
769
- }
770
-
771
- node.send(outputs);
772
-
773
- setStatus(
774
- isOffline ? "yellow" : "blue",
775
- "dot",
776
- (isOffline ? "Offline: " : "") + statusSummary.join(" | ")
777
- );
778
- }
779
-
780
- function storeLatestPayload(msg) {
781
- var payload = safeParseJson(msg.payload);
782
-
783
- if (payload && typeof payload === "object" && !Array.isArray(payload)) {
784
- latestPayload = payload;
785
- } else {
786
- latestPayload = {};
787
- }
788
- }
789
-
790
- function handleGetRequest(msg) {
791
- var payload = safeParseJson(msg.payload);
792
-
793
- if (msg.getConfigStore === true) {
794
- sendConfigSnapshot("requested");
795
- return true;
796
- }
797
-
798
- if (payload && typeof payload === "object") {
799
- if (payload.getConfigStore === true || payload.configStoreGet === true) {
800
- sendConfigSnapshot("requested");
801
- return true;
123
+ warnOncePerMinute("3DM Cloud connection error: " + text);
802
124
  }
803
- }
804
-
805
- return false;
806
- }
807
-
808
- loadFromDisk();
809
-
810
- if (Object.keys(latestStore).length > 0) {
811
- setStatus("blue", "dot", "loaded saved config");
812
- } else {
813
- setStatus("yellow", "ring", "waiting for configStore");
814
- }
815
-
816
- var timer = setInterval(function () {
817
- runConfigStore(false, false);
818
- }, node.tickSeconds * 1000);
125
+ });
819
126
 
820
- setTimeout(function () {
821
- runConfigStore(false, true);
822
- }, 500);
127
+ node.client.on("message", function (topic, payload) {
128
+ node.emit("cloud-message", topic, payload);
129
+ });
130
+ };
823
131
 
824
- node.on("input", function (msg, send, done) {
825
- send = send || function () {
826
- node.send.apply(node, arguments);
827
- };
132
+ node.publish = function (topic, payload, options, callback) {
133
+ if (!node.client || !node.connected) {
134
+ var err = new Error("3DM Cloud is not connected");
828
135
 
829
- if (closed) {
830
- if (done) {
831
- done();
136
+ if (callback) {
137
+ callback(err);
832
138
  }
833
139
 
834
140
  return;
835
141
  }
836
142
 
837
- try {
838
- if (handleGetRequest(msg)) {
839
- if (done) {
840
- done();
841
- }
842
-
843
- return;
844
- }
845
-
846
- var configStore = extractConfigStore(msg);
143
+ var data = payload;
847
144
 
848
- if (configStore) {
849
- var savedNames = storeConfig(configStore);
850
-
851
- if (savedNames.length > 0) {
852
- setStatus("blue", "dot", "saved: " + savedNames.join(", "));
853
-
854
- if (node.emitConfigOnUpdate) {
855
- sendConfigSnapshot("updated");
856
- }
857
-
858
- runConfigStore(false, true);
859
- } else {
860
- setStatus("yellow", "ring", "configStore ignored");
861
- }
862
-
863
- if (done) {
864
- done();
865
- }
866
-
867
- return;
868
- }
145
+ if (typeof data !== "string" && !Buffer.isBuffer(data)) {
146
+ data = JSON.stringify(data);
147
+ }
869
148
 
870
- storeLatestPayload(msg);
871
- runConfigStore(false, false);
149
+ node.client.publish(topic, data, options || {}, callback);
150
+ };
872
151
 
873
- if (done) {
874
- done();
875
- }
876
- } catch (err) {
877
- setStatus("red", "ring", "config store error");
878
- node.error("3DM Config Store error: " + (err.message || err), msg);
152
+ node.subscribe = function (topic, options, callback) {
153
+ node.subscriptions[topic] = options || {};
879
154
 
880
- if (done) {
881
- done(err);
882
- }
155
+ if (node.client && node.connected) {
156
+ node.client.subscribe(topic, options || {}, callback);
157
+ } else if (callback) {
158
+ callback();
883
159
  }
884
- });
160
+ };
885
161
 
886
162
  node.on("close", function (removed, done) {
887
163
  if (typeof removed === "function") {
@@ -890,11 +166,25 @@ module.exports = function (RED) {
890
166
 
891
167
  done = done || function () {};
892
168
 
893
- closed = true;
894
- clearInterval(timer);
895
- done();
169
+ node.connected = false;
170
+
171
+ if (node.client) {
172
+ node.client.end(true, function () {
173
+ node.client = null;
174
+ done();
175
+ });
176
+ } else {
177
+ done();
178
+ }
896
179
  });
180
+
181
+ node.connect();
897
182
  }
898
183
 
899
- RED.nodes.registerType("3dm-config-store", ThreeDMConfigStoreNode);
184
+ RED.nodes.registerType("3dm-cloud-config", ThreeDMCloudConfigNode, {
185
+ credentials: {
186
+ username: { type: "text" },
187
+ password: { type: "password" }
188
+ }
189
+ });
900
190
  };
@@ -140,6 +140,34 @@ module.exports = function (RED) {
140
140
  return text === "scheduler" || text === "schedule" || text === "consched" || text === "control-schedule";
141
141
  }
142
142
 
143
+ function stableStringify(value) {
144
+ if (value === null || typeof value !== "object") {
145
+ return JSON.stringify(value);
146
+ }
147
+
148
+ if (Array.isArray(value)) {
149
+ return "[" + value.map(stableStringify).join(",") + "]";
150
+ }
151
+
152
+ return "{" + Object.keys(value).sort().map(function (key) {
153
+ return JSON.stringify(key) + ":" + stableStringify(value[key]);
154
+ }).join(",") + "}";
155
+ }
156
+
157
+ function configBlockChanged(oldBlock, newBlock) {
158
+ if (!oldBlock) {
159
+ return true;
160
+ }
161
+
162
+ return stableStringify({
163
+ type: oldBlock.type,
164
+ data: oldBlock.data
165
+ }) !== stableStringify({
166
+ type: newBlock.type,
167
+ data: newBlock.data
168
+ });
169
+ }
170
+
143
171
  function ThreeDMConfigStoreNode(config) {
144
172
  RED.nodes.createNode(this, config);
145
173
 
@@ -239,7 +267,23 @@ module.exports = function (RED) {
239
267
  return true;
240
268
  }
241
269
 
242
- return String(configName) === node.configName;
270
+ return String(configName).trim() === node.configName;
271
+ }
272
+
273
+ function getAcceptedConfigNames(configStore) {
274
+ var names = [];
275
+
276
+ if (!configStore || typeof configStore !== "object" || Array.isArray(configStore)) {
277
+ return names;
278
+ }
279
+
280
+ Object.keys(configStore).forEach(function (configName) {
281
+ if (shouldAcceptConfigName(configName)) {
282
+ names.push(String(configName).trim());
283
+ }
284
+ });
285
+
286
+ return names;
243
287
  }
244
288
 
245
289
  function inferBlockType(configName, block) {
@@ -309,12 +353,19 @@ module.exports = function (RED) {
309
353
  return savedNames;
310
354
  }
311
355
 
312
- Object.keys(configStore).forEach(function (configName) {
313
- if (!shouldAcceptConfigName(configName)) {
356
+ Object.keys(configStore).forEach(function (rawConfigName) {
357
+ if (!shouldAcceptConfigName(rawConfigName)) {
358
+ return;
359
+ }
360
+
361
+ var configName = String(rawConfigName).trim();
362
+ var normalisedBlock = normaliseConfigBlock(configName, configStore[rawConfigName]);
363
+
364
+ if (!configBlockChanged(latestStore[configName], normalisedBlock)) {
314
365
  return;
315
366
  }
316
367
 
317
- latestStore[configName] = normaliseConfigBlock(configName, configStore[configName]);
368
+ latestStore[configName] = normalisedBlock;
318
369
  savedNames.push(configName);
319
370
  });
320
371
 
@@ -846,6 +897,7 @@ module.exports = function (RED) {
846
897
  var configStore = extractConfigStore(msg);
847
898
 
848
899
  if (configStore) {
900
+ var matchedNames = getAcceptedConfigNames(configStore);
849
901
  var savedNames = storeConfig(configStore);
850
902
 
851
903
  if (savedNames.length > 0) {
@@ -856,8 +908,17 @@ module.exports = function (RED) {
856
908
  }
857
909
 
858
910
  runConfigStore(false, true);
911
+ } else if (matchedNames.length > 0) {
912
+ setStatus("blue", "ring", "config unchanged: " + matchedNames.join(", "));
859
913
  } else {
860
- setStatus("yellow", "ring", "configStore ignored");
914
+ setStatus(
915
+ "yellow",
916
+ "ring",
917
+ "configStore ignored - looking for " +
918
+ (node.configName || "all") +
919
+ ", received " +
920
+ Object.keys(configStore).join(", ")
921
+ );
861
922
  }
862
923
 
863
924
  if (done) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-3dm-space",
3
- "version": "1.0.5",
3
+ "version": "1.0.10",
4
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",