node-red-contrib-3dm-space 2.3.0 → 2.4.0

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.
package/3dm-cloud-in.html CHANGED
@@ -6,7 +6,9 @@
6
6
  name: { value: "" },
7
7
  server: { value: "", type: "3dm-cloud-config", required: true },
8
8
  keyFilter: { value: "" },
9
- outputMode: { value: "value" }
9
+ outputMode: { value: "value" },
10
+ subscribeLastValues: { value: false },
11
+ onlyChanges: { value: false }
10
12
  },
11
13
  inputs: 0,
12
14
  outputs: 1,
@@ -49,6 +51,14 @@
49
51
  <option value="object">Object with key</option>
50
52
  </select>
51
53
  </div>
54
+ <div class="form-row">
55
+ <input type="checkbox" id="node-input-subscribeLastValues" style="display:inline-block; width:auto; margin-left:105px; vertical-align:top;">
56
+ <label for="node-input-subscribeLastValues" style="width:70%;">Subscribe to last values</label>
57
+ </div>
58
+ <div class="form-row">
59
+ <input type="checkbox" id="node-input-onlyChanges" style="display:inline-block; width:auto; margin-left:105px; vertical-align:top;">
60
+ <label for="node-input-onlyChanges" style="width:70%;">Only pass changed values</label>
61
+ </div>
52
62
  </script>
53
63
 
54
64
  <script type="text/html" data-help-name="3dm-cloud-in">
@@ -68,4 +78,8 @@
68
78
  <li><b>Value only</b>: outputs only the selected value.</li>
69
79
  <li><b>Object with key</b>: outputs a JSON object containing the selected key and value.</li>
70
80
  </ul>
81
+ <p><b>Subscribe to last values</b> (off by default): requests saved shared attributes once on connection and on each reconnect, then continues receiving live updates. With an Attribute Key, requests that key; otherwise requests all shared attributes. This restores saved settings, not missed command history or telemetry.</p>
82
+ <p><b>Only pass changed values</b> (off by default): suppresses repeats of the selected key, comparing after five-decimal rounding. With no key, remembers values per attribute and suppresses a message only when every included key is unchanged. When any key changes, the complete received attribute object is passed through. Nested object key order is ignored; array order and value types matter.</p>
83
+ <p>The first value passes. Memory survives reconnects, but resets when this node is redeployed or Node-RED restarts. Do not enable change filtering for repeated one-shot commands that must run each time.</p>
84
+ <p>No polling or disk writes are added. Change memory is capped at 1,024 keys and 1 MiB of serialized key/value bytes per node (actual JavaScript heap usage is higher). Evicted or oversized values may pass again. A saved-value request times out after 15 seconds without repeated application retries; live updates remain active.</p>
71
85
  </script>
package/3dm-cloud-in.js CHANGED
@@ -8,6 +8,21 @@ module.exports = function (RED) {
8
8
  */
9
9
  var ATTRIBUTES_TOPIC = "v1/devices/me/attributes";
10
10
  var MQTT_QOS = 1;
11
+ var nextRequestId = 0;
12
+ var CACHE_MAX_KEYS = 1024;
13
+ var CACHE_MAX_BYTES = 1024 * 1024;
14
+
15
+ function stableJson(value) {
16
+ if (Array.isArray(value)) {
17
+ return "[" + value.map(stableJson).join(",") + "]";
18
+ }
19
+ if (value && typeof value === "object") {
20
+ return "{" + Object.keys(value).sort().map(function (key) {
21
+ return JSON.stringify(key) + ":" + stableJson(value[key]);
22
+ }).join(",") + "}";
23
+ }
24
+ return JSON.stringify(value);
25
+ }
11
26
 
12
27
  function ThreeDMCloudInNode(config) {
13
28
  RED.nodes.createNode(this, config);
@@ -18,6 +33,46 @@ module.exports = function (RED) {
18
33
  node.server = RED.nodes.getNode(config.server);
19
34
  node.keyFilter = config.keyFilter || "";
20
35
  node.outputMode = config.outputMode || "value";
36
+ node.subscribeLastValues = config.subscribeLastValues === true || config.subscribeLastValues === "true";
37
+ node.onlyChanges = config.onlyChanges === true || config.onlyChanges === "true";
38
+
39
+ var closed = false;
40
+ var connected = false;
41
+ var generation = 0;
42
+ var pending = null;
43
+ var cache = new Map();
44
+ var cacheBytes = 0;
45
+
46
+ function clearPending() {
47
+ if (pending && pending.timer) {
48
+ clearTimeout(pending.timer);
49
+ }
50
+ pending = null;
51
+ }
52
+
53
+ function changed(data) {
54
+ if (!node.onlyChanges) { return true; }
55
+ var object = data && typeof data === "object" && !Array.isArray(data);
56
+ var keys = node.keyFilter ? [node.keyFilter] : (object ? Object.keys(data) : [""]);
57
+ var anyChanged = false;
58
+ keys.forEach(function (key) {
59
+ var value = node.keyFilter || object ? data[key] : data;
60
+ var serialized = stableJson(value);
61
+ var previous = cache.get(key);
62
+ if (!previous || previous.value !== serialized) { anyChanged = true; }
63
+ if (previous) { cacheBytes -= previous.bytes; cache.delete(key); }
64
+ var bytes = Buffer.byteLength(key) + Buffer.byteLength(serialized);
65
+ if (bytes > CACHE_MAX_BYTES) { return; }
66
+ while (cache.size >= CACHE_MAX_KEYS || cacheBytes + bytes > CACHE_MAX_BYTES) {
67
+ var oldest = cache.keys().next().value;
68
+ cacheBytes -= cache.get(oldest).bytes;
69
+ cache.delete(oldest);
70
+ }
71
+ cache.set(key, { value: serialized, bytes: bytes });
72
+ cacheBytes += bytes;
73
+ });
74
+ return anyChanged;
75
+ }
21
76
 
22
77
  if (!node.server) {
23
78
  node.status({ fill: "red", shape: "ring", text: "missing config" });
@@ -46,17 +101,7 @@ module.exports = function (RED) {
46
101
  return Object.prototype.hasOwnProperty.call(obj, key);
47
102
  }
48
103
 
49
- var stateHandler = function (connected) {
50
- updateStatus(connected);
51
- };
52
-
53
- var messageHandler = function (topic, payload) {
54
- if (topic !== ATTRIBUTES_TOPIC) {
55
- return;
56
- }
57
-
58
- var data = roundPayload(parsePayload(payload));
59
-
104
+ function outputData(data) {
60
105
  if (node.keyFilter) {
61
106
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
62
107
  return;
@@ -66,6 +111,8 @@ module.exports = function (RED) {
66
111
  return;
67
112
  }
68
113
 
114
+ if (!changed(data)) { return; }
115
+
69
116
  var msg = {
70
117
  topic: node.keyFilter,
71
118
  key: node.keyFilter,
@@ -85,31 +132,114 @@ module.exports = function (RED) {
85
132
  return;
86
133
  }
87
134
 
135
+ if (!changed(data)) { return; }
88
136
  node.send({
89
137
  topic: "3dm.attributes",
90
138
  payload: data
91
139
  });
92
140
  };
93
141
 
94
- node.server.on("state", stateHandler);
95
- node.server.on("cloud-message", messageHandler);
142
+ function requestLastValues(epoch) {
143
+ if (!node.subscribeLastValues || closed || !connected || epoch !== generation) { return; }
144
+ node.server.subscribe("v1/devices/me/attributes/response/+", { qos: MQTT_QOS }, function (err) {
145
+ if (closed || !connected || epoch !== generation) { return; }
146
+ if (err) {
147
+ node.warn("3DM Cloud saved attribute response subscribe failed: " + (err.message || err));
148
+ return;
149
+ }
150
+ var id = String(++nextRequestId);
151
+ pending = { id: id, touched: new Set(), touchedBytes: 0, discard: false };
152
+ pending.timer = setTimeout(function () {
153
+ clearPending();
154
+ if (!closed) { node.warn("3DM Cloud saved attribute request timed out; live updates remain active."); }
155
+ }, 15000);
156
+ var request = { sharedKeys: node.keyFilter };
157
+ node.server.publish("v1/devices/me/attributes/request/" + id, request, { qos: MQTT_QOS }, function (error) {
158
+ if (error && pending && pending.id === id) {
159
+ clearPending();
160
+ node.warn("3DM Cloud saved attribute request failed: " + (error.message || error));
161
+ }
162
+ });
163
+ });
164
+ }
96
165
 
97
- node.server.subscribe(ATTRIBUTES_TOPIC, { qos: MQTT_QOS }, function (err) {
98
- if (err) {
99
- node.status({ fill: "red", shape: "ring", text: "subscribe failed" });
100
- node.warn("3DM Cloud attribute subscribe failed: " + (err.message || err));
101
- } else {
102
- updateStatus(node.server.connected);
166
+ var stateHandler = function (online) {
167
+ if (closed) { return; }
168
+ online = Boolean(online);
169
+ updateStatus(online);
170
+ if (online === connected) { return; }
171
+ connected = online;
172
+ generation += 1;
173
+ clearPending();
174
+ if (!online) { return; }
175
+ var epoch = generation;
176
+ // Subscribe to live changes first, then await the response subscription ACK.
177
+ node.server.subscribe(ATTRIBUTES_TOPIC, { qos: MQTT_QOS }, function (err) {
178
+ if (closed || !connected || epoch !== generation) { return; }
179
+ if (err) {
180
+ node.status({ fill: "red", shape: "ring", text: "subscribe failed" });
181
+ node.warn("3DM Cloud attribute subscribe failed: " + (err.message || err));
182
+ return;
183
+ }
184
+ requestLastValues(epoch);
185
+ });
186
+ };
187
+
188
+ var messageHandler = function (topic, payload) {
189
+ if (closed) { return; }
190
+ if (topic === ATTRIBUTES_TOPIC) {
191
+ var live = roundPayload(parsePayload(payload));
192
+ if (pending && live && typeof live === "object") {
193
+ Object.keys(live).forEach(function (key) {
194
+ if (pending.touched.has(key) || pending.discard) { return; }
195
+ var bytes = Buffer.byteLength(key);
196
+ if (pending.touched.size >= CACHE_MAX_KEYS || pending.touchedBytes + bytes > CACHE_MAX_BYTES) {
197
+ pending.discard = true;
198
+ } else {
199
+ pending.touched.add(key);
200
+ pending.touchedBytes += bytes;
201
+ }
202
+ });
203
+ }
204
+ outputData(live);
205
+ return;
103
206
  }
104
- });
207
+ if (!pending || topic !== "v1/devices/me/attributes/response/" + pending.id) { return; }
208
+ var response = parsePayload(payload);
209
+ var active = pending;
210
+ clearPending();
211
+ if (response && response.error) {
212
+ node.warn("3DM Cloud saved attribute response error: " + String(response.error));
213
+ return;
214
+ }
215
+ if (!response || typeof response !== "object" || !response.shared ||
216
+ typeof response.shared !== "object" || Array.isArray(response.shared) || active.discard) { return; }
217
+ var data = roundPayload(response.shared);
218
+ active.touched.forEach(function (key) { delete data[key]; });
219
+ if (Object.keys(data).length > 0) { outputData(data); }
220
+ };
221
+
222
+ node.server.on("state", stateHandler);
223
+ node.server.on("cloud-message", messageHandler);
105
224
 
106
- updateStatus(node.server.connected);
225
+ // Register the live subscription even when initially offline.
226
+ if (node.server.connected) {
227
+ stateHandler(true);
228
+ } else {
229
+ node.server.subscribe(ATTRIBUTES_TOPIC, { qos: MQTT_QOS }, function (err) {
230
+ if (!closed && err) { node.warn("3DM Cloud attribute subscribe failed: " + (err.message || err)); }
231
+ });
232
+ updateStatus(false);
233
+ }
107
234
 
108
235
  node.on("close", function () {
109
- if (node.server) {
110
- node.server.removeListener("state", stateHandler);
111
- node.server.removeListener("cloud-message", messageHandler);
112
- }
236
+ closed = true;
237
+ generation += 1;
238
+ clearPending();
239
+ cache.clear();
240
+ cacheBytes = 0;
241
+ node.server.removeListener("state", stateHandler);
242
+ node.server.removeListener("cloud-message", messageHandler);
113
243
  });
114
244
  }
115
245
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.0 — 2026-10-20
4
+
5
+ - Add optional Subscribe to last values to 3DM In for initial connect and reconnect recovery of saved shared attributes.
6
+ - Add optional Only pass changed values, retaining a bounded per-node comparison cache across communications disconnects.
7
+ - Keep both options off by default and preserve existing output formats, five-decimal precision, cloud-out status pin, and Smithtek proprietary licence.
8
+ - Correlate response IDs, wait for subscription acknowledgement, ignore obsolete responses, protect newer live values, and clear request timers on disconnect/close.
9
+ - Document memory limits, local cache measurements, server/network impact, and command-filtering behaviour.
10
+
11
+
3
12
  ## 2.3.0 — 2026-10-18
4
13
 
5
14
  - Add a second 3DM Out output for boolean cloud connection status: true online, false offline, on deployment and state changes.
package/README.md CHANGED
@@ -577,6 +577,41 @@ Use this for:
577
577
 
578
578
  ---
579
579
 
580
+ ## 3DM In Saved Values and Change Filtering
581
+
582
+ Version 2.4.0 adds two independent checkboxes. Both default to **off**, preserving existing flows.
583
+
584
+ ### Subscribe to last values
585
+
586
+ When enabled, the node requests the current saved **shared attributes** on initial connection and on every reconnect. With an Attribute Key selected, it requests that key only. With no key, it requests all shared attributes. Restored values use the existing Output Type and five-decimal precision settings, then normal live updates continue.
587
+
588
+ This reads saved state, not historical telemetry or a replay of every missed command. The response subscription is acknowledged before the request is published. Requests have unique IDs so nodes sharing a login only handle their own replies. A 15-second response timeout clears the pending request and logs a warning; live updates continue. There is no periodic polling or repeated application retry loop. The next reconnect requests again. A live value received while awaiting a snapshot takes precedence over that snapshot for the same key. Temporary live-key tracking is bounded to 1,024 keys and 1 MiB of key-name bytes. If that limit is exceeded before the response, the snapshot is discarded to avoid overwriting newer live values.
589
+
590
+ ### Only pass changed values
591
+
592
+ When enabled, the first observed value passes. Later messages pass only when the selected key changes, including values restored after reconnect. Values are compared after rounding to five decimal places. Types matter (for example, false and 0 differ). Nested object property order is ignored; array order matters.
593
+
594
+ With no Attribute Key, the node remembers each received attribute separately. A message is suppressed only if every included key is unchanged. When any key is new or changed, the complete received attribute object passes, including any unchanged keys in that message. Missing keys in a partial update are not interpreted as deleted attributes.
595
+
596
+ The cache survives a communications disconnect. It resets when this node is recreated by a deployment or Node-RED restarts, so the first value then passes again. Do not enable this option for repeated one-shot commands that must execute each time even if the value is identical. Comparing values does not provide command execution acknowledgement.
597
+
598
+ ### Node-RED memory and CPU impact
599
+
600
+ - No new MQTT connection, disk storage, persistent history, or polling loop is added.
601
+ - With change filtering off, no previous attribute values are retained. Saved-value fetching holds one pending request ID, one timeout, and temporary tracking of live keys while awaiting its reply.
602
+ - With change filtering on, each node retains only its latest comparison value per relevant attribute. The cache is limited to **1,024 keys** and **1 MiB of serialized UTF-8 key/value bytes**, whichever is reached first. Actual JavaScript heap use is higher because of strings, maps, and object overhead; the 1 MiB limit is not a process-memory limit. Parsing, rounding, and comparison also allocate temporary memory proportional to incoming payload size.
603
+ - Oversized values are passed without being cached. Least-recently-used keys are evicted to stay within the limits. These values may therefore pass again even if unchanged.
604
+ - Synthetic Node.js v22.17.0 tests measured roughly **0.1 KiB** additional retained heap for populating one boolean cache entry, **10 KiB** for one 10 KiB string, and **9.4 KiB** for 100 numeric attributes. These are approximate cache-population measurements after node creation, excluding the base node, empty cache, Node-RED runtime, MQTT connection, and transient request/payload allocations. Gateway hardware and Node.js versions will differ.
605
+ - Comparison work grows with the size of the value. Nested object keys are sorted for consistent comparison. For ordinary booleans and numeric setpoints, little data needs comparing; large configuration objects cost more. No production CPU or latency benchmark has been performed.
606
+
607
+ ### 3DM.space server and network impact
608
+
609
+ Each enabled 3DM In node sends **one attribute read request per successful connection/reconnect**, receives one response, and subscribes to the response topic on the existing MQTT connection. MQTT acknowledgements and subscription traffic add protocol overhead. If ten enabled In nodes share a login, one reconnect causes ten read requests and normally ten responses; requests are not combined between nodes. Repeated connection failures can increase this traffic, and large all-attribute responses use more bandwidth than a selected-key request.
610
+
611
+ The feature reads attributes; it does not write telemetry or configuration. Server processing includes request handling, attribute retrieval, and response serialization. Exact server RAM/CPU use depends on the deployed ThingsBoard version, attribute sizes, device count, and cache/database configuration; it has not been measured on 3DM.space. Change filtering runs locally **after receipt**, so it reduces downstream Node-RED work but does not reduce the server's live attribute notifications or MQTT bandwidth. Prefer a selected Attribute Key when only one value is needed.
612
+
613
+ Implementation follows the [ThingsBoard MQTT attribute request API](https://thingsboard.io/docs/pe/reference/mqtt-api/attributes/). Local simulated MQTT tests verify the request/response flow; a live reconnect test against your 3DM.space instance is still required to confirm deployment-specific behaviour.
614
+
580
615
  ## 3DM In Status Messages
581
616
 
582
617
  ### connected
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-3dm-space",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
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": "SEE LICENSE IN LICENSE",