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

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,171 +1,887 @@
1
1
  module.exports = function (RED) {
2
2
  "use strict";
3
3
 
4
- var mqtt = require("mqtt");
4
+ var fs = require("fs");
5
+ var path = require("path");
5
6
 
6
- /*
7
- * Hidden 3DM.space Cloud connection settings.
8
- */
9
- var CLOUD_HOST = "3dm.space";
10
- var CLOUD_PORT = 1883;
11
- var CLOUD_URL = "mqtt://" + CLOUD_HOST + ":" + CLOUD_PORT;
7
+ function asBool(value) {
8
+ return value === true || value === "true" || value === 1 || value === "1";
9
+ }
12
10
 
13
- var MQTT_KEEPALIVE = 60;
14
- var MQTT_CLEAN_SESSION = true;
15
- var MQTT_RECONNECT_MS = 5000;
16
- var MQTT_CONNECT_TIMEOUT_MS = 15000;
11
+ function asNumber(value) {
12
+ var n = parseFloat(value);
13
+ return Number.isNaN(n) ? NaN : n;
14
+ }
17
15
 
18
- /*
19
- * Prevent the same connection error being logged every few seconds.
20
- */
21
- var REPEAT_ERROR_LOG_MS = 60000;
16
+ function clampNumber(value, min, max, fallback) {
17
+ var n = parseInt(value, 10);
22
18
 
23
- function cleanText(value) {
24
- if (value === null || value === undefined) {
25
- return "";
19
+ if (Number.isNaN(n)) {
20
+ return fallback;
26
21
  }
27
22
 
28
- return String(value).trim();
23
+ return Math.max(min, Math.min(n, max));
29
24
  }
30
25
 
31
- function ThreeDMCloudConfigNode(config) {
32
- RED.nodes.createNode(this, config);
26
+ function safeParseJson(value) {
27
+ if (typeof value !== "string") {
28
+ return value;
29
+ }
33
30
 
34
- var node = this;
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
+ }
35
46
 
36
- node.name = cleanText(config.name);
37
- node.clientid = cleanText(config.clientid);
47
+ if (typeof obj !== "object") {
48
+ return undefined;
49
+ }
38
50
 
39
- node.username = node.credentials && node.credentials.username
40
- ? cleanText(node.credentials.username)
41
- : "";
51
+ if (hasOwn(obj, key)) {
52
+ return obj[key];
53
+ }
42
54
 
43
- node.password = node.credentials && node.credentials.password
44
- ? cleanText(node.credentials.password)
45
- : "";
55
+ var current = obj;
56
+ var parts = String(key).split(".");
46
57
 
47
- node.connected = false;
48
- node.client = null;
49
- node.subscriptions = {};
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
+ }
50
64
 
51
- node.lastErrorText = "";
52
- node.lastErrorTime = 0;
65
+ current = current[part];
66
+ }
53
67
 
54
- node.setMaxListeners(0);
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, maxOutputs) {
94
+ maxOutputs = clampNumber(maxOutputs || 24, 1, 24, 24);
95
+
96
+ var match = String(outputKey || "").match(/output(\d+)/i);
97
+
98
+ if (match) {
99
+ var number = parseInt(match[1], 10);
100
+
101
+ if (number >= 1 && number <= maxOutputs) {
102
+ return number;
103
+ }
104
+
105
+ return null;
106
+ }
107
+
108
+ var fallbackNumber = fallbackIndex + 1;
109
+
110
+ if (fallbackNumber >= 1 && fallbackNumber <= maxOutputs) {
111
+ return fallbackNumber;
112
+ }
113
+
114
+ return null;
115
+ }
116
+
117
+ function normaliseMode(value, fallback) {
118
+ if (value === null || value === undefined || value === "") {
119
+ return fallback;
120
+ }
121
+
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;
55
172
 
56
173
  function warnOncePerMinute(text) {
57
174
  var now = Date.now();
58
175
 
59
- if (text !== node.lastErrorText || (now - node.lastErrorTime) > REPEAT_ERROR_LOG_MS) {
60
- node.lastErrorText = text;
61
- node.lastErrorTime = now;
176
+ if (text !== lastWarnText || (now - lastWarnTime) > 60000) {
177
+ lastWarnText = text;
178
+ lastWarnTime = now;
62
179
  node.warn(text);
63
180
  }
64
181
  }
65
182
 
66
- node.connect = function () {
67
- if (!node.username) {
68
- node.connected = false;
69
- node.emit("state", false);
70
- warnOncePerMinute("3DM Cloud username is missing");
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) {
71
637
  return;
72
638
  }
73
639
 
74
- var options = {
75
- username: node.username,
76
- password: node.password,
77
- clean: MQTT_CLEAN_SESSION,
78
- keepalive: MQTT_KEEPALIVE,
79
- reconnectPeriod: MQTT_RECONNECT_MS,
80
- connectTimeout: MQTT_CONNECT_TIMEOUT_MS
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
81
649
  };
82
650
 
83
- /*
84
- * Only send Client ID if the user entered one.
85
- */
86
- if (node.clientid) {
87
- options.clientId = node.clientid;
651
+ node.send(outputs);
652
+ }
653
+
654
+ function runConfigStore(isOffline, forceOutput) {
655
+ if (closed) {
656
+ return;
88
657
  }
89
658
 
90
- node.client = mqtt.connect(CLOUD_URL, options);
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
+ }
91
675
 
92
- node.client.on("connect", function () {
93
- node.connected = true;
94
- node.lastErrorText = "";
95
- node.lastErrorTime = 0;
676
+ var itemKey = itemMemoryKey(item, index, outputKey, configName);
96
677
 
97
- node.emit("state", true);
678
+ item.output = outputKey;
679
+ activeItemKeys.add(itemKey);
98
680
 
99
- Object.keys(node.subscriptions).forEach(function (topic) {
100
- node.client.subscribe(topic, node.subscriptions[topic], function (err) {
101
- if (err) {
102
- warnOncePerMinute("3DM Cloud subscribe failed: " + (err.message || err));
103
- }
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()
104
697
  });
105
- });
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");
106
705
  });
107
706
 
108
- node.client.on("reconnect", function () {
109
- node.emit("reconnect");
707
+ stoppedScheduleRuns.forEach(function (_value, key) {
708
+ if (!activeItemKeys.has(key)) {
709
+ stoppedScheduleRuns.delete(key);
710
+ }
110
711
  });
111
712
 
112
- node.client.on("offline", function () {
113
- node.connected = false;
114
- node.emit("state", false);
713
+ controlItemStates.forEach(function (_value, key) {
714
+ if (!activeItemKeys.has(key)) {
715
+ controlItemStates.delete(key);
716
+ }
115
717
  });
116
718
 
117
- node.client.on("close", function () {
118
- node.connected = false;
119
- node.emit("state", false);
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
+ );
120
753
  });
121
754
 
122
- node.client.on("error", function (err) {
123
- node.connected = false;
124
- node.emit("state", false);
755
+ if (closed) {
756
+ return;
757
+ }
125
758
 
126
- var text = err && err.message ? err.message : String(err);
759
+ if (statusSummary.length === 0) {
760
+ var names = Object.keys(latestStore);
127
761
 
128
- if (text.indexOf("Not authorized") !== -1 || text.indexOf("not authorized") !== -1) {
129
- warnOncePerMinute("3DM Cloud login rejected. Check username, password, and Client ID.");
762
+ if (names.length === 0) {
763
+ setStatus("yellow", "ring", "waiting for configStore");
130
764
  } else {
131
- warnOncePerMinute("3DM Cloud connection error: " + text);
765
+ setStatus("blue", "dot", "config stored: " + names.join(", "));
132
766
  }
133
- });
134
767
 
135
- node.client.on("message", function (topic, payload) {
136
- node.emit("cloud-message", topic, payload);
137
- });
138
- };
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);
139
792
 
140
- node.publish = function (topic, payload, options, callback) {
141
- if (!node.client || !node.connected) {
142
- var err = new Error("3DM Cloud is not connected");
793
+ if (msg.getConfigStore === true) {
794
+ sendConfigSnapshot("requested");
795
+ return true;
796
+ }
143
797
 
144
- if (callback) {
145
- callback(err);
798
+ if (payload && typeof payload === "object") {
799
+ if (payload.getConfigStore === true || payload.configStoreGet === true) {
800
+ sendConfigSnapshot("requested");
801
+ return true;
802
+ }
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);
819
+
820
+ setTimeout(function () {
821
+ runConfigStore(false, true);
822
+ }, 500);
823
+
824
+ node.on("input", function (msg, send, done) {
825
+ send = send || function () {
826
+ node.send.apply(node, arguments);
827
+ };
828
+
829
+ if (closed) {
830
+ if (done) {
831
+ done();
146
832
  }
147
833
 
148
834
  return;
149
835
  }
150
836
 
151
- var data = payload;
837
+ try {
838
+ if (handleGetRequest(msg)) {
839
+ if (done) {
840
+ done();
841
+ }
152
842
 
153
- if (typeof data !== "string" && !Buffer.isBuffer(data)) {
154
- data = JSON.stringify(data);
155
- }
843
+ return;
844
+ }
845
+
846
+ var configStore = extractConfigStore(msg);
156
847
 
157
- node.client.publish(topic, data, options || {}, callback);
158
- };
848
+ if (configStore) {
849
+ var savedNames = storeConfig(configStore);
159
850
 
160
- node.subscribe = function (topic, options, callback) {
161
- node.subscriptions[topic] = options || {};
851
+ if (savedNames.length > 0) {
852
+ setStatus("blue", "dot", "saved: " + savedNames.join(", "));
162
853
 
163
- if (node.client && node.connected) {
164
- node.client.subscribe(topic, options || {}, callback);
165
- } else if (callback) {
166
- callback();
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
+ }
869
+
870
+ storeLatestPayload(msg);
871
+ runConfigStore(false, false);
872
+
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);
879
+
880
+ if (done) {
881
+ done(err);
882
+ }
167
883
  }
168
- };
884
+ });
169
885
 
170
886
  node.on("close", function (removed, done) {
171
887
  if (typeof removed === "function") {
@@ -174,24 +890,11 @@ module.exports = function (RED) {
174
890
 
175
891
  done = done || function () {};
176
892
 
177
- node.connected = false;
178
-
179
- if (node.client) {
180
- node.client.end(true, function () {
181
- done();
182
- });
183
- } else {
184
- done();
185
- }
893
+ closed = true;
894
+ clearInterval(timer);
895
+ done();
186
896
  });
187
-
188
- node.connect();
189
897
  }
190
898
 
191
- RED.nodes.registerType("3dm-cloud-config", ThreeDMCloudConfigNode, {
192
- credentials: {
193
- username: { type: "text" },
194
- password: { type: "password" }
195
- }
196
- });
899
+ RED.nodes.registerType("3dm-config-store", ThreeDMConfigStoreNode);
197
900
  };