node-red-contrib-i3x 0.0.1

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,75 @@
1
+ <script type="text/html" data-template-name="i3x-history">
2
+ <div class="form-row">
3
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+ <input type="text" id="node-input-name" placeholder="Name">
5
+ </div>
6
+ <div class="form-row">
7
+ <label for="node-input-server"><i class="fa fa-server"></i> Server</label>
8
+ <input type="text" id="node-input-server">
9
+ </div>
10
+ <div class="form-row">
11
+ <label for="node-input-elementIds"><i class="fa fa-list"></i> Element IDs</label>
12
+ <input type="text" id="node-input-elementIds" placeholder="comma-separated element IDs">
13
+ </div>
14
+ <div class="form-row">
15
+ <label for="node-input-startTime"><i class="fa fa-clock-o"></i> Start Time</label>
16
+ <input type="text" id="node-input-startTime" placeholder="ISO 8601 or relative (e.g. -1h, -7d)">
17
+ </div>
18
+ <div class="form-row">
19
+ <label for="node-input-endTime"><i class="fa fa-clock-o"></i> End Time</label>
20
+ <input type="text" id="node-input-endTime" placeholder="ISO 8601 or relative (default: now)">
21
+ </div>
22
+ <div class="form-row">
23
+ <label for="node-input-maxDepth"><i class="fa fa-level-down"></i> Max Depth</label>
24
+ <input type="number" id="node-input-maxDepth" placeholder="1" min="0" step="1">
25
+ </div>
26
+ </script>
27
+
28
+ <script type="text/html" data-help-name="i3x-history">
29
+ <p>Queries historical values for one or more i3X objects.</p>
30
+
31
+ <h3>Inputs</h3>
32
+ <dl class="message-properties">
33
+ <dt class="optional">elementIds <span class="property-type">string | string[]</span></dt>
34
+ <dd>Element IDs to query history for. Also accepts <code>msg.nodeIds</code>.</dd>
35
+ <dt class="optional">startTime <span class="property-type">string</span></dt>
36
+ <dd>Start of the time range. ISO 8601 timestamp or relative notation (<code>-1h</code>, <code>-7d</code>, <code>-30m</code>).</dd>
37
+ <dt class="optional">endTime <span class="property-type">string</span></dt>
38
+ <dd>End of the time range. Defaults to current time.</dd>
39
+ <dt class="optional">maxDepth <span class="property-type">number</span></dt>
40
+ <dd>Recursion depth. <code>0</code> = infinite, <code>1</code> = no recursion (default).</dd>
41
+ </dl>
42
+
43
+ <h3>Outputs</h3>
44
+ <dl class="message-properties">
45
+ <dt>payload <span class="property-type">array</span></dt>
46
+ <dd>Historical time-series data returned by the API.</dd>
47
+ </dl>
48
+
49
+ <h3>Details</h3>
50
+ <p>Uses <code>POST /objects/history</code>. Time values support both ISO 8601 and
51
+ relative notation: <code>-30s</code> (seconds), <code>-5m</code> (minutes), <code>-1h</code> (hours),
52
+ <code>-7d</code> (days), <code>-1w</code> (weeks).</p>
53
+ </script>
54
+
55
+ <script type="text/javascript">
56
+ RED.nodes.registerType("i3x-history", {
57
+ category: "i3x",
58
+ color: "#5DB87C",
59
+ defaults: {
60
+ name: { value: "" },
61
+ server: { value: "", type: "i3x-server", required: true },
62
+ elementIds: { value: "" },
63
+ startTime: { value: "-1h" },
64
+ endTime: { value: "" },
65
+ maxDepth: { value: 1 },
66
+ },
67
+ inputs: 1,
68
+ outputs: 1,
69
+ icon: "i3x-icon.svg",
70
+ paletteLabel: "i3x history",
71
+ label: function () {
72
+ return this.name || this.elementIds || "i3x history";
73
+ },
74
+ });
75
+ </script>
@@ -0,0 +1,67 @@
1
+ /**
2
+ * i3x-history – Query historical values from the i3X API.
3
+ */
4
+ "use strict";
5
+
6
+ const { bindServer, parseIds, safeSend } = require("../lib/node-utils");
7
+
8
+ /**
9
+ * Resolve relative time strings like "-1h", "-7d", "-30m" to ISO 8601.
10
+ * @param {string} input
11
+ * @returns {string|undefined} ISO 8601 timestamp
12
+ */
13
+ function resolveTime(input) {
14
+ if (!input) return undefined;
15
+ const rel = /^-(\d+)([smhdw])$/i.exec(input.trim());
16
+ if (!rel) return input;
17
+ const amount = parseInt(rel[1], 10);
18
+ const unit = rel[2].toLowerCase();
19
+ const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000 };
20
+ return new Date(Date.now() - amount * (ms[unit] || 0)).toISOString();
21
+ }
22
+
23
+ module.exports = function (RED) {
24
+ function I3XHistoryNode(config) {
25
+ RED.nodes.createNode(this, config);
26
+ const node = this;
27
+
28
+ node.elementIds = config.elementIds || "";
29
+ node.startTime = config.startTime || "";
30
+ node.endTime = config.endTime || "";
31
+ node.maxDepth = parseInt(config.maxDepth, 10);
32
+ if (isNaN(node.maxDepth)) node.maxDepth = 1;
33
+
34
+ if (!bindServer(node, RED, config.server)) return;
35
+
36
+ node.on("input", async function (msg, send, done) {
37
+ send = safeSend(node, send);
38
+ const client = node.server.client;
39
+
40
+ const ids = parseIds(msg.elementIds || msg.nodeIds || node.elementIds);
41
+ if (ids.length === 0) {
42
+ const err = new Error("elementIds is required");
43
+ if (done) done(err); else node.error(err, msg);
44
+ return;
45
+ }
46
+
47
+ const startTime = resolveTime(msg.startTime || node.startTime);
48
+ const endTime = resolveTime(msg.endTime || node.endTime);
49
+ const maxDepth = msg.maxDepth !== undefined ? parseInt(msg.maxDepth, 10) : node.maxDepth;
50
+
51
+ node.status({ fill: "blue", shape: "dot", text: "querying..." });
52
+
53
+ try {
54
+ const result = await client.readHistory(ids, { startTime, endTime, maxDepth });
55
+ msg.payload = result;
56
+ node.status({ fill: "green", shape: "dot", text: "ok" });
57
+ send(msg);
58
+ if (done) done();
59
+ } catch (err) {
60
+ node.status({ fill: "red", shape: "ring", text: err.message.substring(0, 32) });
61
+ if (done) done(err); else node.error(err, msg);
62
+ }
63
+ });
64
+ }
65
+
66
+ RED.nodes.registerType("i3x-history", I3XHistoryNode);
67
+ };
@@ -0,0 +1,60 @@
1
+ <script type="text/html" data-template-name="i3x-read">
2
+ <div class="form-row">
3
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+ <input type="text" id="node-input-name" placeholder="Name">
5
+ </div>
6
+ <div class="form-row">
7
+ <label for="node-input-server"><i class="fa fa-server"></i> Server</label>
8
+ <input type="text" id="node-input-server">
9
+ </div>
10
+ <div class="form-row">
11
+ <label for="node-input-elementIds"><i class="fa fa-list"></i> Element IDs</label>
12
+ <input type="text" id="node-input-elementIds" placeholder="comma-separated element IDs">
13
+ </div>
14
+ <div class="form-row">
15
+ <label for="node-input-maxDepth"><i class="fa fa-level-down"></i> Max Depth</label>
16
+ <input type="number" id="node-input-maxDepth" placeholder="1" min="0" step="1">
17
+ </div>
18
+ </script>
19
+
20
+ <script type="text/html" data-help-name="i3x-read">
21
+ <p>Reads the last known values for one or more i3X objects.</p>
22
+
23
+ <h3>Inputs</h3>
24
+ <dl class="message-properties">
25
+ <dt class="optional">elementIds <span class="property-type">string | string[]</span></dt>
26
+ <dd>Element IDs to read. Comma-separated string or array. Also accepts <code>msg.nodeIds</code>.</dd>
27
+ <dt class="optional">maxDepth <span class="property-type">number</span></dt>
28
+ <dd>Recursion depth for child components. <code>0</code> = infinite, <code>1</code> = this element only (default).</dd>
29
+ </dl>
30
+
31
+ <h3>Outputs</h3>
32
+ <dl class="message-properties">
33
+ <dt>payload <span class="property-type">object | array</span></dt>
34
+ <dd>Last known value(s) returned by the i3X API.</dd>
35
+ </dl>
36
+
37
+ <h3>Details</h3>
38
+ <p>Uses <code>POST /objects/value</code>. The <code>maxDepth</code> parameter controls whether
39
+ child component values are included recursively.</p>
40
+ </script>
41
+
42
+ <script type="text/javascript">
43
+ RED.nodes.registerType("i3x-read", {
44
+ category: "i3x",
45
+ color: "#5DB87C",
46
+ defaults: {
47
+ name: { value: "" },
48
+ server: { value: "", type: "i3x-server", required: true },
49
+ elementIds: { value: "" },
50
+ maxDepth: { value: 1 },
51
+ },
52
+ inputs: 1,
53
+ outputs: 1,
54
+ icon: "i3x-icon.svg",
55
+ paletteLabel: "i3x read",
56
+ label: function () {
57
+ return this.name || this.elementIds || "i3x read";
58
+ },
59
+ });
60
+ </script>
@@ -0,0 +1,48 @@
1
+ /**
2
+ * i3x-read – Read last known values from one or more i3X objects.
3
+ */
4
+ "use strict";
5
+
6
+ const { bindServer, parseIds, safeSend } = require("../lib/node-utils");
7
+
8
+ module.exports = function (RED) {
9
+ function I3XReadNode(config) {
10
+ RED.nodes.createNode(this, config);
11
+ const node = this;
12
+
13
+ node.elementIds = config.elementIds || "";
14
+ node.maxDepth = parseInt(config.maxDepth, 10);
15
+ if (isNaN(node.maxDepth)) node.maxDepth = 1;
16
+
17
+ if (!bindServer(node, RED, config.server)) return;
18
+
19
+ node.on("input", async function (msg, send, done) {
20
+ send = safeSend(node, send);
21
+ const client = node.server.client;
22
+
23
+ const ids = parseIds(msg.elementIds || msg.nodeIds || node.elementIds);
24
+ if (ids.length === 0) {
25
+ const err = new Error("elementIds is required (string, comma-separated, or array)");
26
+ if (done) done(err); else node.error(err, msg);
27
+ return;
28
+ }
29
+
30
+ const maxDepth = msg.maxDepth !== undefined ? parseInt(msg.maxDepth, 10) : node.maxDepth;
31
+
32
+ node.status({ fill: "blue", shape: "dot", text: "requesting..." });
33
+
34
+ try {
35
+ const result = await client.readValues(ids, { maxDepth });
36
+ msg.payload = result;
37
+ node.status({ fill: "green", shape: "dot", text: "ok" });
38
+ send(msg);
39
+ if (done) done();
40
+ } catch (err) {
41
+ node.status({ fill: "red", shape: "ring", text: err.message.substring(0, 32) });
42
+ if (done) done(err); else node.error(err, msg);
43
+ }
44
+ });
45
+ }
46
+
47
+ RED.nodes.registerType("i3x-read", I3XReadNode);
48
+ };
@@ -0,0 +1,98 @@
1
+ <script type="text/html" data-template-name="i3x-server">
2
+ <div class="form-row">
3
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
4
+ <input type="text" id="node-config-input-name" placeholder="i3X Server">
5
+ </div>
6
+ <div class="form-row">
7
+ <label for="node-config-input-baseUrl"><i class="fa fa-globe"></i> Base URL</label>
8
+ <input type="text" id="node-config-input-baseUrl" placeholder="https://i3x.cesmii.net">
9
+ </div>
10
+ <div class="form-row">
11
+ <label for="node-config-input-apiVersion"><i class="fa fa-code-fork"></i> API Version</label>
12
+ <input type="text" id="node-config-input-apiVersion" placeholder="v0 (leave empty for current)">
13
+ </div>
14
+ <div class="form-row">
15
+ <label for="node-config-input-authType"><i class="fa fa-lock"></i> Auth Type</label>
16
+ <select id="node-config-input-authType">
17
+ <option value="none">None</option>
18
+ <option value="basic">Basic Auth</option>
19
+ <option value="bearer">Bearer Token</option>
20
+ <option value="apikey">API Key</option>
21
+ </select>
22
+ </div>
23
+ <div class="form-row i3x-auth-basic">
24
+ <label for="node-config-input-username"><i class="fa fa-user"></i> Username</label>
25
+ <input type="text" id="node-config-input-username">
26
+ </div>
27
+ <div class="form-row i3x-auth-basic">
28
+ <label for="node-config-input-password"><i class="fa fa-key"></i> Password</label>
29
+ <input type="password" id="node-config-input-password">
30
+ </div>
31
+ <div class="form-row i3x-auth-bearer">
32
+ <label for="node-config-input-token"><i class="fa fa-ticket"></i> Token</label>
33
+ <input type="password" id="node-config-input-token">
34
+ </div>
35
+ <div class="form-row i3x-auth-apikey">
36
+ <label for="node-config-input-apiKey"><i class="fa fa-key"></i> API Key</label>
37
+ <input type="password" id="node-config-input-apiKey">
38
+ </div>
39
+ <div class="form-row">
40
+ <label for="node-config-input-tlsConfig"><i class="fa fa-shield"></i> TLS</label>
41
+ <input type="text" id="node-config-input-tlsConfig">
42
+ </div>
43
+ <div class="form-row">
44
+ <label for="node-config-input-timeout"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
45
+ <input type="number" id="node-config-input-timeout" placeholder="10000" min="1000" step="1000">
46
+ </div>
47
+ </script>
48
+
49
+ <script type="text/html" data-help-name="i3x-server">
50
+ <p>Configuration node for an i3X API server connection.</p>
51
+ <h3>Properties</h3>
52
+ <dl class="message-properties">
53
+ <dt>Base URL <span class="property-type">string</span></dt>
54
+ <dd>The root URL of the i3X API server, e.g. <code>https://i3x.cesmii.net</code>.</dd>
55
+ <dt>API Version <span class="property-type">string</span></dt>
56
+ <dd>Optional path prefix for versioned endpoints (e.g. <code>v0</code>). Leave empty for current default.</dd>
57
+ <dt>Auth Type <span class="property-type">select</span></dt>
58
+ <dd>Authentication method: None, Basic Auth, Bearer Token, or API Key.</dd>
59
+ <dt>TLS <span class="property-type">tls-config</span></dt>
60
+ <dd>Optional TLS configuration for custom certificates.</dd>
61
+ <dt>Timeout <span class="property-type">number</span></dt>
62
+ <dd>HTTP request timeout in milliseconds (default 10000).</dd>
63
+ </dl>
64
+ </script>
65
+
66
+ <script type="text/javascript">
67
+ RED.nodes.registerType("i3x-server", {
68
+ category: "config",
69
+ defaults: {
70
+ name: { value: "" },
71
+ baseUrl: { value: "", required: true },
72
+ apiVersion: { value: "" },
73
+ authType: { value: "none" },
74
+ tlsConfig: { value: "", type: "tls-config", required: false },
75
+ timeout: { value: 10000 },
76
+ },
77
+ credentials: {
78
+ username: { type: "text" },
79
+ password: { type: "password" },
80
+ token: { type: "password" },
81
+ apiKey: { type: "password" },
82
+ },
83
+ label: function () {
84
+ return this.name || this.baseUrl || "i3x server";
85
+ },
86
+ oneditprepare: function () {
87
+ var authType = $("#node-config-input-authType");
88
+ function toggleAuth() {
89
+ var val = authType.val();
90
+ $(".i3x-auth-basic").toggle(val === "basic");
91
+ $(".i3x-auth-bearer").toggle(val === "bearer");
92
+ $(".i3x-auth-apikey").toggle(val === "apikey");
93
+ }
94
+ authType.on("change", toggleAuth);
95
+ toggleAuth();
96
+ },
97
+ });
98
+ </script>
@@ -0,0 +1,75 @@
1
+ /**
2
+ * i3x-server – Config node providing a shared I3XClient instance.
3
+ */
4
+ "use strict";
5
+
6
+ const I3XClient = require("../lib/i3x-client");
7
+
8
+ module.exports = function (RED) {
9
+ function I3XServerNode(config) {
10
+ RED.nodes.createNode(this, config);
11
+ const node = this;
12
+
13
+ node.name = config.name;
14
+ node.baseUrl = config.baseUrl;
15
+ node.apiVersion = config.apiVersion || "";
16
+ node.authType = config.authType || "none";
17
+ node.timeout = parseInt(config.timeout, 10) || 10000;
18
+ node.tlsConfigId = config.tlsConfig;
19
+
20
+ const clientConfig = {
21
+ baseUrl: node.baseUrl,
22
+ apiVersion: node.apiVersion,
23
+ authType: node.authType,
24
+ timeout: node.timeout,
25
+ };
26
+
27
+ if (node.credentials) {
28
+ clientConfig.username = node.credentials.username;
29
+ clientConfig.password = node.credentials.password;
30
+ clientConfig.token = node.credentials.token;
31
+ clientConfig.apiKey = node.credentials.apiKey;
32
+ }
33
+
34
+ if (node.tlsConfigId) {
35
+ const tlsNode = RED.nodes.getNode(node.tlsConfigId);
36
+ if (tlsNode && tlsNode.addTLSOptions) {
37
+ const tlsOpts = {};
38
+ tlsNode.addTLSOptions(tlsOpts);
39
+ if (tlsOpts.ca || tlsOpts.cert || tlsOpts.key || tlsOpts.rejectUnauthorized !== undefined) {
40
+ clientConfig.tlsOptions = tlsOpts;
41
+ }
42
+ }
43
+ }
44
+
45
+ node.client = new I3XClient(clientConfig);
46
+ node.setMaxListeners(0);
47
+
48
+ node.client.testConnection()
49
+ .then(() => {
50
+ node.connected = true;
51
+ node.emit("connected");
52
+ node.log("Connected to i3X server: " + node.baseUrl);
53
+ })
54
+ .catch((err) => {
55
+ node.connected = false;
56
+ node.emit("disconnected", err);
57
+ node.warn("i3X connection test failed: " + err.message);
58
+ });
59
+
60
+ node.on("close", function (done) {
61
+ node.client.destroy();
62
+ node.connected = false;
63
+ done();
64
+ });
65
+ }
66
+
67
+ RED.nodes.registerType("i3x-server", I3XServerNode, {
68
+ credentials: {
69
+ username: { type: "text" },
70
+ password: { type: "password" },
71
+ token: { type: "password" },
72
+ apiKey: { type: "password" },
73
+ },
74
+ });
75
+ };
@@ -0,0 +1,79 @@
1
+ <script type="text/html" data-template-name="i3x-subscribe">
2
+ <div class="form-row">
3
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+ <input type="text" id="node-input-name" placeholder="Name">
5
+ </div>
6
+ <div class="form-row">
7
+ <label for="node-input-server"><i class="fa fa-server"></i> Server</label>
8
+ <input type="text" id="node-input-server">
9
+ </div>
10
+ <div class="form-row">
11
+ <label for="node-input-elementIds"><i class="fa fa-list"></i> Element IDs</label>
12
+ <input type="text" id="node-input-elementIds" placeholder="comma-separated element IDs to monitor">
13
+ </div>
14
+ <div class="form-row">
15
+ <label for="node-input-mode"><i class="fa fa-exchange"></i> Mode</label>
16
+ <select id="node-input-mode">
17
+ <option value="sse">SSE (Server-Sent Events)</option>
18
+ <option value="poll">Polling (sync)</option>
19
+ </select>
20
+ </div>
21
+ <div class="form-row i3x-sub-poll">
22
+ <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">
24
+ </div>
25
+ <div class="form-row">
26
+ <label for="node-input-maxDepth"><i class="fa fa-level-down"></i> Max Depth</label>
27
+ <input type="number" id="node-input-maxDepth" placeholder="1" min="0" step="1">
28
+ </div>
29
+ </script>
30
+
31
+ <script type="text/html" data-help-name="i3x-subscribe">
32
+ <p>Subscribes to value changes on one or more i3X objects and emits messages on each update.</p>
33
+
34
+ <h3>Outputs</h3>
35
+ <dl class="message-properties">
36
+ <dt>payload <span class="property-type">object | array</span></dt>
37
+ <dd>Value change events from the subscription.</dd>
38
+ <dt>topic <span class="property-type">string</span></dt>
39
+ <dd>Always <code>i3x/subscription</code>.</dd>
40
+ </dl>
41
+
42
+ <h3>Details</h3>
43
+ <p>Creates a server-side subscription via the i3X Subscribe API and monitors the specified elements.</p>
44
+ <p><b>SSE mode</b> opens a persistent Server-Sent Events stream (<code>GET /subscriptions/{id}/stream</code>)
45
+ and emits messages in real-time as values change.</p>
46
+ <p><b>Polling mode</b> periodically calls <code>POST /subscriptions/{id}/sync</code> to retrieve and
47
+ clear queued updates. If SSE setup fails, the node automatically falls back to polling.</p>
48
+ <p>The subscription is automatically cleaned up (deleted from the server) when the node is stopped or re-deployed.</p>
49
+ </script>
50
+
51
+ <script type="text/javascript">
52
+ RED.nodes.registerType("i3x-subscribe", {
53
+ category: "i3x",
54
+ color: "#5DB87C",
55
+ defaults: {
56
+ name: { value: "" },
57
+ server: { value: "", type: "i3x-server", required: true },
58
+ elementIds: { value: "" },
59
+ mode: { value: "sse" },
60
+ pollingInterval: { value: 5000 },
61
+ maxDepth: { value: 1 },
62
+ },
63
+ inputs: 0,
64
+ outputs: 1,
65
+ icon: "i3x-icon.svg",
66
+ paletteLabel: "i3x subscribe",
67
+ label: function () {
68
+ return this.name || this.elementIds || "i3x subscribe";
69
+ },
70
+ oneditprepare: function () {
71
+ var mode = $("#node-input-mode");
72
+ function togglePoll() {
73
+ $(".i3x-sub-poll").toggle(mode.val() === "poll");
74
+ }
75
+ mode.on("change", togglePoll);
76
+ togglePoll();
77
+ },
78
+ });
79
+ </script>
@@ -0,0 +1,152 @@
1
+ /**
2
+ * i3x-subscribe – Subscribe to value changes via SSE streaming or polling fallback.
3
+ */
4
+ "use strict";
5
+
6
+ const { bindServer, parseIds } = require("../lib/node-utils");
7
+
8
+ module.exports = function (RED) {
9
+ function I3XSubscribeNode(config) {
10
+ RED.nodes.createNode(this, config);
11
+ const node = this;
12
+
13
+ node.elementIds = config.elementIds || "";
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;
18
+
19
+ node._subscriptionId = null;
20
+ node._sseHandle = null;
21
+ node._pollTimer = null;
22
+ node._closing = false;
23
+
24
+ if (!bindServer(node, RED, config.server)) return;
25
+
26
+ async function setup() {
27
+ const client = node.server.client;
28
+ const ids = parseIds(node.elementIds);
29
+ if (ids.length === 0) {
30
+ node.status({ fill: "yellow", shape: "ring", text: "no element IDs" });
31
+ return;
32
+ }
33
+
34
+ node.status({ fill: "yellow", shape: "dot", text: "subscribing..." });
35
+
36
+ try {
37
+ const sub = await client.createSubscription();
38
+ node._subscriptionId = sub.subscriptionId;
39
+ await client.registerMonitoredItems(node._subscriptionId, ids, node.maxDepth);
40
+
41
+ if (node.mode === "sse") {
42
+ startSSE(client);
43
+ } else {
44
+ startPolling(client);
45
+ }
46
+ } catch (err) {
47
+ node.status({ fill: "red", shape: "ring", text: err.message.substring(0, 32) });
48
+ node.error("Subscription setup failed: " + err.message);
49
+
50
+ if (node.mode === "sse" && !node._closing) {
51
+ node.warn("SSE setup failed, falling back to polling");
52
+ fallbackToPolling(client, ids);
53
+ }
54
+ }
55
+ }
56
+
57
+ async function fallbackToPolling(client, ids) {
58
+ try {
59
+ if (!node._subscriptionId) {
60
+ const sub = await client.createSubscription();
61
+ node._subscriptionId = sub.subscriptionId;
62
+ await client.registerMonitoredItems(node._subscriptionId, ids, node.maxDepth);
63
+ }
64
+ startPolling(client);
65
+ } catch (pollErr) {
66
+ node.status({ fill: "red", shape: "ring", text: "failed" });
67
+ node.error("Polling fallback also failed: " + pollErr.message);
68
+ }
69
+ }
70
+
71
+ function startSSE(client) {
72
+ node.status({ fill: "green", shape: "dot", text: "streaming (SSE)" });
73
+
74
+ node._sseHandle = client.streamSubscription(node._subscriptionId, {
75
+ onData: (event) => {
76
+ if (node._closing) return;
77
+ node.send({ payload: event, topic: "i3x/subscription" });
78
+ },
79
+ onError: (err) => {
80
+ if (node._closing) return;
81
+ node.status({ fill: "red", shape: "ring", text: "stream error" });
82
+ node.error("SSE stream error: " + err.message);
83
+ },
84
+ onReconnect: (attempt) => {
85
+ if (node._closing) return;
86
+ node.status({ fill: "yellow", shape: "dot", text: "reconnecting (" + attempt + ")..." });
87
+ node.warn("SSE stream reconnecting, attempt " + attempt);
88
+ },
89
+ });
90
+ }
91
+
92
+ function startPolling(client) {
93
+ node.status({ fill: "green", shape: "dot", text: "polling (" + node.pollingInterval + "ms)" });
94
+
95
+ async function poll() {
96
+ if (node._closing) return;
97
+ try {
98
+ const data = await client.syncSubscription(node._subscriptionId);
99
+ if (data && (Array.isArray(data) ? data.length > 0 : Object.keys(data).length > 0)) {
100
+ node.send({ payload: data, topic: "i3x/subscription" });
101
+ }
102
+ } catch (err) {
103
+ if (!node._closing) {
104
+ node.status({ fill: "red", shape: "ring", text: "poll error" });
105
+ node.error("Polling error: " + err.message);
106
+ }
107
+ }
108
+ }
109
+
110
+ poll();
111
+ node._pollTimer = setInterval(poll, node.pollingInterval);
112
+ }
113
+
114
+ async function teardown(done) {
115
+ node._closing = true;
116
+
117
+ if (node._pollTimer) {
118
+ clearInterval(node._pollTimer);
119
+ node._pollTimer = null;
120
+ }
121
+
122
+ if (node._sseHandle) {
123
+ node._sseHandle.close();
124
+ node._sseHandle = null;
125
+ }
126
+
127
+ if (node._subscriptionId && node.server && node.server.client) {
128
+ try {
129
+ await node.server.client.deleteSubscription(node._subscriptionId);
130
+ } catch (_) {
131
+ // best-effort cleanup
132
+ }
133
+ node._subscriptionId = null;
134
+ }
135
+
136
+ done();
137
+ }
138
+
139
+ if (node.server.connected) {
140
+ setup();
141
+ }
142
+ node.server.on("connected", () => {
143
+ if (!node._subscriptionId && !node._closing) {
144
+ setup();
145
+ }
146
+ });
147
+
148
+ node.on("close", teardown);
149
+ }
150
+
151
+ RED.nodes.registerType("i3x-subscribe", I3XSubscribeNode);
152
+ };