node-red-contrib-i3x 0.0.4 → 0.0.5
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/CHANGELOG.md +39 -0
- package/README.md +38 -35
- package/examples/i3x-complete-demo.json +2 -2
- package/lib/i3x-client.js +130 -56
- package/nodes/i3x-server.js +4 -0
- package/nodes/i3x-subscribe.html +6 -4
- package/nodes/i3x-subscribe.js +32 -3
- package/nodes/i3x-write.html +7 -4
- package/package.json +1 -1
- package/.claude/settings.local.json +0 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.5 (2026-06-12)
|
|
4
|
+
|
|
5
|
+
Migration to the **i3X API 1.0 Release** specification (finalized 2026-06-09). See the
|
|
6
|
+
[official i3X changelog](https://github.com/cesmii/i3X/blob/1.0/CHANGELOG.md) for the
|
|
7
|
+
spec-side deltas this release adopts.
|
|
8
|
+
|
|
9
|
+
### Breaking (spec-mandated)
|
|
10
|
+
|
|
11
|
+
- **Bulk write endpoints** – `PUT /objects/{elementId}/value` and `PUT /objects/{elementId}/history`
|
|
12
|
+
were removed from the spec. `writeValue()` / `writeHistory()` now use the bulk endpoints
|
|
13
|
+
`PUT /objects/value` and `PUT /objects/history` with `{"updates": [{"elementId", "value"}]}`
|
|
14
|
+
bodies. Values are normalised to VQT objects (`{value, quality, timestamp}`); history writes
|
|
15
|
+
default missing `quality` to `"Good"` and missing `timestamp` to the current UTC time.
|
|
16
|
+
- **`clientId` required on all subscription endpoints** – create, list, delete, register,
|
|
17
|
+
unregister, stream, and sync now always send a `clientId` (1.0 servers reject requests
|
|
18
|
+
without one with 400). The i3x-server config node derives a stable `clientId` from its
|
|
19
|
+
node id; it can be overridden per call or via the `I3XClient` constructor.
|
|
20
|
+
- **`GET /objects/{elementId}/history` removed** – `getHistory()` is deprecated and now
|
|
21
|
+
delegates to the bulk `POST /objects/history` endpoint, returning its bulk result array.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `writeValues(updates)` – bulk-write current values of multiple objects in one request
|
|
26
|
+
- Sync acknowledgement – the subscribe node tracks batch `sequenceNumber`s and acknowledges
|
|
27
|
+
received updates via `lastSequenceNumber` on the next poll; `lastSequenceNumber = -1`
|
|
28
|
+
(acknowledge all) is supported by the client
|
|
29
|
+
- Poll-only server support – servers may answer `/subscriptions/stream` with HTTP 501;
|
|
30
|
+
the subscribe node now detects this and falls back to sync polling automatically
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Sync responses are handled in the new batched format `[{sequenceNumber, updates: [...]}]`
|
|
35
|
+
(flat pre-1.0 responses are still tolerated); the subscribe node emits the flattened updates
|
|
36
|
+
- Error details are read from the new `responseDetail` envelope field (with fallback to
|
|
37
|
+
the older `problemDetail` and `error` shapes) and appended to error messages
|
|
38
|
+
- Write payload sanitization now validates against the VQT fields (`value`, `quality`,
|
|
39
|
+
`timestamp`); unknown fields are rejected to avoid silent data loss
|
|
40
|
+
- README and node help texts updated to the 1.0 Release endpoint set
|
|
41
|
+
|
|
3
42
|
## 0.0.4 (2026-04-12)
|
|
4
43
|
|
|
5
44
|
Migration to i3X API 1.0-Beta specification with enhanced query capabilities and improved API alignment.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Node-RED nodes for the **i3X** (Industrial Information Interoperability eXchange
|
|
|
4
4
|
|
|
5
5
|
i3X is an open, vendor-agnostic REST API specification for standardised access to contextualised manufacturing information platforms (Historians, MES, MOM, etc.).
|
|
6
6
|
|
|
7
|
-
> **Note:**
|
|
7
|
+
> **Note:** This package targets the **i3X API 1.0 Release** (finalized 2026-06-09). The specification is stable; the next revision is not expected before the vNext working group convenes in late 2026.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -68,10 +68,12 @@ Write a current value or historical data to an i3X object.
|
|
|
68
68
|
- **Target:** `value` (default) or `history` – selectable via dropdown or `msg.writeTarget`
|
|
69
69
|
- **Output:** `msg.payload` – write confirmation from the API
|
|
70
70
|
|
|
71
|
-
| Target | API Endpoint
|
|
72
|
-
| --------- |
|
|
73
|
-
| `value` | `PUT /objects/
|
|
74
|
-
| `history` | `PUT /objects/
|
|
71
|
+
| Target | API Endpoint | Payload format |
|
|
72
|
+
| --------- | --------------------- | ------------------------------------- |
|
|
73
|
+
| `value` | `PUT /objects/value` | A value or VQT object `{value, quality?, timestamp?}` |
|
|
74
|
+
| `history` | `PUT /objects/history`| A VQT or array of VQT records `[{value, quality, timestamp}, …]` |
|
|
75
|
+
|
|
76
|
+
Both use the i3X 1.0 bulk update format (`{updates: [{elementId, value}]}`); the node builds it for you. For history writes a missing `quality` defaults to `"Good"` and a missing `timestamp` to the current UTC time.
|
|
75
77
|
|
|
76
78
|
### i3x-history
|
|
77
79
|
|
|
@@ -85,10 +87,10 @@ Query historical time-series data.
|
|
|
85
87
|
|
|
86
88
|
Subscribe to value changes via SSE streaming or polling.
|
|
87
89
|
|
|
88
|
-
- **SSE mode:** Opens a persistent Server-Sent Events stream (`
|
|
89
|
-
- **Polling mode:** Periodically calls `POST /subscriptions/
|
|
90
|
-
- **Fallback:** If SSE fails
|
|
91
|
-
- **Lifecycle:** Subscriptions are created on deploy and deleted on stop/re-deploy
|
|
90
|
+
- **SSE mode:** Opens a persistent Server-Sent Events stream (`POST /subscriptions/stream`)
|
|
91
|
+
- **Polling mode:** Periodically calls `POST /subscriptions/sync`, acknowledging received batches by sequence number
|
|
92
|
+
- **Fallback:** If SSE fails — or the server returns HTTP 501 (streaming not supported) — the node automatically falls back to polling
|
|
93
|
+
- **Lifecycle:** Subscriptions are created on deploy and deleted on stop/re-deploy; all calls are scoped by a `clientId` derived from the server config node (required by i3X 1.0)
|
|
92
94
|
|
|
93
95
|
## Built-in Resilience & Best Practices
|
|
94
96
|
|
|
@@ -108,32 +110,33 @@ The shared HTTP client (`lib/i3x-client.js`) implements all [i3X Client Develope
|
|
|
108
110
|
|
|
109
111
|
## API Endpoints Used
|
|
110
112
|
|
|
111
|
-
This package targets the [i3X API 1.0
|
|
112
|
-
|
|
113
|
-
| Category | Method | Endpoint
|
|
114
|
-
| --------- | ------ |
|
|
115
|
-
| Info | GET | `/info`
|
|
116
|
-
| Explore | GET | `/namespaces`
|
|
117
|
-
| Explore | GET | `/objecttypes`
|
|
118
|
-
| Explore | POST | `/objecttypes/query`
|
|
119
|
-
| Explore | GET | `/relationshiptypes`
|
|
120
|
-
| Explore | POST | `/relationshiptypes/query`
|
|
121
|
-
| Explore | GET | `/objects`
|
|
122
|
-
| Explore | POST | `/objects/list`
|
|
123
|
-
| Explore | POST | `/objects/related`
|
|
124
|
-
| Query | POST | `/objects/value`
|
|
125
|
-
| Query |
|
|
126
|
-
|
|
|
127
|
-
| Update | PUT | `/objects/
|
|
128
|
-
|
|
|
129
|
-
| Subscribe |
|
|
130
|
-
| Subscribe | POST | `/subscriptions`
|
|
131
|
-
| Subscribe |
|
|
132
|
-
| Subscribe |
|
|
133
|
-
| Subscribe | POST | `/subscriptions/
|
|
134
|
-
| Subscribe | POST | `/subscriptions/
|
|
135
|
-
|
|
136
|
-
|
|
113
|
+
This package targets the [i3X API 1.0 Release](https://api.i3x.dev/v1/docs):
|
|
114
|
+
|
|
115
|
+
| Category | Method | Endpoint |
|
|
116
|
+
| --------- | ------ | ---------------------------- |
|
|
117
|
+
| Info | GET | `/info` |
|
|
118
|
+
| Explore | GET | `/namespaces` |
|
|
119
|
+
| Explore | GET | `/objecttypes` |
|
|
120
|
+
| Explore | POST | `/objecttypes/query` |
|
|
121
|
+
| Explore | GET | `/relationshiptypes` |
|
|
122
|
+
| Explore | POST | `/relationshiptypes/query` |
|
|
123
|
+
| Explore | GET | `/objects` |
|
|
124
|
+
| Explore | POST | `/objects/list` |
|
|
125
|
+
| Explore | POST | `/objects/related` |
|
|
126
|
+
| Query | POST | `/objects/value` |
|
|
127
|
+
| Query | POST | `/objects/history` |
|
|
128
|
+
| Update | PUT | `/objects/value` |
|
|
129
|
+
| Update | PUT | `/objects/history` |
|
|
130
|
+
| Subscribe | POST | `/subscriptions` |
|
|
131
|
+
| Subscribe | POST | `/subscriptions/list` |
|
|
132
|
+
| Subscribe | POST | `/subscriptions/delete` |
|
|
133
|
+
| Subscribe | POST | `/subscriptions/register` |
|
|
134
|
+
| Subscribe | POST | `/subscriptions/unregister` |
|
|
135
|
+
| Subscribe | POST | `/subscriptions/stream` |
|
|
136
|
+
| Subscribe | POST | `/subscriptions/sync` |
|
|
137
|
+
|
|
138
|
+
All subscription requests carry the spec-required `clientId` (derived from the
|
|
139
|
+
server config node), which scopes subscriptions per client.
|
|
137
140
|
|
|
138
141
|
## Example Flows
|
|
139
142
|
|
|
@@ -601,7 +601,7 @@
|
|
|
601
601
|
"type": "comment",
|
|
602
602
|
"z": "i3x-demo-tab",
|
|
603
603
|
"name": "=== 3. WRITE - Write values ===",
|
|
604
|
-
"info": "The **i3x-write** node writes a value to an i3X object.\n\n**API endpoint:** `PUT /objects/
|
|
604
|
+
"info": "The **i3x-write** node writes a value to an i3X object.\n\n**API endpoint:** `PUT /objects/value` (bulk update format)\n\n**Input:**\n- `msg.payload` - The value to write (must match the element's type schema)\n- `msg.elementId` or node property - Target element\n\n**Output:**\n- `msg.payload` - Server confirmation (`{ success, message }`)\n- `msg.elementId` - The element ID that was written to\n\n**Payload format depends on the element type:**\n- `state-type` → object: `{ description, color, type: { id, name, description }, metadata: { ... } }`\n- `measurement-value-type` / `sensor-type` → plain number (e.g. `72.5`)\n- `measurement-health-type` → integer (e.g. `95`)\n\n**Analogy:** Comparable to OPC UA Write or S7 OUT.",
|
|
605
605
|
"x": 270,
|
|
606
606
|
"y": 800,
|
|
607
607
|
"wires": []
|
|
@@ -974,7 +974,7 @@
|
|
|
974
974
|
"type": "comment",
|
|
975
975
|
"z": "i3x-demo-tab",
|
|
976
976
|
"name": "=== 5. SUBSCRIBE - Monitor value changes ===",
|
|
977
|
-
"info": "The **i3x-subscribe** node monitors value changes and outputs a message on each update.\n\n**Modes:**\n- **SSE** (Server-Sent Events) – Real-time stream via `
|
|
977
|
+
"info": "The **i3x-subscribe** node monitors value changes and outputs a message on each update.\n\n**Modes:**\n- **SSE** (Server-Sent Events) – Real-time stream via `POST /subscriptions/stream`\n- **Polling** – Periodic sync via `POST /subscriptions/sync`\n\n**Behaviour:**\n- Automatically creates a subscription on deploy\n- Registers the specified element IDs as monitored items\n- Automatically deletes the subscription on stop/re-deploy\n- If SSE fails, automatically falls back to polling\n\n**Analogy:** Comparable to MQTT IN or OPC UA Subscription.\n\n**Note:** This node has no input – it starts automatically on deploy.",
|
|
978
978
|
"x": 310,
|
|
979
979
|
"y": 1320,
|
|
980
980
|
"wires": []
|
package/lib/i3x-client.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* I3XClient – Shared HTTP client for the i3X (CESMII) API.
|
|
3
3
|
*
|
|
4
|
-
* Wraps all REST endpoints described in the i3X OpenAPI 1.0
|
|
5
|
-
* Used by every node-red-contrib-i3x node via the
|
|
4
|
+
* Wraps all REST endpoints described in the i3X OpenAPI 1.0 Release spec
|
|
5
|
+
* (released 2026-06-09). Used by every node-red-contrib-i3x node via the
|
|
6
|
+
* i3x-server config node.
|
|
6
7
|
*
|
|
7
8
|
* @see https://api.i3x.dev/v1/docs
|
|
9
|
+
* @see https://github.com/cesmii/i3X/blob/1.0/CHANGELOG.md
|
|
8
10
|
*/
|
|
9
11
|
"use strict";
|
|
10
12
|
|
|
@@ -76,6 +78,8 @@ class I3XClient extends EventEmitter {
|
|
|
76
78
|
* @param {string} [config.apiKey]
|
|
77
79
|
* @param {object} [config.tlsOptions] – { rejectUnauthorized, ca, cert, key }
|
|
78
80
|
* @param {number} [config.timeout] – ms, default 10000
|
|
81
|
+
* @param {string} [config.clientId] – client identifier; required by the
|
|
82
|
+
* 1.0 spec on all subscription endpoints (scopes subscriptions per client)
|
|
79
83
|
*/
|
|
80
84
|
constructor(config) {
|
|
81
85
|
super();
|
|
@@ -83,6 +87,7 @@ class I3XClient extends EventEmitter {
|
|
|
83
87
|
this.apiVersion = config.apiVersion || "";
|
|
84
88
|
this.authType = config.authType || "none";
|
|
85
89
|
this.timeout = config.timeout || 10000;
|
|
90
|
+
this.clientId = config.clientId || "node-red-contrib-i3x";
|
|
86
91
|
|
|
87
92
|
// Warn if credentials are sent over plain HTTP (not localhost)
|
|
88
93
|
if (this.authType !== "none" && this.baseUrl && !this.baseUrl.startsWith("https://")) {
|
|
@@ -182,7 +187,7 @@ class I3XClient extends EventEmitter {
|
|
|
182
187
|
|
|
183
188
|
/**
|
|
184
189
|
* @param {object} [options]
|
|
185
|
-
* @param {string} [options.typeElementId] – filter by type
|
|
190
|
+
* @param {string} [options.typeElementId] – filter by type
|
|
186
191
|
* @param {string} [options.typeId] – legacy alias for typeElementId
|
|
187
192
|
* @param {boolean} [options.includeMetadata]
|
|
188
193
|
* @param {boolean} [options.root] – return only root objects
|
|
@@ -255,7 +260,10 @@ class I3XClient extends EventEmitter {
|
|
|
255
260
|
}
|
|
256
261
|
|
|
257
262
|
/**
|
|
258
|
-
* Single-object historical values query
|
|
263
|
+
* Single-object historical values query.
|
|
264
|
+
* @deprecated The per-element `GET /objects/{elementId}/history` endpoint was
|
|
265
|
+
* removed in the 1.0 Release – this now delegates to the bulk
|
|
266
|
+
* `POST /objects/history` endpoint and returns its bulk result array.
|
|
259
267
|
* @param {string} elementId
|
|
260
268
|
* @param {object} [options]
|
|
261
269
|
* @param {string} [options.startTime] – ISO 8601
|
|
@@ -263,72 +271,112 @@ class I3XClient extends EventEmitter {
|
|
|
263
271
|
* @param {number} [options.maxDepth]
|
|
264
272
|
*/
|
|
265
273
|
async getHistory(elementId, options = {}) {
|
|
266
|
-
|
|
267
|
-
if (options.startTime) params.startTime = options.startTime;
|
|
268
|
-
if (options.endTime) params.endTime = options.endTime;
|
|
269
|
-
if (options.maxDepth !== undefined) params.maxDepth = options.maxDepth;
|
|
270
|
-
const res = await this._requestRaw(
|
|
271
|
-
"get",
|
|
272
|
-
`/objects/${encodeURIComponent(elementId)}/history`,
|
|
273
|
-
{ params }
|
|
274
|
-
);
|
|
275
|
-
const result = I3XClient._unwrapEnvelope(res.data);
|
|
276
|
-
if (res.status === 206) {
|
|
277
|
-
result._partial = true;
|
|
278
|
-
}
|
|
279
|
-
return result;
|
|
274
|
+
return this.readHistory([elementId], options);
|
|
280
275
|
}
|
|
281
276
|
|
|
282
|
-
// ── Update
|
|
277
|
+
// ── Update (1.0 Release: bulk-only endpoints) ──────────────────────
|
|
283
278
|
|
|
284
279
|
/**
|
|
280
|
+
* Write the current value of a single object.
|
|
281
|
+
* Convenience wrapper around {@link writeValues}.
|
|
285
282
|
* @param {string} elementId
|
|
286
|
-
* @param {*} value
|
|
283
|
+
* @param {*} value – primitive, or VQT object {value, quality?, timestamp?}
|
|
287
284
|
*/
|
|
288
285
|
async writeValue(elementId, value) {
|
|
289
|
-
|
|
290
|
-
return this._put(`/objects/${encodeURIComponent(elementId)}/value`, value);
|
|
286
|
+
return this.writeValues([{ elementId, value }]);
|
|
291
287
|
}
|
|
292
288
|
|
|
293
289
|
/**
|
|
290
|
+
* Bulk-write current values (PUT /objects/value).
|
|
291
|
+
* @param {Array<{elementId:string, value:*}>} updates
|
|
292
|
+
*/
|
|
293
|
+
async writeValues(updates) {
|
|
294
|
+
if (!Array.isArray(updates) || updates.length === 0) {
|
|
295
|
+
throw new Error("updates must be a non-empty array of {elementId, value}");
|
|
296
|
+
}
|
|
297
|
+
const body = {
|
|
298
|
+
updates: updates.map((u) => {
|
|
299
|
+
if (!u || !u.elementId) {
|
|
300
|
+
throw new Error("Each update requires an elementId");
|
|
301
|
+
}
|
|
302
|
+
return { elementId: u.elementId, value: I3XClient._toVQT(u.value) };
|
|
303
|
+
}),
|
|
304
|
+
};
|
|
305
|
+
return this._put("/objects/value", body);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Write historical values of a single object (PUT /objects/history).
|
|
310
|
+
* For history writes the spec requires full VQTs – missing quality defaults
|
|
311
|
+
* to "Good", a missing timestamp defaults to the current time (UTC).
|
|
294
312
|
* @param {string} elementId
|
|
295
|
-
* @param {*} data –
|
|
313
|
+
* @param {*} data – VQT object, array of VQTs, or primitive value
|
|
296
314
|
*/
|
|
297
315
|
async writeHistory(elementId, data) {
|
|
298
|
-
|
|
299
|
-
|
|
316
|
+
const values = Array.isArray(data)
|
|
317
|
+
? data
|
|
318
|
+
: data && typeof data === "object" && Array.isArray(data.values)
|
|
319
|
+
? data.values
|
|
320
|
+
: [data];
|
|
321
|
+
if (values.length === 0) {
|
|
322
|
+
throw new Error("writeHistory requires at least one value");
|
|
323
|
+
}
|
|
324
|
+
const body = {
|
|
325
|
+
updates: values.map((v) => ({
|
|
326
|
+
elementId,
|
|
327
|
+
value: I3XClient._toHistoryVQT(v),
|
|
328
|
+
})),
|
|
329
|
+
};
|
|
330
|
+
return this._put("/objects/history", body);
|
|
300
331
|
}
|
|
301
332
|
|
|
302
|
-
static
|
|
303
|
-
"value", "timestamp", "quality", "displayName", "attributes", "metadata",
|
|
304
|
-
"startTime", "endTime", "values", "elementId", "status",
|
|
305
|
-
]);
|
|
333
|
+
static _VQT_FIELDS = new Set(["value", "quality", "timestamp"]);
|
|
306
334
|
|
|
307
|
-
|
|
308
|
-
|
|
335
|
+
/**
|
|
336
|
+
* Normalise a raw payload into a VQTInput {value, quality?, timestamp?}.
|
|
337
|
+
* Primitives and arrays are wrapped as {value}. Objects must use only
|
|
338
|
+
* the VQT fields – anything else is rejected to avoid silent data loss.
|
|
339
|
+
* @private
|
|
340
|
+
*/
|
|
341
|
+
static _toVQT(raw) {
|
|
342
|
+
if (raw === null || raw === undefined) {
|
|
309
343
|
throw new Error("Write payload must not be null or undefined");
|
|
310
344
|
}
|
|
311
|
-
if (typeof
|
|
312
|
-
const
|
|
313
|
-
const disallowed = keys.filter((k) => !I3XClient._WRITE_ALLOWED_FIELDS.has(k));
|
|
345
|
+
if (typeof raw === "object" && !Array.isArray(raw) && "value" in raw) {
|
|
346
|
+
const disallowed = Object.keys(raw).filter((k) => !I3XClient._VQT_FIELDS.has(k));
|
|
314
347
|
if (disallowed.length > 0) {
|
|
315
348
|
throw new Error("Disallowed fields in write payload: " + disallowed.join(", "));
|
|
316
349
|
}
|
|
350
|
+
const vqt = { value: raw.value };
|
|
351
|
+
if (raw.quality !== undefined) vqt.quality = raw.quality;
|
|
352
|
+
if (raw.timestamp !== undefined) vqt.timestamp = raw.timestamp;
|
|
353
|
+
return vqt;
|
|
317
354
|
}
|
|
355
|
+
return { value: raw };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Like _toVQT, but fills the fields the spec requires for history writes.
|
|
360
|
+
* @private
|
|
361
|
+
*/
|
|
362
|
+
static _toHistoryVQT(raw) {
|
|
363
|
+
const vqt = I3XClient._toVQT(raw);
|
|
364
|
+
if (vqt.quality === undefined) vqt.quality = "Good";
|
|
365
|
+
if (vqt.timestamp === undefined) vqt.timestamp = new Date().toISOString();
|
|
366
|
+
return vqt;
|
|
318
367
|
}
|
|
319
368
|
|
|
320
|
-
// ── Subscribe (
|
|
369
|
+
// ── Subscribe (1.0 Release: clientId required on all endpoints) ──
|
|
321
370
|
|
|
322
371
|
/**
|
|
323
372
|
* Create a new subscription.
|
|
324
373
|
* @param {object} [options]
|
|
325
|
-
* @param {string} [options.clientId] –
|
|
374
|
+
* @param {string} [options.clientId] – overrides the client-level clientId
|
|
326
375
|
* @param {string} [options.displayName] – optional human-readable name
|
|
327
376
|
* @returns {Promise<{subscriptionId:string, clientId?:string, displayName?:string}>}
|
|
328
377
|
*/
|
|
329
378
|
async createSubscription(options = {}) {
|
|
330
|
-
const body = {};
|
|
331
|
-
if (options.clientId) body.clientId = options.clientId;
|
|
379
|
+
const body = { clientId: options.clientId || this.clientId };
|
|
332
380
|
if (options.displayName) body.displayName = options.displayName;
|
|
333
381
|
return this._post("/subscriptions", body);
|
|
334
382
|
}
|
|
@@ -337,11 +385,10 @@ class I3XClient extends EventEmitter {
|
|
|
337
385
|
* List subscriptions by IDs.
|
|
338
386
|
* @param {string[]} subscriptionIds
|
|
339
387
|
* @param {object} [options]
|
|
340
|
-
* @param {string} [options.clientId]
|
|
388
|
+
* @param {string} [options.clientId] – overrides the client-level clientId
|
|
341
389
|
*/
|
|
342
390
|
async listSubscriptions(subscriptionIds, options = {}) {
|
|
343
|
-
const body = { subscriptionIds };
|
|
344
|
-
if (options.clientId) body.clientId = options.clientId;
|
|
391
|
+
const body = { clientId: options.clientId || this.clientId, subscriptionIds };
|
|
345
392
|
return this._post("/subscriptions/list", body);
|
|
346
393
|
}
|
|
347
394
|
|
|
@@ -349,11 +396,10 @@ class I3XClient extends EventEmitter {
|
|
|
349
396
|
* Delete one or more subscriptions.
|
|
350
397
|
* @param {string[]} subscriptionIds
|
|
351
398
|
* @param {object} [options]
|
|
352
|
-
* @param {string} [options.clientId]
|
|
399
|
+
* @param {string} [options.clientId] – overrides the client-level clientId
|
|
353
400
|
*/
|
|
354
401
|
async deleteSubscriptions(subscriptionIds, options = {}) {
|
|
355
|
-
const body = { subscriptionIds };
|
|
356
|
-
if (options.clientId) body.clientId = options.clientId;
|
|
402
|
+
const body = { clientId: options.clientId || this.clientId, subscriptionIds };
|
|
357
403
|
return this._post("/subscriptions/delete", body);
|
|
358
404
|
}
|
|
359
405
|
|
|
@@ -363,15 +409,19 @@ class I3XClient extends EventEmitter {
|
|
|
363
409
|
* @param {string[]} elementIds
|
|
364
410
|
* @param {object} [options]
|
|
365
411
|
* @param {number} [options.maxDepth] – default 1
|
|
366
|
-
* @param {string} [options.clientId]
|
|
412
|
+
* @param {string} [options.clientId] – overrides the client-level clientId
|
|
367
413
|
*/
|
|
368
414
|
async registerMonitoredItems(subscriptionId, elementIds, options = {}) {
|
|
369
415
|
// Support legacy positional maxDepth: registerMonitoredItems(id, ids, 2)
|
|
370
416
|
if (typeof options === "number") {
|
|
371
417
|
options = { maxDepth: options };
|
|
372
418
|
}
|
|
373
|
-
const body = {
|
|
374
|
-
|
|
419
|
+
const body = {
|
|
420
|
+
clientId: options.clientId || this.clientId,
|
|
421
|
+
subscriptionId,
|
|
422
|
+
elementIds,
|
|
423
|
+
maxDepth: options.maxDepth !== undefined ? options.maxDepth : 1,
|
|
424
|
+
};
|
|
375
425
|
return this._post("/subscriptions/register", body);
|
|
376
426
|
}
|
|
377
427
|
|
|
@@ -380,11 +430,14 @@ class I3XClient extends EventEmitter {
|
|
|
380
430
|
* @param {string} subscriptionId
|
|
381
431
|
* @param {string[]} elementIds
|
|
382
432
|
* @param {object} [options]
|
|
383
|
-
* @param {string} [options.clientId]
|
|
433
|
+
* @param {string} [options.clientId] – overrides the client-level clientId
|
|
384
434
|
*/
|
|
385
435
|
async unregisterMonitoredItems(subscriptionId, elementIds, options = {}) {
|
|
386
|
-
const body = {
|
|
387
|
-
|
|
436
|
+
const body = {
|
|
437
|
+
clientId: options.clientId || this.clientId,
|
|
438
|
+
subscriptionId,
|
|
439
|
+
elementIds,
|
|
440
|
+
};
|
|
388
441
|
return this._post("/subscriptions/unregister", body);
|
|
389
442
|
}
|
|
390
443
|
|
|
@@ -414,8 +467,10 @@ class I3XClient extends EventEmitter {
|
|
|
414
467
|
const maxReconnects = options.maxReconnects !== undefined ? options.maxReconnects : 5;
|
|
415
468
|
|
|
416
469
|
const url = `${this._prefix()}/subscriptions/stream`;
|
|
417
|
-
const postBody = {
|
|
418
|
-
|
|
470
|
+
const postBody = {
|
|
471
|
+
clientId: options.clientId || this.clientId,
|
|
472
|
+
subscriptionId,
|
|
473
|
+
};
|
|
419
474
|
|
|
420
475
|
let controller = new AbortController();
|
|
421
476
|
let closed = false;
|
|
@@ -482,6 +537,13 @@ class I3XClient extends EventEmitter {
|
|
|
482
537
|
if (!closed) reconnect(err);
|
|
483
538
|
});
|
|
484
539
|
} catch (err) {
|
|
540
|
+
// 1.0 Release: poll-only servers SHOULD return 501 for /stream –
|
|
541
|
+
// surface it immediately so callers can fall back to /sync polling.
|
|
542
|
+
if (err.response && err.response.status === 501) {
|
|
543
|
+
closed = true;
|
|
544
|
+
if (onError) onError(this._wrapError(err));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
485
547
|
if (!closed) reconnect(err);
|
|
486
548
|
}
|
|
487
549
|
};
|
|
@@ -512,17 +574,22 @@ class I3XClient extends EventEmitter {
|
|
|
512
574
|
|
|
513
575
|
/**
|
|
514
576
|
* Poll-based sync: acknowledge previously received updates and return pending ones.
|
|
577
|
+
* The 1.0 Release returns updates grouped into batches:
|
|
578
|
+
* [{sequenceNumber, updates: [...]}, ...]. Pass lastSequenceNumber = -1 to
|
|
579
|
+
* acknowledge and clear all pending updates in one round trip.
|
|
515
580
|
* @param {string} subscriptionId
|
|
516
581
|
* @param {object} [options]
|
|
517
582
|
* @param {number} [options.lastSequenceNumber] – acknowledge events up to this sequence number
|
|
518
|
-
* @param {string} [options.clientId]
|
|
583
|
+
* @param {string} [options.clientId] – overrides the client-level clientId
|
|
519
584
|
*/
|
|
520
585
|
async syncSubscription(subscriptionId, options = {}) {
|
|
521
|
-
const body = {
|
|
586
|
+
const body = {
|
|
587
|
+
clientId: options.clientId || this.clientId,
|
|
588
|
+
subscriptionId,
|
|
589
|
+
};
|
|
522
590
|
if (options.lastSequenceNumber !== undefined) {
|
|
523
591
|
body.lastSequenceNumber = options.lastSequenceNumber;
|
|
524
592
|
}
|
|
525
|
-
if (options.clientId) body.clientId = options.clientId;
|
|
526
593
|
return this._post("/subscriptions/sync", body);
|
|
527
594
|
}
|
|
528
595
|
|
|
@@ -585,7 +652,7 @@ class I3XClient extends EventEmitter {
|
|
|
585
652
|
}
|
|
586
653
|
|
|
587
654
|
/**
|
|
588
|
-
* Unwrap the
|
|
655
|
+
* Unwrap the spec response envelope if present.
|
|
589
656
|
* SuccessResponse: {success, result} → result
|
|
590
657
|
* BulkResponse: {success, results} → results
|
|
591
658
|
* @private
|
|
@@ -650,6 +717,13 @@ class I3XClient extends EventEmitter {
|
|
|
650
717
|
delete sanitized[key];
|
|
651
718
|
}
|
|
652
719
|
wrapped.body = sanitized;
|
|
720
|
+
// 1.0 Release: error details live in `responseDetail`
|
|
721
|
+
// (Beta used `problemDetail`, earlier drafts `error`)
|
|
722
|
+
const detail = body.responseDetail || body.problemDetail || body.error;
|
|
723
|
+
if (detail && typeof detail === "object") {
|
|
724
|
+
const text = detail.detail || detail.message || detail.title;
|
|
725
|
+
if (text) wrapped.message = `${err.message}: ${text}`;
|
|
726
|
+
}
|
|
653
727
|
} else {
|
|
654
728
|
wrapped.body = body;
|
|
655
729
|
}
|
package/nodes/i3x-server.js
CHANGED
|
@@ -22,6 +22,10 @@ module.exports = function (RED) {
|
|
|
22
22
|
apiVersion: node.apiVersion,
|
|
23
23
|
authType: node.authType,
|
|
24
24
|
timeout: node.timeout,
|
|
25
|
+
// 1.0 spec: clientId is required on all subscription endpoints and
|
|
26
|
+
// scopes subscriptions per client. The config-node id is stable
|
|
27
|
+
// across restarts, so subscriptions survive a redeploy cleanly.
|
|
28
|
+
clientId: "node-red-" + node.id.replace(/\./g, "-"),
|
|
25
29
|
};
|
|
26
30
|
|
|
27
31
|
if (node.credentials) {
|
package/nodes/i3x-subscribe.html
CHANGED
|
@@ -43,11 +43,13 @@
|
|
|
43
43
|
</dl>
|
|
44
44
|
|
|
45
45
|
<h3>Details</h3>
|
|
46
|
-
<p>Creates a server-side subscription via the i3X Subscribe API and monitors the specified elements
|
|
47
|
-
<
|
|
46
|
+
<p>Creates a server-side subscription via the i3X Subscribe API and monitors the specified elements.
|
|
47
|
+
Subscriptions are scoped by a <code>clientId</code> derived from the server config node (required by i3X 1.0).</p>
|
|
48
|
+
<p><b>SSE mode</b> opens a persistent Server-Sent Events stream (<code>POST /subscriptions/stream</code>)
|
|
48
49
|
and emits messages in real-time as values change.</p>
|
|
49
|
-
<p><b>Polling mode</b> periodically calls <code>POST /subscriptions/
|
|
50
|
-
|
|
50
|
+
<p><b>Polling mode</b> periodically calls <code>POST /subscriptions/sync</code> to retrieve queued
|
|
51
|
+
update batches; previously received batches are acknowledged via their sequence number. If SSE setup
|
|
52
|
+
fails — or the server does not support streaming (HTTP 501) — the node automatically falls back to polling.</p>
|
|
51
53
|
<p>The subscription is automatically cleaned up (deleted from the server) when the node is stopped or re-deployed.</p>
|
|
52
54
|
<p>Use the <b>Browse</b> button to visually select elements from the server.</p>
|
|
53
55
|
</script>
|
package/nodes/i3x-subscribe.js
CHANGED
|
@@ -19,6 +19,7 @@ module.exports = function (RED) {
|
|
|
19
19
|
node._sseHandle = null;
|
|
20
20
|
node._pollTimer = null;
|
|
21
21
|
node._closing = false;
|
|
22
|
+
node._lastSequenceNumber = undefined;
|
|
22
23
|
|
|
23
24
|
if (!bindServer(node, RED, config.server)) return;
|
|
24
25
|
|
|
@@ -77,6 +78,13 @@ module.exports = function (RED) {
|
|
|
77
78
|
},
|
|
78
79
|
onError: (err) => {
|
|
79
80
|
if (node._closing) return;
|
|
81
|
+
// 1.0 spec: poll-only servers return 501 for /stream
|
|
82
|
+
if (err.statusCode === 501) {
|
|
83
|
+
node.warn("Server does not support SSE streaming (501) – falling back to polling");
|
|
84
|
+
node._sseHandle = null;
|
|
85
|
+
startPolling(client);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
80
88
|
node.status({ fill: "red", shape: "ring", text: "stream error" });
|
|
81
89
|
node.error("SSE stream error: " + err.message);
|
|
82
90
|
},
|
|
@@ -94,9 +102,29 @@ module.exports = function (RED) {
|
|
|
94
102
|
async function poll() {
|
|
95
103
|
if (node._closing) return;
|
|
96
104
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
// Acknowledge everything received so far, then fetch pending batches
|
|
106
|
+
const opts = {};
|
|
107
|
+
if (node._lastSequenceNumber !== undefined) {
|
|
108
|
+
opts.lastSequenceNumber = node._lastSequenceNumber;
|
|
109
|
+
}
|
|
110
|
+
const batches = await client.syncSubscription(node._subscriptionId, opts);
|
|
111
|
+
// 1.0 spec: [{sequenceNumber, updates: [...]}, ...]
|
|
112
|
+
const updates = [];
|
|
113
|
+
for (const batch of Array.isArray(batches) ? batches : []) {
|
|
114
|
+
if (batch && typeof batch === "object" && Array.isArray(batch.updates)) {
|
|
115
|
+
updates.push(...batch.updates);
|
|
116
|
+
if (typeof batch.sequenceNumber === "number") {
|
|
117
|
+
node._lastSequenceNumber = node._lastSequenceNumber === undefined
|
|
118
|
+
? batch.sequenceNumber
|
|
119
|
+
: Math.max(node._lastSequenceNumber, batch.sequenceNumber);
|
|
120
|
+
}
|
|
121
|
+
} else if (batch !== null && batch !== undefined) {
|
|
122
|
+
// tolerate pre-1.0 servers returning a flat update list
|
|
123
|
+
updates.push(batch);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (updates.length > 0) {
|
|
127
|
+
node.send({ payload: updates, topic: "i3x/subscription" });
|
|
100
128
|
}
|
|
101
129
|
} catch (err) {
|
|
102
130
|
if (!node._closing) {
|
|
@@ -144,6 +172,7 @@ module.exports = function (RED) {
|
|
|
144
172
|
if (node._pollTimer) { clearInterval(node._pollTimer); node._pollTimer = null; }
|
|
145
173
|
if (node._sseHandle) { node._sseHandle.close(); node._sseHandle = null; }
|
|
146
174
|
node._subscriptionId = null;
|
|
175
|
+
node._lastSequenceNumber = undefined;
|
|
147
176
|
setup();
|
|
148
177
|
}
|
|
149
178
|
});
|
package/nodes/i3x-write.html
CHANGED
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
<h3>Inputs</h3>
|
|
30
30
|
<dl class="message-properties">
|
|
31
31
|
<dt>payload <span class="property-type">any</span></dt>
|
|
32
|
-
<dd>The value to write.
|
|
32
|
+
<dd>The value to write. Either a plain value (wrapped automatically) or a
|
|
33
|
+
VQT object <code>{value, quality, timestamp}</code>. For history writes an
|
|
34
|
+
array of VQTs writes multiple records at once; missing <code>quality</code>
|
|
35
|
+
defaults to <code>"Good"</code>, missing <code>timestamp</code> to now.</dd>
|
|
33
36
|
<dt class="optional">elementId <span class="property-type">string</span></dt>
|
|
34
37
|
<dd>Target element ID. Overrides the configured value. Also accepts <code>msg.nodeId</code>.</dd>
|
|
35
38
|
<dt class="optional">writeTarget <span class="property-type">string</span></dt>
|
|
@@ -45,10 +48,10 @@
|
|
|
45
48
|
</dl>
|
|
46
49
|
|
|
47
50
|
<h3>Details</h3>
|
|
48
|
-
<p>When <b>Target</b> is set to <i>Current Value</i>, uses <code>PUT /objects/
|
|
51
|
+
<p>When <b>Target</b> is set to <i>Current Value</i>, uses <code>PUT /objects/value</code>
|
|
49
52
|
to update the current value of the object.</p>
|
|
50
|
-
<p>When <b>Target</b> is set to <i>Historical Data</i>, uses <code>PUT /objects/
|
|
51
|
-
to write historical time-series records.</p>
|
|
53
|
+
<p>When <b>Target</b> is set to <i>Historical Data</i>, uses <code>PUT /objects/history</code>
|
|
54
|
+
to write historical time-series records (i3X 1.0 bulk update format).</p>
|
|
52
55
|
<p>Use the <b>Browse</b> button to visually select the target element from the server,
|
|
53
56
|
or provide the element ID via <code>msg.elementId</code> at runtime.</p>
|
|
54
57
|
</script>
|
package/package.json
CHANGED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"WebFetch(domain:www.i3x.dev)",
|
|
5
|
-
"WebFetch(domain:api.i3x.dev)",
|
|
6
|
-
"Bash(npx mocha:*)",
|
|
7
|
-
"Bash(npm test:*)",
|
|
8
|
-
"Bash(node-red:*)",
|
|
9
|
-
"Bash(npx node-red:*)",
|
|
10
|
-
"Bash(docker compose:*)",
|
|
11
|
-
"Bash(ls /home/la/private/node-red-contrib-i3x/docker-compose* /home/la/private/node-red-contrib-i3x/Dockerfile* 2>/dev/null)"
|
|
12
|
-
]
|
|
13
|
-
}
|
|
14
|
-
}
|