node-red-contrib-i3x 0.0.2 → 0.0.4

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.
@@ -45,6 +45,11 @@ module.exports = function (RED) {
45
45
  node.client = new I3XClient(clientConfig);
46
46
  node.setMaxListeners(0);
47
47
 
48
+ // Warn if credentials are sent over plain HTTP
49
+ if (node.client._httpsWarning) {
50
+ node.warn(node.client._httpsWarning);
51
+ }
52
+
48
53
  node.client.testConnection()
49
54
  .then(() => {
50
55
  node.connected = true;
@@ -72,4 +77,121 @@ module.exports = function (RED) {
72
77
  apiKey: { type: "password" },
73
78
  },
74
79
  });
80
+
81
+ // ── Admin browse endpoints for editor treeview ─────────────────────
82
+
83
+ RED.httpAdmin.get("/i3x-server/:id/browse/objecttypes", async function (req, res) {
84
+ const node = RED.nodes.getNode(req.params.id);
85
+ if (!node || !node.client) {
86
+ return res.status(404).json({ error: "Server not found – please deploy first" });
87
+ }
88
+ try {
89
+ const result = await node.client.getObjectTypes({
90
+ namespaceUri: req.query.namespaceUri || undefined,
91
+ });
92
+ res.json(result);
93
+ } catch (err) {
94
+ res.status(500).json({ error: err.message });
95
+ }
96
+ });
97
+
98
+ RED.httpAdmin.get("/i3x-server/:id/browse/objects", async function (req, res) {
99
+ const node = RED.nodes.getNode(req.params.id);
100
+ if (!node || !node.client) {
101
+ return res.status(404).json({ error: "Server not found – please deploy first" });
102
+ }
103
+ try {
104
+ const opts = {};
105
+ if (req.query.typeId) opts.typeId = req.query.typeId;
106
+ if (req.query.includeMetadata === "true") opts.includeMetadata = true;
107
+ const result = await node.client.getObjects(opts);
108
+ res.json(result);
109
+ } catch (err) {
110
+ res.status(500).json({ error: err.message });
111
+ }
112
+ });
113
+
114
+ RED.httpAdmin.get("/i3x-server/:id/browse/related/:elementId", async function (req, res) {
115
+ const node = RED.nodes.getNode(req.params.id);
116
+ if (!node || !node.client) {
117
+ return res.status(404).json({ error: "Server not found – please deploy first" });
118
+ }
119
+ try {
120
+ const result = await node.client.getRelatedObjects(
121
+ [req.params.elementId],
122
+ { includeMetadata: true }
123
+ );
124
+ res.json(result);
125
+ } catch (err) {
126
+ res.status(500).json({ error: err.message });
127
+ }
128
+ });
129
+
130
+ // ── Server-side search across all objects ──────────────────────────
131
+ RED.httpAdmin.get("/i3x-server/:id/browse/search", async function (req, res) {
132
+ const node = RED.nodes.getNode(req.params.id);
133
+ if (!node || !node.client) {
134
+ return res.status(404).json({ error: "Server not found – please deploy first" });
135
+ }
136
+ try {
137
+ const q = (req.query.q || "").toLowerCase();
138
+ if (!q || q.length < 2) {
139
+ return res.json([]);
140
+ }
141
+ // Fetch all object types then all objects per type, filter by query
142
+ const types = await node.client.getObjectTypes({});
143
+ const results = [];
144
+ const MAX_RESULTS = 50;
145
+ for (const type of (Array.isArray(types) ? types : [])) {
146
+ const tid = type.elementId || type.id;
147
+ const objects = await node.client.getObjects({ typeId: tid });
148
+ for (const obj of (Array.isArray(objects) ? objects : [])) {
149
+ const eid = obj.elementId || obj.id || "";
150
+ const name = obj.displayName || "";
151
+ if (name.toLowerCase().includes(q) || eid.toLowerCase().includes(q)) {
152
+ results.push({
153
+ elementId: eid,
154
+ displayName: name,
155
+ typeName: type.displayName || tid,
156
+ typeId: tid,
157
+ });
158
+ if (results.length >= MAX_RESULTS) break;
159
+ }
160
+ }
161
+ if (results.length >= MAX_RESULTS) break;
162
+ }
163
+ res.json(results);
164
+ } catch (err) {
165
+ res.status(500).json({ error: err.message });
166
+ }
167
+ });
168
+
169
+ // ── Read live values for browser widget ────────────────────────────
170
+ RED.httpAdmin.post("/i3x-server/:id/browse/values", async function (req, res) {
171
+ const node = RED.nodes.getNode(req.params.id);
172
+ if (!node || !node.client) {
173
+ return res.status(404).json({ error: "Server not found – please deploy first" });
174
+ }
175
+ try {
176
+ const elementIds = req.body && req.body.elementIds;
177
+ if (!Array.isArray(elementIds) || elementIds.length === 0) {
178
+ return res.json([]);
179
+ }
180
+ // Limit batch size to prevent abuse
181
+ const ids = elementIds.slice(0, 50);
182
+ const result = await node.client.readValues(ids, { maxDepth: 1 });
183
+ res.json(result);
184
+ } catch (err) {
185
+ res.status(500).json({ error: err.message });
186
+ }
187
+ });
188
+
189
+ // ── Connection status check ───────────────────────────────────────
190
+ RED.httpAdmin.get("/i3x-server/:id/status", function (req, res) {
191
+ const node = RED.nodes.getNode(req.params.id);
192
+ if (!node) {
193
+ return res.status(404).json({ connected: false, error: "Node not found" });
194
+ }
195
+ res.json({ connected: !!node.connected });
196
+ });
75
197
  };
@@ -11,6 +11,9 @@
11
11
  <label for="node-input-elementIds"><i class="fa fa-list"></i> Element IDs</label>
12
12
  <input type="text" id="node-input-elementIds" placeholder="comma-separated element IDs to monitor">
13
13
  </div>
14
+ <div class="form-row">
15
+ <div id="i3x-subscribe-browser"></div>
16
+ </div>
14
17
  <div class="form-row">
15
18
  <label for="node-input-mode"><i class="fa fa-exchange"></i> Mode</label>
16
19
  <select id="node-input-mode">
@@ -20,7 +23,7 @@
20
23
  </div>
21
24
  <div class="form-row i3x-sub-poll">
22
25
  <label for="node-input-pollingInterval"><i class="fa fa-clock-o"></i> Poll Interval (ms)</label>
23
- <input type="number" id="node-input-pollingInterval" placeholder="5000" min="500" step="500">
26
+ <input type="number" id="node-input-pollingInterval" placeholder="5000" min="1000" step="1000">
24
27
  </div>
25
28
  <div class="form-row">
26
29
  <label for="node-input-maxDepth"><i class="fa fa-level-down"></i> Max Depth</label>
@@ -46,6 +49,7 @@
46
49
  <p><b>Polling mode</b> periodically calls <code>POST /subscriptions/{id}/sync</code> to retrieve and
47
50
  clear queued updates. If SSE setup fails, the node automatically falls back to polling.</p>
48
51
  <p>The subscription is automatically cleaned up (deleted from the server) when the node is stopped or re-deployed.</p>
52
+ <p>Use the <b>Browse</b> button to visually select elements from the server.</p>
49
53
  </script>
50
54
 
51
55
  <script type="text/javascript">
@@ -74,6 +78,15 @@
74
78
  }
75
79
  mode.on("change", togglePoll);
76
80
  togglePoll();
81
+
82
+ if (window.I3XBrowser) {
83
+ this._browser = I3XBrowser.create({
84
+ container: "#i3x-subscribe-browser",
85
+ serverField: "#node-input-server",
86
+ targetField: "#node-input-elementIds",
87
+ mode: "multi",
88
+ });
89
+ }
77
90
  },
78
91
  });
79
92
  </script>
@@ -3,7 +3,7 @@
3
3
  */
4
4
  "use strict";
5
5
 
6
- const { bindServer, parseIds } = require("../lib/node-utils");
6
+ const { bindServer, parseIds, statusError, clampMaxDepth } = require("../lib/node-utils");
7
7
 
8
8
  module.exports = function (RED) {
9
9
  function I3XSubscribeNode(config) {
@@ -12,9 +12,8 @@ module.exports = function (RED) {
12
12
 
13
13
  node.elementIds = config.elementIds || "";
14
14
  node.mode = config.mode || "sse";
15
- node.pollingInterval = parseInt(config.pollingInterval, 10) || 5000;
16
- node.maxDepth = parseInt(config.maxDepth, 10);
17
- if (isNaN(node.maxDepth)) node.maxDepth = 1;
15
+ node.pollingInterval = Math.max(1000, parseInt(config.pollingInterval, 10) || 5000);
16
+ node.maxDepth = clampMaxDepth(config.maxDepth);
18
17
 
19
18
  node._subscriptionId = null;
20
19
  node._sseHandle = null;
@@ -44,7 +43,7 @@ module.exports = function (RED) {
44
43
  startPolling(client);
45
44
  }
46
45
  } catch (err) {
47
- node.status({ fill: "red", shape: "ring", text: err.message.substring(0, 32) });
46
+ node.status({ fill: "red", shape: "ring", text: statusError(err.message) });
48
47
  node.error("Subscription setup failed: " + err.message);
49
48
 
50
49
  if (node.mode === "sse" && !node._closing) {
@@ -126,7 +125,7 @@ module.exports = function (RED) {
126
125
 
127
126
  if (node._subscriptionId && node.server && node.server.client) {
128
127
  try {
129
- await node.server.client.deleteSubscription(node._subscriptionId);
128
+ await node.server.client.deleteSubscriptions([node._subscriptionId]);
130
129
  } catch (_) {
131
130
  // best-effort cleanup
132
131
  }
@@ -140,7 +139,11 @@ module.exports = function (RED) {
140
139
  setup();
141
140
  }
142
141
  node.server.on("connected", () => {
143
- if (!node._subscriptionId && !node._closing) {
142
+ if (!node._closing) {
143
+ // Clean up stale state from a previous connection
144
+ if (node._pollTimer) { clearInterval(node._pollTimer); node._pollTimer = null; }
145
+ if (node._sseHandle) { node._sseHandle.close(); node._sseHandle = null; }
146
+ node._subscriptionId = null;
144
147
  setup();
145
148
  }
146
149
  });
@@ -18,6 +18,9 @@
18
18
  <label for="node-input-elementId"><i class="fa fa-crosshairs"></i> Element ID</label>
19
19
  <input type="text" id="node-input-elementId" placeholder="target element ID">
20
20
  </div>
21
+ <div class="form-row">
22
+ <div id="i3x-write-browser"></div>
23
+ </div>
21
24
  </script>
22
25
 
23
26
  <script type="text/html" data-help-name="i3x-write">
@@ -46,8 +49,8 @@
46
49
  to update the current value of the object.</p>
47
50
  <p>When <b>Target</b> is set to <i>Historical Data</i>, uses <code>PUT /objects/{elementId}/history</code>
48
51
  to write historical time-series records.</p>
49
- <p>The payload format must match the element's type schema (e.g. a plain number for
50
- <code>measurement-value-type</code>, or a state object for <code>state-type</code>).</p>
52
+ <p>Use the <b>Browse</b> button to visually select the target element from the server,
53
+ or provide the element ID via <code>msg.elementId</code> at runtime.</p>
51
54
  </script>
52
55
 
53
56
  <script type="text/javascript">
@@ -68,5 +71,15 @@
68
71
  return this.name || this.elementId || "i3x write";
69
72
  },
70
73
  align: "right",
74
+ oneditprepare: function () {
75
+ if (window.I3XBrowser) {
76
+ this._browser = I3XBrowser.create({
77
+ container: "#i3x-write-browser",
78
+ serverField: "#node-input-server",
79
+ targetField: "#node-input-elementId",
80
+ mode: "single",
81
+ });
82
+ }
83
+ },
71
84
  });
72
85
  </script>
@@ -3,7 +3,7 @@
3
3
  */
4
4
  "use strict";
5
5
 
6
- const { bindServer, safeSend } = require("../lib/node-utils");
6
+ const { bindServer, safeSend, statusError } = require("../lib/node-utils");
7
7
 
8
8
  module.exports = function (RED) {
9
9
  function I3XWriteNode(config) {
@@ -42,11 +42,11 @@ module.exports = function (RED) {
42
42
  : await client.writeValue(elementId, value);
43
43
  msg.payload = result;
44
44
  msg.elementId = elementId;
45
- node.status({ fill: "green", shape: "dot", text: "ok" });
45
+ node.status({ fill: "green", shape: "dot", text: "wrote " + elementId.substring(0, 20) });
46
46
  send(msg);
47
47
  if (done) done();
48
48
  } catch (err) {
49
- node.status({ fill: "red", shape: "ring", text: err.message.substring(0, 32) });
49
+ node.status({ fill: "red", shape: "ring", text: statusError(err.message) });
50
50
  if (done) done(err); else node.error(err, msg);
51
51
  }
52
52
  });
@@ -1,11 +1,4 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
2
2
  <rect width="40" height="40" rx="4" fill="#2E8B57"/>
3
- <text x="20" y="16" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="white">i3X</text>
4
- <line x1="8" y1="22" x2="32" y2="22" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
5
- <circle cx="12" cy="30" r="3" fill="none" stroke="white" stroke-width="1.5"/>
6
- <circle cx="20" cy="30" r="3" fill="none" stroke="white" stroke-width="1.5"/>
7
- <circle cx="28" cy="30" r="3" fill="none" stroke="white" stroke-width="1.5"/>
8
- <line x1="12" y1="27" x2="12" y2="22" stroke="white" stroke-width="1.5"/>
9
- <line x1="20" y1="27" x2="20" y2="22" stroke="white" stroke-width="1.5"/>
10
- <line x1="28" y1="27" x2="28" y2="22" stroke="white" stroke-width="1.5"/>
3
+ <text x="20" y="26" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white">i3X</text>
11
4
  </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-i3x",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Node-RED nodes for the i3X (Industrial Information Interoperability eXchange) API by CESMII",
5
5
  "keywords": [
6
6
  "node-red",
@@ -27,13 +27,13 @@
27
27
  "test:docker": "docker compose run --rm test"
28
28
  },
29
29
  "dependencies": {
30
- "axios": "^1.7.0"
30
+ "axios": "^1.15.0"
31
31
  },
32
32
  "devDependencies": {
33
- "mocha": "^10.0.0",
33
+ "mocha": "^11.0.0",
34
34
  "chai": "^4.0.0",
35
- "sinon": "^17.0.0",
36
- "nock": "^13.0.0",
35
+ "sinon": "^21.0.0",
36
+ "nock": "^14.0.0",
37
37
  "node-red": "^3.0.0",
38
38
  "node-red-node-test-helper": "^0.3.0"
39
39
  },