node-red-contrib-3dm-space 0.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,44 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("3dm-cloud-config", {
3
+ category: "config",
4
+ defaults: {
5
+ name: { value: "" },
6
+ clientid: { value: "" }
7
+ },
8
+ credentials: {
9
+ username: { type: "text" },
10
+ password: { type: "password" }
11
+ },
12
+ label: function () {
13
+ return this.name || "3DM Cloud Login";
14
+ }
15
+ });
16
+ </script>
17
+
18
+ <script type="text/html" data-template-name="3dm-cloud-config">
19
+ <div class="form-row">
20
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
21
+ <input type="text" id="node-config-input-name" placeholder="e.g. Main PassPort">
22
+ </div>
23
+
24
+ <div class="form-row">
25
+ <label for="node-config-input-username"><i class="fa fa-user"></i> Username</label>
26
+ <input type="text" id="node-config-input-username" placeholder="Paste device username">
27
+ </div>
28
+
29
+ <div class="form-row">
30
+ <label for="node-config-input-password"><i class="fa fa-key"></i> Password</label>
31
+ <input type="password" id="node-config-input-password" placeholder="Paste device password">
32
+ </div>
33
+
34
+ <div class="form-row">
35
+ <label for="node-config-input-clientid"><i class="fa fa-id-card"></i> Client ID</label>
36
+ <input type="text" id="node-config-input-clientid" placeholder="Optional, leave blank unless supplied">
37
+ </div>
38
+ </script>
39
+
40
+ <script type="text/html" data-help-name="3dm-cloud-config">
41
+ <p>Login details for 3DM.space SCADA Cloud.</p>
42
+ <p>Give this login a friendly name, then paste the device username and password from 3DM.space.</p>
43
+ <p>Client ID is optional. Leave it blank unless supplied.</p>
44
+ </script>
@@ -0,0 +1,192 @@
1
+ module.exports = function (RED) {
2
+ "use strict";
3
+
4
+ var mqtt = require("mqtt");
5
+
6
+ /*
7
+ * 3DM.space Cloud connection settings
8
+ * Hidden from users.
9
+ */
10
+ var CLOUD_HOST = "3dm.space";
11
+ var CLOUD_PORT = 1883;
12
+ var CLOUD_URL = "mqtt://" + CLOUD_HOST + ":" + CLOUD_PORT;
13
+
14
+ var MQTT_KEEPALIVE = 60;
15
+ var MQTT_CLEAN_SESSION = true;
16
+ var MQTT_RECONNECT_MS = 5000;
17
+ var MQTT_CONNECT_TIMEOUT_MS = 15000;
18
+
19
+ /*
20
+ * Prevent the logs getting hammered with the same auth error every 5 seconds.
21
+ */
22
+ var REPEAT_ERROR_LOG_MS = 60000;
23
+
24
+ function cleanText(value) {
25
+ if (value === null || value === undefined) {
26
+ return "";
27
+ }
28
+
29
+ return String(value).trim();
30
+ }
31
+
32
+ function ThreeDMCloudConfigNode(config) {
33
+ RED.nodes.createNode(this, config);
34
+
35
+ var node = this;
36
+
37
+ node.name = cleanText(config.name);
38
+ node.clientid = cleanText(config.clientid);
39
+
40
+ node.username = node.credentials && node.credentials.username
41
+ ? cleanText(node.credentials.username)
42
+ : "";
43
+
44
+ node.password = node.credentials && node.credentials.password
45
+ ? cleanText(node.credentials.password)
46
+ : "";
47
+
48
+ node.connected = false;
49
+ node.client = null;
50
+ node.subscriptions = {};
51
+
52
+ node.lastErrorText = "";
53
+ node.lastErrorTime = 0;
54
+
55
+ node.setMaxListeners(0);
56
+
57
+ function warnOncePerMinute(text) {
58
+ var now = Date.now();
59
+
60
+ if (text !== node.lastErrorText || (now - node.lastErrorTime) > REPEAT_ERROR_LOG_MS) {
61
+ node.lastErrorText = text;
62
+ node.lastErrorTime = now;
63
+ node.warn(text);
64
+ }
65
+ }
66
+
67
+ node.connect = function () {
68
+ if (!node.username) {
69
+ node.connected = false;
70
+ node.emit("state", false);
71
+ warnOncePerMinute("3DM Cloud username is missing");
72
+ return;
73
+ }
74
+
75
+ var options = {
76
+ username: node.username,
77
+ password: node.password,
78
+ clean: MQTT_CLEAN_SESSION,
79
+ keepalive: MQTT_KEEPALIVE,
80
+ reconnectPeriod: MQTT_RECONNECT_MS,
81
+ connectTimeout: MQTT_CONNECT_TIMEOUT_MS
82
+ };
83
+
84
+ /*
85
+ * Only send Client ID if the user entered one.
86
+ */
87
+ if (node.clientid) {
88
+ options.clientId = node.clientid;
89
+ }
90
+
91
+ node.client = mqtt.connect(CLOUD_URL, options);
92
+
93
+ node.client.on("connect", function () {
94
+ node.connected = true;
95
+ node.lastErrorText = "";
96
+ node.lastErrorTime = 0;
97
+
98
+ node.emit("state", true);
99
+
100
+ Object.keys(node.subscriptions).forEach(function (topic) {
101
+ node.client.subscribe(topic, node.subscriptions[topic], function (err) {
102
+ if (err) {
103
+ warnOncePerMinute("3DM Cloud subscribe failed: " + (err.message || err));
104
+ }
105
+ });
106
+ });
107
+ });
108
+
109
+ node.client.on("reconnect", function () {
110
+ node.emit("reconnect");
111
+ });
112
+
113
+ node.client.on("offline", function () {
114
+ node.connected = false;
115
+ node.emit("state", false);
116
+ });
117
+
118
+ node.client.on("close", function () {
119
+ node.connected = false;
120
+ node.emit("state", false);
121
+ });
122
+
123
+ node.client.on("error", function (err) {
124
+ node.connected = false;
125
+ node.emit("state", false);
126
+
127
+ var text = err && err.message ? err.message : String(err);
128
+
129
+ if (text.indexOf("Not authorized") !== -1 || text.indexOf("not authorized") !== -1) {
130
+ warnOncePerMinute("3DM Cloud login rejected. Check username, password, and Client ID.");
131
+ } else {
132
+ warnOncePerMinute("3DM Cloud connection error: " + text);
133
+ }
134
+ });
135
+
136
+ node.client.on("message", function (topic, payload) {
137
+ node.emit("cloud-message", topic, payload);
138
+ });
139
+ };
140
+
141
+ node.publish = function (topic, payload, options, callback) {
142
+ if (!node.client || !node.connected) {
143
+ var err = new Error("3DM Cloud is not connected");
144
+
145
+ if (callback) {
146
+ callback(err);
147
+ }
148
+
149
+ return;
150
+ }
151
+
152
+ var data = payload;
153
+
154
+ if (typeof data !== "string" && !Buffer.isBuffer(data)) {
155
+ data = JSON.stringify(data);
156
+ }
157
+
158
+ node.client.publish(topic, data, options || {}, callback);
159
+ };
160
+
161
+ node.subscribe = function (topic, options, callback) {
162
+ node.subscriptions[topic] = options || {};
163
+
164
+ if (node.client && node.connected) {
165
+ node.client.subscribe(topic, options || {}, callback);
166
+ } else if (callback) {
167
+ callback();
168
+ }
169
+ };
170
+
171
+ node.on("close", function (removed, done) {
172
+ node.connected = false;
173
+
174
+ if (node.client) {
175
+ node.client.end(true, function () {
176
+ done();
177
+ });
178
+ } else {
179
+ done();
180
+ }
181
+ });
182
+
183
+ node.connect();
184
+ }
185
+
186
+ RED.nodes.registerType("3dm-cloud-config", ThreeDMCloudConfigNode, {
187
+ credentials: {
188
+ username: { type: "text" },
189
+ password: { type: "password" }
190
+ }
191
+ });
192
+ };
@@ -0,0 +1,58 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("3dm-cloud-in", {
3
+ category: "3DM.space",
4
+ color: "#656565",
5
+ defaults: {
6
+ name: { value: "" },
7
+ server: { value: "", type: "3dm-cloud-config", required: true },
8
+ keyFilter: { value: "" },
9
+ outputMode: { value: "value" }
10
+ },
11
+ inputs: 0,
12
+ outputs: 1,
13
+ icon: "icon.png",
14
+ label: function () {
15
+ return this.name || "3DM attributes in";
16
+ },
17
+ paletteLabel: "3DM in"
18
+ });
19
+ </script>
20
+
21
+ <script type="text/html" data-template-name="3dm-cloud-in">
22
+ <div class="form-row">
23
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
24
+ <input type="text" id="node-input-name" placeholder="3DM attributes in">
25
+ </div>
26
+
27
+ <div class="form-row">
28
+ <label for="node-input-server"><i class="fa fa-cloud"></i> Login</label>
29
+ <input type="text" id="node-input-server">
30
+ </div>
31
+
32
+ <div class="form-row">
33
+ <label for="node-input-keyFilter"><i class="fa fa-filter"></i> Key Filter</label>
34
+ <input type="text" id="node-input-keyFilter" placeholder="Leave blank to allow all">
35
+ </div>
36
+
37
+ <div class="form-row">
38
+ <label for="node-input-outputMode"><i class="fa fa-sign-out"></i> Output</label>
39
+ <select id="node-input-outputMode" style="width:70%;">
40
+ <option value="value">Value only</option>
41
+ <option value="object">Object with key</option>
42
+ </select>
43
+ </div>
44
+ </script>
45
+
46
+ <script type="text/html" data-help-name="3dm-cloud-in">
47
+ <p>Receives cloud attribute updates from 3DM.space.</p>
48
+
49
+ <p>If Key Filter is blank, all incoming attributes pass through.</p>
50
+
51
+ <p>If Key Filter is set to <code>pump_cmd</code>, only messages containing <code>pump_cmd</code> are passed through.</p>
52
+
53
+ <p>Output modes:</p>
54
+ <ul>
55
+ <li><b>Value only</b>: outputs just the selected value.</li>
56
+ <li><b>Object with key</b>: outputs a JSON object containing the selected key.</li>
57
+ </ul>
58
+ </script>
@@ -0,0 +1,115 @@
1
+ module.exports = function (RED) {
2
+ "use strict";
3
+
4
+ /*
5
+ * Hidden cloud subscribe settings.
6
+ */
7
+ var ATTRIBUTES_TOPIC = "v1/devices/me/attributes";
8
+ var MQTT_QOS = 1;
9
+
10
+ function ThreeDMCloudInNode(config) {
11
+ RED.nodes.createNode(this, config);
12
+
13
+ var node = this;
14
+
15
+ node.name = config.name;
16
+ node.server = RED.nodes.getNode(config.server);
17
+ node.keyFilter = config.keyFilter || "";
18
+ node.outputMode = config.outputMode || "value";
19
+
20
+ if (!node.server) {
21
+ node.status({ fill: "red", shape: "ring", text: "missing config" });
22
+ return;
23
+ }
24
+
25
+ function updateStatus(connected) {
26
+ if (connected) {
27
+ node.status({ fill: "green", shape: "dot", text: "connected" });
28
+ } else {
29
+ node.status({ fill: "red", shape: "ring", text: "disconnected" });
30
+ }
31
+ }
32
+
33
+ function parsePayload(payload) {
34
+ var text = payload.toString();
35
+
36
+ try {
37
+ return JSON.parse(text);
38
+ } catch (e) {
39
+ return text;
40
+ }
41
+ }
42
+
43
+ function hasOwn(obj, key) {
44
+ return Object.prototype.hasOwnProperty.call(obj, key);
45
+ }
46
+
47
+ var stateHandler = function (connected) {
48
+ updateStatus(connected);
49
+ };
50
+
51
+ var messageHandler = function (topic, payload) {
52
+ if (topic !== ATTRIBUTES_TOPIC) {
53
+ return;
54
+ }
55
+
56
+ var data = parsePayload(payload);
57
+
58
+ if (node.keyFilter) {
59
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
60
+ return;
61
+ }
62
+
63
+ if (!hasOwn(data, node.keyFilter)) {
64
+ return;
65
+ }
66
+
67
+ var msg = {
68
+ topic: node.keyFilter,
69
+ key: node.keyFilter,
70
+ value: data[node.keyFilter],
71
+ values: data
72
+ };
73
+
74
+ if (node.outputMode === "object") {
75
+ var obj = {};
76
+ obj[node.keyFilter] = data[node.keyFilter];
77
+ msg.payload = obj;
78
+ } else {
79
+ msg.payload = data[node.keyFilter];
80
+ }
81
+
82
+ node.send(msg);
83
+ return;
84
+ }
85
+
86
+ node.send({
87
+ topic: "3dm.attributes",
88
+ payload: data
89
+ });
90
+ };
91
+
92
+ node.server.on("state", stateHandler);
93
+ node.server.on("cloud-message", messageHandler);
94
+
95
+ node.server.subscribe(ATTRIBUTES_TOPIC, { qos: MQTT_QOS }, function (err) {
96
+ if (err) {
97
+ node.status({ fill: "red", shape: "ring", text: "subscribe failed" });
98
+ node.warn("3DM Cloud attribute subscribe failed: " + (err.message || err));
99
+ } else {
100
+ updateStatus(node.server.connected);
101
+ }
102
+ });
103
+
104
+ updateStatus(node.server.connected);
105
+
106
+ node.on("close", function () {
107
+ if (node.server) {
108
+ node.server.removeListener("state", stateHandler);
109
+ node.server.removeListener("cloud-message", messageHandler);
110
+ }
111
+ });
112
+ }
113
+
114
+ RED.nodes.registerType("3dm-cloud-in", ThreeDMCloudInNode);
115
+ };
@@ -0,0 +1,40 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("3dm-cloud-out", {
3
+ category: "3DM.space",
4
+ color: "#656565",
5
+ defaults: {
6
+ name: { value: "" },
7
+ server: { value: "", type: "3dm-cloud-config", required: true }
8
+ },
9
+ inputs: 1,
10
+ outputs: 1,
11
+ icon: "icon.png",
12
+ label: function () {
13
+ return this.name || "3DM telemetry out";
14
+ },
15
+ paletteLabel: "3DM out"
16
+ });
17
+ </script>
18
+
19
+ <script type="text/html" data-template-name="3dm-cloud-out">
20
+ <div class="form-row">
21
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
22
+ <input type="text" id="node-input-name" placeholder="3DM telemetry out">
23
+ </div>
24
+
25
+ <div class="form-row">
26
+ <label for="node-input-server"><i class="fa fa-cloud"></i> Login</label>
27
+ <input type="text" id="node-input-server">
28
+ </div>
29
+ </script>
30
+
31
+ <script type="text/html" data-help-name="3dm-cloud-out">
32
+ <p>Sends JSON telemetry to 3DM.space SCADA Cloud.</p>
33
+
34
+ <p>Input payload should be a JSON object:</p>
35
+ <pre>{"tank_level":74,"pump_run":true,"battery":12.8}</pre>
36
+
37
+ <p>The node automatically adds the timestamp and sends it to the cloud.</p>
38
+
39
+ <p>The node is rate limited to prevent publishing too fast.</p>
40
+ </script>
@@ -0,0 +1,669 @@
1
+ module.exports = function (RED) {
2
+ "use strict";
3
+
4
+ var fs = require("fs");
5
+ var path = require("path");
6
+ var Queue = require("node-persistent-queue");
7
+
8
+ /*
9
+ * Hidden cloud publish settings.
10
+ */
11
+ var TELEMETRY_TOPIC = "v1/devices/me/telemetry";
12
+ var MQTT_QOS = 1;
13
+
14
+ /*
15
+ * Store and forward settings.
16
+ */
17
+ var STORE_AND_FORWARD_ENABLED = true;
18
+
19
+ /*
20
+ * SQLite-backed persistent queue file.
21
+ * This keeps the queue in its own cupboard.
22
+ */
23
+ var QUEUE_DIR = "/home/pi/.node-red/queues";
24
+ var QUEUE_FILE = path.join(QUEUE_DIR, "3dm-space-telemetry.sqlite");
25
+
26
+ /*
27
+ * Queue protection.
28
+ *
29
+ * 10000 packets at 250ms replay speed takes about 42 minutes to clear.
30
+ * Increase this later if you want more offline history.
31
+ */
32
+ var MAX_QUEUE_MESSAGES = 10000;
33
+ var QUEUE_HIGH_WATERMARK = 8000;
34
+
35
+ /*
36
+ * Replay/offload speed.
37
+ * 250ms = 4 packets per second.
38
+ */
39
+ var QUEUE_REPLAY_GAP_MS = 250;
40
+
41
+ /*
42
+ * Live publish throttle.
43
+ *
44
+ * This does not delete messages.
45
+ * If live messages arrive too fast, they are queued instead.
46
+ *
47
+ * 10000 = 10 seconds
48
+ * 1000 = 1 second for testing
49
+ * 100 = very fast dev testing
50
+ */
51
+ var MIN_PUBLISH_INTERVAL_MS = 10000;
52
+
53
+ var RATE_LIMIT_TEXT = "rate limited: queued";
54
+ var QUEUE_FULL_TEXT = "queue full: message rejected";
55
+ var BAD_PAYLOAD_TEXT = "bad payload";
56
+
57
+ function cleanMkdir(dir) {
58
+ try {
59
+ if (!fs.existsSync(dir)) {
60
+ fs.mkdirSync(dir, { recursive: true });
61
+ }
62
+ } catch (err) {
63
+ /*
64
+ * Older Node versions may not like recursive.
65
+ * Try one level fallback.
66
+ */
67
+ try {
68
+ fs.mkdirSync(dir);
69
+ } catch (err2) {
70
+ throw err;
71
+ }
72
+ }
73
+ }
74
+
75
+ function ThreeDMCloudOutNode(config) {
76
+ RED.nodes.createNode(this, config);
77
+
78
+ var node = this;
79
+
80
+ node.name = config.name;
81
+ node.server = RED.nodes.getNode(config.server);
82
+
83
+ node.lastLivePublishTime = 0;
84
+ node.queueOpen = false;
85
+ node.replaying = false;
86
+ node.lastStatusText = "";
87
+ node.lastWarnText = "";
88
+ node.lastWarnTime = 0;
89
+
90
+ var initJobs = [];
91
+ var queue = null;
92
+ var replayTimer = null;
93
+
94
+ if (!node.server) {
95
+ node.status({ fill: "red", shape: "ring", text: "missing config" });
96
+ return;
97
+ }
98
+
99
+ function warnOncePerMinute(text) {
100
+ var now = Date.now();
101
+
102
+ if (text !== node.lastWarnText || (now - node.lastWarnTime) > 60000) {
103
+ node.lastWarnText = text;
104
+ node.lastWarnTime = now;
105
+ node.warn(text);
106
+ }
107
+ }
108
+
109
+ function queueCount() {
110
+ if (!queue || !node.queueOpen) {
111
+ return 0;
112
+ }
113
+
114
+ try {
115
+ return queue.getLength();
116
+ } catch (err) {
117
+ return 0;
118
+ }
119
+ }
120
+
121
+ function queueIsEmpty() {
122
+ if (!queue || !node.queueOpen) {
123
+ return true;
124
+ }
125
+
126
+ try {
127
+ return queue.isEmpty();
128
+ } catch (err) {
129
+ return true;
130
+ }
131
+ }
132
+
133
+ function setStatus(fill, shape, text) {
134
+ /*
135
+ * Avoid hammering Node-RED status updates.
136
+ */
137
+ if (node.lastStatusText !== fill + "|" + shape + "|" + text) {
138
+ node.lastStatusText = fill + "|" + shape + "|" + text;
139
+ node.status({ fill: fill, shape: shape, text: text });
140
+ }
141
+ }
142
+
143
+ function updateStatus(reason) {
144
+ var count = queueCount();
145
+
146
+ if (!node.server.connected) {
147
+ setStatus("yellow", "ring", "storing offline (" + count + ")");
148
+ return;
149
+ }
150
+
151
+ if (node.replaying && count > 0) {
152
+ setStatus("green", "ring", "replaying (" + count + ")");
153
+ return;
154
+ }
155
+
156
+ if (count >= QUEUE_HIGH_WATERMARK) {
157
+ setStatus("yellow", "ring", "queue high (" + count + ")");
158
+ return;
159
+ }
160
+
161
+ if (count > 0) {
162
+ setStatus("green", "ring", "queued (" + count + ")");
163
+ return;
164
+ }
165
+
166
+ if (reason) {
167
+ setStatus("green", "dot", reason);
168
+ } else {
169
+ setStatus("green", "dot", "connected");
170
+ }
171
+ }
172
+
173
+ function buildTelemetryPacket(msg) {
174
+ var payload = msg.payload;
175
+
176
+ if (payload === null || payload === undefined) {
177
+ throw new Error("payload is empty");
178
+ }
179
+
180
+ if (typeof payload === "string") {
181
+ try {
182
+ payload = JSON.parse(payload);
183
+ } catch (e) {
184
+ throw new Error("payload must be a JSON object");
185
+ }
186
+ }
187
+
188
+ if (typeof payload !== "object" || Array.isArray(payload)) {
189
+ throw new Error("payload must be a JSON object");
190
+ }
191
+
192
+ /*
193
+ * Timestamp is created when the message arrives at the out node.
194
+ * Do not recreate this timestamp during replay.
195
+ */
196
+ var ts = Date.now();
197
+
198
+ if (msg.ts) {
199
+ ts = Number(msg.ts);
200
+ } else if (msg.timestamp) {
201
+ ts = Number(msg.timestamp);
202
+ }
203
+
204
+ if (isNaN(ts)) {
205
+ ts = Date.now();
206
+ }
207
+
208
+ return {
209
+ ts: ts,
210
+ values: payload
211
+ };
212
+ }
213
+
214
+ function isLiveRateLimited() {
215
+ var now = Date.now();
216
+ var elapsed = now - node.lastLivePublishTime;
217
+
218
+ if (elapsed < MIN_PUBLISH_INTERVAL_MS) {
219
+ return {
220
+ limited: true,
221
+ remainingMs: MIN_PUBLISH_INTERVAL_MS - elapsed
222
+ };
223
+ }
224
+
225
+ return {
226
+ limited: false,
227
+ remainingMs: 0
228
+ };
229
+ }
230
+
231
+ function makeJob(packet) {
232
+ return {
233
+ topic: TELEMETRY_TOPIC,
234
+ qos: MQTT_QOS,
235
+ packet: packet,
236
+ createdAt: Date.now()
237
+ };
238
+ }
239
+
240
+ function publishPacket(packet, callback) {
241
+ if (!node.server || !node.server.connected) {
242
+ callback(new Error("3DM Cloud is not connected"));
243
+ return;
244
+ }
245
+
246
+ node.server.publish(
247
+ TELEMETRY_TOPIC,
248
+ packet,
249
+ { qos: MQTT_QOS },
250
+ callback
251
+ );
252
+ }
253
+
254
+ function canQueueNewMessage() {
255
+ var count = queueCount();
256
+
257
+ if (count >= MAX_QUEUE_MESSAGES) {
258
+ return false;
259
+ }
260
+
261
+ return true;
262
+ }
263
+
264
+ function addJobToQueue(job, reason, msg, send, done) {
265
+ if (!STORE_AND_FORWARD_ENABLED) {
266
+ msg.sent = false;
267
+ msg.queued = false;
268
+ msg.error = "store and forward disabled";
269
+ msg.topic = "3dm.telemetry";
270
+
271
+ send(msg);
272
+
273
+ if (done) {
274
+ done();
275
+ }
276
+
277
+ return;
278
+ }
279
+
280
+ if (!queue || !node.queueOpen) {
281
+ /*
282
+ * Queue not ready yet.
283
+ * Keep timestamped job in memory so it does not lose its original time.
284
+ */
285
+ initJobs.push({
286
+ job: job,
287
+ reason: reason
288
+ });
289
+
290
+ msg.sent = false;
291
+ msg.queued = true;
292
+ msg.queueReason = reason;
293
+ msg.topic = "3dm.telemetry";
294
+
295
+ setStatus("yellow", "ring", "queue opening (" + initJobs.length + ")");
296
+ send(msg);
297
+
298
+ if (done) {
299
+ done();
300
+ }
301
+
302
+ return;
303
+ }
304
+
305
+ if (!canQueueNewMessage()) {
306
+ msg.sent = false;
307
+ msg.queued = false;
308
+ msg.queueFull = true;
309
+ msg.error = QUEUE_FULL_TEXT;
310
+ msg.topic = "3dm.telemetry";
311
+
312
+ setStatus("red", "ring", QUEUE_FULL_TEXT + " (" + queueCount() + ")");
313
+ warnOncePerMinute(QUEUE_FULL_TEXT + ". Limit is " + MAX_QUEUE_MESSAGES + " packets.");
314
+
315
+ send(msg);
316
+
317
+ if (done) {
318
+ done();
319
+ }
320
+
321
+ return;
322
+ }
323
+
324
+ try {
325
+ queue.add(job);
326
+
327
+ msg.sent = false;
328
+ msg.queued = true;
329
+ msg.queueReason = reason;
330
+ msg.queueLength = queueCount();
331
+ msg.topic = "3dm.telemetry";
332
+
333
+ if (reason === "rate-limited") {
334
+ setStatus("yellow", "ring", RATE_LIMIT_TEXT + " (" + queueCount() + ")");
335
+ } else if (reason === "offline") {
336
+ setStatus("yellow", "ring", "storing offline (" + queueCount() + ")");
337
+ } else {
338
+ setStatus("yellow", "ring", "queued (" + queueCount() + ")");
339
+ }
340
+
341
+ if (queueCount() >= QUEUE_HIGH_WATERMARK) {
342
+ warnOncePerMinute("3DM Cloud queue high: " + queueCount() + " packets.");
343
+ }
344
+
345
+ send(msg);
346
+
347
+ if (done) {
348
+ done();
349
+ }
350
+
351
+ maybeStartReplay();
352
+ } catch (err) {
353
+ msg.sent = false;
354
+ msg.queued = false;
355
+ msg.error = err.message || String(err);
356
+ msg.topic = "3dm.telemetry";
357
+
358
+ setStatus("red", "ring", "queue error");
359
+ node.error("3DM queue add failed: " + msg.error, msg);
360
+
361
+ send(msg);
362
+
363
+ if (done) {
364
+ done();
365
+ }
366
+ }
367
+ }
368
+
369
+ function maybeStartReplay() {
370
+ if (!queue || !node.queueOpen) {
371
+ return;
372
+ }
373
+
374
+ if (!node.server || !node.server.connected) {
375
+ return;
376
+ }
377
+
378
+ if (queueIsEmpty()) {
379
+ node.replaying = false;
380
+ updateStatus("connected");
381
+ return;
382
+ }
383
+
384
+ if (!queue.isStarted()) {
385
+ node.replaying = true;
386
+ updateStatus();
387
+ queue.start();
388
+ }
389
+ }
390
+
391
+ function stopReplay() {
392
+ if (replayTimer) {
393
+ clearTimeout(replayTimer);
394
+ replayTimer = null;
395
+ }
396
+
397
+ if (queue && node.queueOpen) {
398
+ try {
399
+ if (queue.isStarted()) {
400
+ queue.stop();
401
+ }
402
+ } catch (err) {
403
+ /*
404
+ * Ignore stop race.
405
+ */
406
+ }
407
+ }
408
+
409
+ node.replaying = false;
410
+ updateStatus();
411
+ }
412
+
413
+ function setupQueue() {
414
+ if (!STORE_AND_FORWARD_ENABLED) {
415
+ updateStatus();
416
+ return;
417
+ }
418
+
419
+ try {
420
+ cleanMkdir(QUEUE_DIR);
421
+ } catch (err) {
422
+ setStatus("red", "ring", "queue dir error");
423
+ node.error("3DM queue directory error: " + (err.message || err));
424
+ return;
425
+ }
426
+
427
+ queue = new Queue(QUEUE_FILE);
428
+
429
+ queue.on("open", function () {
430
+ node.queueOpen = true;
431
+ node.log("3DM Cloud queue opened: " + QUEUE_FILE);
432
+
433
+ if (initJobs.length > 0) {
434
+ initJobs.forEach(function (item) {
435
+ if (canQueueNewMessage()) {
436
+ queue.add(item.job);
437
+ } else {
438
+ warnOncePerMinute(QUEUE_FULL_TEXT + " while loading startup messages.");
439
+ }
440
+ });
441
+
442
+ initJobs = [];
443
+ }
444
+
445
+ updateStatus();
446
+ maybeStartReplay();
447
+ });
448
+
449
+ queue.on("next", function (item) {
450
+ var job = item.job || item;
451
+
452
+ if (!job || !job.packet) {
453
+ /*
454
+ * Bad stored job. Remove it so it does not jam the queue forever.
455
+ */
456
+ warnOncePerMinute("3DM queue found invalid stored packet. Removing it.");
457
+ replayTimer = setTimeout(function () {
458
+ replayTimer = null;
459
+ queue.done();
460
+ }, QUEUE_REPLAY_GAP_MS);
461
+ return;
462
+ }
463
+
464
+ if (!node.server || !node.server.connected) {
465
+ stopReplay();
466
+ return;
467
+ }
468
+
469
+ node.replaying = true;
470
+ updateStatus();
471
+
472
+ publishPacket(job.packet, function (err) {
473
+ if (err) {
474
+ /*
475
+ * Do not call queue.done().
476
+ * This keeps the packet stored for the next retry.
477
+ */
478
+ warnOncePerMinute("3DM Cloud replay stopped: " + (err.message || err));
479
+ stopReplay();
480
+ return;
481
+ }
482
+
483
+ replayTimer = setTimeout(function () {
484
+ replayTimer = null;
485
+
486
+ try {
487
+ queue.done();
488
+ } catch (doneErr) {
489
+ node.error("3DM queue done failed: " + (doneErr.message || doneErr));
490
+ }
491
+
492
+ updateStatus("replayed");
493
+ }, QUEUE_REPLAY_GAP_MS);
494
+ });
495
+ });
496
+
497
+ queue.on("start", function () {
498
+ node.replaying = true;
499
+ updateStatus();
500
+ });
501
+
502
+ queue.on("stop", function () {
503
+ node.replaying = false;
504
+ updateStatus();
505
+ });
506
+
507
+ queue.on("empty", function () {
508
+ node.replaying = false;
509
+ updateStatus("connected");
510
+ });
511
+
512
+ queue.on("add", function () {
513
+ updateStatus();
514
+ });
515
+
516
+ queue
517
+ .open()
518
+ .then(function () {
519
+ /*
520
+ * Some versions emit open, some also resolve here.
521
+ * The open handler does the real work.
522
+ */
523
+ })
524
+ .catch(function (err) {
525
+ setStatus("red", "ring", "queue open failed");
526
+ node.error("3DM queue failed to open " + QUEUE_FILE + ": " + (err.message || err));
527
+ });
528
+ }
529
+
530
+ var stateHandler = function (connected) {
531
+ if (connected) {
532
+ updateStatus("connected");
533
+ maybeStartReplay();
534
+ } else {
535
+ stopReplay();
536
+ updateStatus();
537
+ }
538
+ };
539
+
540
+ node.server.on("state", stateHandler);
541
+
542
+ setupQueue();
543
+
544
+ updateStatus();
545
+
546
+ node.on("input", function (msg, send, done) {
547
+ send = send || function () {
548
+ node.send.apply(node, arguments);
549
+ };
550
+
551
+ try {
552
+ var originalPayload = msg.payload;
553
+ var packet = buildTelemetryPacket(msg);
554
+ var job = makeJob(packet);
555
+
556
+ /*
557
+ * If cloud is offline, store immediately.
558
+ */
559
+ if (!node.server || !node.server.connected) {
560
+ msg.payload = originalPayload;
561
+ addJobToQueue(job, "offline", msg, send, done);
562
+ return;
563
+ }
564
+
565
+ /*
566
+ * If queue already has data, queue new messages too.
567
+ * This keeps replay/order sane.
568
+ */
569
+ if (!queueIsEmpty() || node.replaying) {
570
+ msg.payload = originalPayload;
571
+ addJobToQueue(job, "queue-not-empty", msg, send, done);
572
+ return;
573
+ }
574
+
575
+ /*
576
+ * Live throttle.
577
+ * Do not reject good telemetry.
578
+ * Queue it with the original timestamp instead.
579
+ */
580
+ var limit = isLiveRateLimited();
581
+
582
+ if (limit.limited) {
583
+ msg.payload = originalPayload;
584
+ msg.rateLimited = true;
585
+ msg.rateLimitRemainingMs = limit.remainingMs;
586
+ addJobToQueue(job, "rate-limited", msg, send, done);
587
+ return;
588
+ }
589
+
590
+ /*
591
+ * Publish live now.
592
+ */
593
+ node.lastLivePublishTime = Date.now();
594
+
595
+ publishPacket(packet, function (err) {
596
+ if (err) {
597
+ msg.payload = originalPayload;
598
+ addJobToQueue(job, "publish-failed", msg, send, done);
599
+ return;
600
+ }
601
+
602
+ setStatus("green", "dot", "sent");
603
+
604
+ msg.payload = originalPayload;
605
+ msg.sent = true;
606
+ msg.queued = false;
607
+ msg.rateLimited = false;
608
+ msg.topic = "3dm.telemetry";
609
+
610
+ send(msg);
611
+
612
+ if (done) {
613
+ done();
614
+ }
615
+ });
616
+ } catch (err) {
617
+ setStatus("red", "ring", BAD_PAYLOAD_TEXT);
618
+
619
+ msg.sent = false;
620
+ msg.queued = false;
621
+ msg.rateLimited = false;
622
+ msg.error = err.message || String(err);
623
+ msg.topic = "3dm.telemetry";
624
+
625
+ warnOncePerMinute("3DM Cloud telemetry bad payload: " + msg.error);
626
+
627
+ send(msg);
628
+
629
+ if (done) {
630
+ done();
631
+ }
632
+ }
633
+ });
634
+
635
+ node.on("close", function (removed, done) {
636
+ if (node.server) {
637
+ node.server.removeListener("state", stateHandler);
638
+ }
639
+
640
+ if (replayTimer) {
641
+ clearTimeout(replayTimer);
642
+ replayTimer = null;
643
+ }
644
+
645
+ if (queue && node.queueOpen) {
646
+ try {
647
+ if (queue.isStarted()) {
648
+ queue.stop();
649
+ }
650
+
651
+ queue
652
+ .close()
653
+ .then(function () {
654
+ done();
655
+ })
656
+ .catch(function (err) {
657
+ done(err);
658
+ });
659
+ } catch (err) {
660
+ done(err);
661
+ }
662
+ } else {
663
+ done();
664
+ }
665
+ });
666
+ }
667
+
668
+ RED.nodes.registerType("3dm-cloud-out", ThreeDMCloudOutNode);
669
+ };
package/icon.png ADDED
Binary file
package/icons/icon.png ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "node-red-contrib-3dm-space",
3
+ "version": "0.0.2",
4
+ "description": "Node-RED nodes for 3DM.space SCADA Cloud telemetry and attributes",
5
+ "main": "3dm-cloud-config.js",
6
+ "keywords": [
7
+ "node-red",
8
+ "3dm",
9
+ "3dm.space",
10
+ "scada",
11
+ "telemetry",
12
+ "store-and-forward"
13
+ ],
14
+ "node-red": {
15
+ "nodes": {
16
+ "3dm-cloud-config": "3dm-cloud-config.js",
17
+ "3dm-cloud-out": "3dm-cloud-out.js",
18
+ "3dm-cloud-in": "3dm-cloud-in.js"
19
+ }
20
+ },
21
+ "dependencies": {
22
+ "mqtt": "~4.3.7",
23
+ "node-persistent-queue": "^0.3.1"
24
+ }
25
+ }