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 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:** The i3X API is currently in **beta (v1.0-Beta)**. Response structures may change as the specification evolves.
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 | Payload format |
72
- | --------- | ------------------------------------- | ------------------------------------- |
73
- | `value` | `PUT /objects/{elementId}/value` | Depends on type schema (number, object, …) |
74
- | `history` | `PUT /objects/{elementId}/history` | Array of VQT records `[{value, quality, timestamp}, …]` |
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 (`GET /subscriptions/{id}/stream`)
89
- - **Polling mode:** Periodically calls `POST /subscriptions/{id}/sync`
90
- - **Fallback:** If SSE fails, the node automatically falls back to polling
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-Beta](https://api.i3x.dev/v1/docs):
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 | GET | `/objects/{elementId}/history` |
126
- | Query | POST | `/objects/history` |
127
- | Update | PUT | `/objects/{elementId}/value` |
128
- | Update | PUT | `/objects/{elementId}/history` |
129
- | Subscribe | GET | `/subscriptions` |
130
- | Subscribe | POST | `/subscriptions` |
131
- | Subscribe | GET | `/subscriptions/{subscriptionId}` |
132
- | Subscribe | DELETE | `/subscriptions/{subscriptionId}` |
133
- | Subscribe | POST | `/subscriptions/{subscriptionId}/register` |
134
- | Subscribe | POST | `/subscriptions/{subscriptionId}/unregister` |
135
- | Subscribe | GET | `/subscriptions/{subscriptionId}/stream` |
136
- | Subscribe | POST | `/subscriptions/{subscriptionId}/sync` |
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/{elementId}/value`\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.",
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 `GET /subscriptions/{id}/stream`\n- **Polling** – Periodic sync via `POST /subscriptions/{id}/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.",
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-Beta spec.
5
- * Used by every node-red-contrib-i3x node via the i3x-server config node.
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 (Beta spec name)
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 (GET).
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
- const params = {};
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
- I3XClient._validateWritePayload(value);
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 – historical data payload
313
+ * @param {*} data – VQT object, array of VQTs, or primitive value
296
314
  */
297
315
  async writeHistory(elementId, data) {
298
- I3XClient._validateWritePayload(data);
299
- return this._put(`/objects/${encodeURIComponent(elementId)}/history`, data);
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 _WRITE_ALLOWED_FIELDS = new Set([
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
- static _validateWritePayload(payload) {
308
- if (payload === null || payload === undefined) {
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 payload === "object" && !Array.isArray(payload)) {
312
- const keys = Object.keys(payload);
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 (Beta-Spec: body-based endpoints) ──────────────────
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] – optional client identifier
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 = { subscriptionId, elementIds, maxDepth: options.maxDepth !== undefined ? options.maxDepth : 1 };
374
- if (options.clientId) body.clientId = options.clientId;
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 = { subscriptionId, elementIds };
387
- if (options.clientId) body.clientId = options.clientId;
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 = { subscriptionId };
418
- if (options.clientId) postBody.clientId = options.clientId;
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 = { subscriptionId };
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 Beta-Spec response envelope if present.
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
  }
@@ -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) {
@@ -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.</p>
47
- <p><b>SSE mode</b> opens a persistent Server-Sent Events stream (<code>GET /subscriptions/{id}/stream</code>)
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/{id}/sync</code> to retrieve and
50
- clear queued updates. If SSE setup fails, the node automatically falls back to polling.</p>
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>
@@ -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
- const data = await client.syncSubscription(node._subscriptionId);
98
- if (data && (Array.isArray(data) ? data.length > 0 : Object.keys(data).length > 0)) {
99
- node.send({ payload: data, topic: "i3x/subscription" });
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
  });
@@ -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. Format depends on the element type schema.</dd>
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/{elementId}/value</code>
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/{elementId}/history</code>
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,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-i3x",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Node-RED nodes for the i3X (Industrial Information Interoperability eXchange) API by CESMII",
5
5
  "keywords": [
6
6
  "node-red",
@@ -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
- }