node-red-contrib-i3x 0.0.3 → 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,72 @@
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
+
42
+ ## 0.0.4 (2026-04-12)
43
+
44
+ Migration to i3X API 1.0-Beta specification with enhanced query capabilities and improved API alignment.
45
+
46
+ ### Added
47
+
48
+ - **Server Info Endpoint** – New `getInfo()` method retrieves server metadata (spec version, server version, capabilities) from `GET /info` endpoint with TTL caching
49
+ - **Single-Object History Query** – New `getHistory(elementId, options)` method for `GET /objects/{elementId}/history` endpoint
50
+ - **Partial Response Handling** – History queries now detect HTTP 206 status and set `_partial: true` flag on results when server returns incomplete data
51
+ - **Root Objects Filter** – `getObjects()` now supports `root: true` parameter to retrieve only root-level objects
52
+ - **Enhanced Subscribe Options** – Subscribe node now supports `maxDepth`, `includeMetadata`, and `returnMode` parameters for fine-grained control
53
+
54
+ ### Changed
55
+
56
+ - **API Version Update** – Updated from i3X v0.0.1 to 1.0-Beta specification
57
+ - **API Documentation URL** – Changed from `https://i3x.cesmii.net/docs` to `https://api.i3x.dev/v1/docs`
58
+ - **Parameter Naming** – `typeId` parameter renamed to `typeElementId` in `getObjects()` (legacy `typeId` still supported as alias)
59
+ - **Relationship Type Parameter** – Fixed casing from `relationshiptype` to `relationshipType` in `getRelatedObjects()`
60
+ - **History Query Implementation** – `getHistoryBulk()` now uses `_requestRaw()` to access HTTP status codes for partial response detection
61
+
62
+ ### Tests
63
+
64
+ - Updated all test cases to reflect 1.0-Beta API changes
65
+ - Added tests for new `getInfo()` endpoint
66
+ - Added tests for single-object history query
67
+ - Added tests for partial response handling (HTTP 206)
68
+ - Updated integration tests for new parameter names
69
+
3
70
  ## 0.0.3 (2026-03-10)
4
71
 
5
72
  Hardening, security improvements, and new browser widget features.
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 **pre-alpha (v0)**. 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,30 +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 Prototype v0.0.1](https://api.i3x.dev/v0/docs):
112
-
113
- | Category | Method | Endpoint |
114
- | --------- | ------ | -------------------------------------------- |
115
- | Explore | GET | `/namespaces` |
116
- | Explore | GET | `/objecttypes` |
117
- | Explore | POST | `/objecttypes/query` |
118
- | Explore | GET | `/relationshiptypes` |
119
- | Explore | POST | `/relationshiptypes/query` |
120
- | Explore | GET | `/objects` |
121
- | Explore | POST | `/objects/list` |
122
- | Explore | POST | `/objects/related` |
123
- | Query | POST | `/objects/value` |
124
- | Query | POST | `/objects/history` |
125
- | Update | PUT | `/objects/{elementId}/value` |
126
- | Update | PUT | `/objects/{elementId}/history` |
127
- | Subscribe | GET | `/subscriptions` |
128
- | Subscribe | POST | `/subscriptions` |
129
- | Subscribe | GET | `/subscriptions/{subscriptionId}` |
130
- | Subscribe | DELETE | `/subscriptions/{subscriptionId}` |
131
- | Subscribe | POST | `/subscriptions/{subscriptionId}/register` |
132
- | Subscribe | POST | `/subscriptions/{subscriptionId}/unregister` |
133
- | Subscribe | GET | `/subscriptions/{subscriptionId}/stream` |
134
- | 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.
135
140
 
136
141
  ## Example Flows
137
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 v0.0.1 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
- * @see https://i3x.cesmii.net/docs
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://")) {
@@ -118,6 +123,21 @@ class I3XClient extends EventEmitter {
118
123
  this._rateLimiter = new RateLimiter();
119
124
  }
120
125
 
126
+ // ── Server Info ──────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Retrieve server information (no authentication required).
130
+ * @returns {Promise<{specVersion:string, serverVersion:string, serverName:string, capabilities:object}>}
131
+ */
132
+ async getInfo() {
133
+ const cacheKey = "info";
134
+ const cached = this._cache.get(cacheKey);
135
+ if (cached) return cached;
136
+ const result = await this._get("/info");
137
+ this._cache.set(cacheKey, result);
138
+ return result;
139
+ }
140
+
121
141
  // ── Explore ────────────────────────────────────────────────────────
122
142
 
123
143
  /** @returns {Promise<Array<{uri:string, displayName:string}>>} */
@@ -166,14 +186,18 @@ class I3XClient extends EventEmitter {
166
186
  }
167
187
 
168
188
  /**
169
- * @param {object} [options]
170
- * @param {string} [options.typeId]
189
+ * @param {object} [options]
190
+ * @param {string} [options.typeElementId] – filter by type
191
+ * @param {string} [options.typeId] – legacy alias for typeElementId
171
192
  * @param {boolean} [options.includeMetadata]
193
+ * @param {boolean} [options.root] – return only root objects
172
194
  */
173
195
  async getObjects(options = {}) {
174
196
  const params = {};
175
- if (options.typeId) params.typeId = options.typeId;
197
+ const typeFilter = options.typeElementId || options.typeId;
198
+ if (typeFilter) params.typeElementId = typeFilter;
176
199
  if (options.includeMetadata) params.includeMetadata = true;
200
+ if (options.root) params.root = true;
177
201
  return this._get("/objects", params);
178
202
  }
179
203
 
@@ -196,7 +220,7 @@ class I3XClient extends EventEmitter {
196
220
  */
197
221
  async getRelatedObjects(elementIds, options = {}) {
198
222
  const body = { elementIds };
199
- if (options.relationshipType) body.relationshiptype = options.relationshipType;
223
+ if (options.relationshipType) body.relationshipType = options.relationshipType;
200
224
  if (options.includeMetadata) body.includeMetadata = true;
201
225
  return this._post("/objects/related", body);
202
226
  }
@@ -215,6 +239,7 @@ class I3XClient extends EventEmitter {
215
239
  }
216
240
 
217
241
  /**
242
+ * Bulk historical values query (POST).
218
243
  * @param {string[]} elementIds
219
244
  * @param {object} [options]
220
245
  * @param {string} [options.startTime] – ISO 8601
@@ -226,112 +251,227 @@ class I3XClient extends EventEmitter {
226
251
  if (options.startTime) body.startTime = options.startTime;
227
252
  if (options.endTime) body.endTime = options.endTime;
228
253
  if (options.maxDepth !== undefined) body.maxDepth = options.maxDepth;
229
- return this._post("/objects/history", body);
254
+ const res = await this._requestRaw("post", "/objects/history", { data: body });
255
+ const result = I3XClient._unwrapEnvelope(res.data);
256
+ if (res.status === 206) {
257
+ result._partial = true;
258
+ }
259
+ return result;
260
+ }
261
+
262
+ /**
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.
267
+ * @param {string} elementId
268
+ * @param {object} [options]
269
+ * @param {string} [options.startTime] – ISO 8601
270
+ * @param {string} [options.endTime] – ISO 8601
271
+ * @param {number} [options.maxDepth]
272
+ */
273
+ async getHistory(elementId, options = {}) {
274
+ return this.readHistory([elementId], options);
230
275
  }
231
276
 
232
- // ── Update ─────────────────────────────────────────────────────────
277
+ // ── Update (1.0 Release: bulk-only endpoints) ──────────────────────
233
278
 
234
279
  /**
280
+ * Write the current value of a single object.
281
+ * Convenience wrapper around {@link writeValues}.
235
282
  * @param {string} elementId
236
- * @param {*} value
283
+ * @param {*} value – primitive, or VQT object {value, quality?, timestamp?}
237
284
  */
238
285
  async writeValue(elementId, value) {
239
- I3XClient._validateWritePayload(value);
240
- return this._put(`/objects/${encodeURIComponent(elementId)}/value`, value);
286
+ return this.writeValues([{ elementId, value }]);
241
287
  }
242
288
 
243
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).
244
312
  * @param {string} elementId
245
- * @param {*} data – historical data payload
313
+ * @param {*} data – VQT object, array of VQTs, or primitive value
246
314
  */
247
315
  async writeHistory(elementId, data) {
248
- I3XClient._validateWritePayload(data);
249
- 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);
250
331
  }
251
332
 
252
- static _WRITE_ALLOWED_FIELDS = new Set([
253
- "value", "timestamp", "quality", "displayName", "attributes", "metadata",
254
- "startTime", "endTime", "values", "elementId", "status",
255
- ]);
333
+ static _VQT_FIELDS = new Set(["value", "quality", "timestamp"]);
256
334
 
257
- static _validateWritePayload(payload) {
258
- 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) {
259
343
  throw new Error("Write payload must not be null or undefined");
260
344
  }
261
- if (typeof payload === "object" && !Array.isArray(payload)) {
262
- const keys = Object.keys(payload);
263
- 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));
264
347
  if (disallowed.length > 0) {
265
348
  throw new Error("Disallowed fields in write payload: " + disallowed.join(", "));
266
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;
267
354
  }
355
+ return { value: raw };
268
356
  }
269
357
 
270
- // ── Subscribe ──────────────────────────────────────────────────────
271
-
272
- async listSubscriptions() {
273
- return this._get("/subscriptions");
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;
274
367
  }
275
368
 
276
- /** @returns {Promise<{subscriptionId:string, message:string}>} */
277
- async createSubscription() {
278
- return this._post("/subscriptions", {});
369
+ // ── Subscribe (1.0 Release: clientId required on all endpoints) ──
370
+
371
+ /**
372
+ * Create a new subscription.
373
+ * @param {object} [options]
374
+ * @param {string} [options.clientId] – overrides the client-level clientId
375
+ * @param {string} [options.displayName] – optional human-readable name
376
+ * @returns {Promise<{subscriptionId:string, clientId?:string, displayName?:string}>}
377
+ */
378
+ async createSubscription(options = {}) {
379
+ const body = { clientId: options.clientId || this.clientId };
380
+ if (options.displayName) body.displayName = options.displayName;
381
+ return this._post("/subscriptions", body);
279
382
  }
280
383
 
281
- /** @param {string} subscriptionId */
282
- async getSubscription(subscriptionId) {
283
- return this._get(`/subscriptions/${encodeURIComponent(subscriptionId)}`);
384
+ /**
385
+ * List subscriptions by IDs.
386
+ * @param {string[]} subscriptionIds
387
+ * @param {object} [options]
388
+ * @param {string} [options.clientId] – overrides the client-level clientId
389
+ */
390
+ async listSubscriptions(subscriptionIds, options = {}) {
391
+ const body = { clientId: options.clientId || this.clientId, subscriptionIds };
392
+ return this._post("/subscriptions/list", body);
284
393
  }
285
394
 
286
- /** @param {string} subscriptionId */
287
- async deleteSubscription(subscriptionId) {
288
- return this._delete(`/subscriptions/${encodeURIComponent(subscriptionId)}`);
395
+ /**
396
+ * Delete one or more subscriptions.
397
+ * @param {string[]} subscriptionIds
398
+ * @param {object} [options]
399
+ * @param {string} [options.clientId] – overrides the client-level clientId
400
+ */
401
+ async deleteSubscriptions(subscriptionIds, options = {}) {
402
+ const body = { clientId: options.clientId || this.clientId, subscriptionIds };
403
+ return this._post("/subscriptions/delete", body);
289
404
  }
290
405
 
291
406
  /**
407
+ * Register monitored items on a subscription.
292
408
  * @param {string} subscriptionId
293
409
  * @param {string[]} elementIds
294
- * @param {number} [maxDepth]
410
+ * @param {object} [options]
411
+ * @param {number} [options.maxDepth] – default 1
412
+ * @param {string} [options.clientId] – overrides the client-level clientId
295
413
  */
296
- async registerMonitoredItems(subscriptionId, elementIds, maxDepth = 1) {
297
- const body = { elementIds, maxDepth };
298
- return this._post(
299
- `/subscriptions/${encodeURIComponent(subscriptionId)}/register`,
300
- body
301
- );
414
+ async registerMonitoredItems(subscriptionId, elementIds, options = {}) {
415
+ // Support legacy positional maxDepth: registerMonitoredItems(id, ids, 2)
416
+ if (typeof options === "number") {
417
+ options = { maxDepth: options };
418
+ }
419
+ const body = {
420
+ clientId: options.clientId || this.clientId,
421
+ subscriptionId,
422
+ elementIds,
423
+ maxDepth: options.maxDepth !== undefined ? options.maxDepth : 1,
424
+ };
425
+ return this._post("/subscriptions/register", body);
302
426
  }
303
427
 
304
428
  /**
429
+ * Unregister monitored items from a subscription.
305
430
  * @param {string} subscriptionId
306
431
  * @param {string[]} elementIds
432
+ * @param {object} [options]
433
+ * @param {string} [options.clientId] – overrides the client-level clientId
307
434
  */
308
- async unregisterMonitoredItems(subscriptionId, elementIds) {
309
- const body = { elementIds };
310
- return this._post(
311
- `/subscriptions/${encodeURIComponent(subscriptionId)}/unregister`,
312
- body
313
- );
435
+ async unregisterMonitoredItems(subscriptionId, elementIds, options = {}) {
436
+ const body = {
437
+ clientId: options.clientId || this.clientId,
438
+ subscriptionId,
439
+ elementIds,
440
+ };
441
+ return this._post("/subscriptions/unregister", body);
314
442
  }
315
443
 
316
444
  /**
317
- * Open an SSE stream for the given subscription.
445
+ * Open an SSE stream for the given subscription (POST).
318
446
  * Supports automatic reconnection on stream errors.
319
447
  *
320
448
  * @param {string} subscriptionId
321
449
  * @param {object} callbacks
322
450
  * @param {function} callbacks.onData – called with each parsed SSE event
323
- * @param {function} [callbacks.onError] – called on stream errors (instead of EventEmitter)
451
+ * @param {function} [callbacks.onError] – called on stream errors
324
452
  * @param {function} [callbacks.onReconnect] – called when a reconnection attempt starts
325
- * @param {number} [maxReconnects=5] – max consecutive reconnection attempts
453
+ * @param {object} [options]
454
+ * @param {string} [options.clientId]
455
+ * @param {number} [options.maxReconnects=5]
326
456
  * @returns {{ close: function }} handle to close the stream
327
457
  */
328
- streamSubscription(subscriptionId, callbacks, maxReconnects = 5) {
458
+ streamSubscription(subscriptionId, callbacks, options = {}) {
329
459
  if (typeof callbacks === "function") {
330
460
  callbacks = { onData: callbacks };
331
461
  }
462
+ // Support legacy positional maxReconnects number
463
+ if (typeof options === "number") {
464
+ options = { maxReconnects: options };
465
+ }
332
466
  const { onData, onError, onReconnect } = callbacks;
467
+ const maxReconnects = options.maxReconnects !== undefined ? options.maxReconnects : 5;
468
+
469
+ const url = `${this._prefix()}/subscriptions/stream`;
470
+ const postBody = {
471
+ clientId: options.clientId || this.clientId,
472
+ subscriptionId,
473
+ };
333
474
 
334
- const url = `${this._prefix()}/subscriptions/${encodeURIComponent(subscriptionId)}/stream`;
335
475
  let controller = new AbortController();
336
476
  let closed = false;
337
477
  let reconnectCount = 0;
@@ -340,6 +480,7 @@ class I3XClient extends EventEmitter {
340
480
  ...this.http.defaults.headers.common,
341
481
  ...this.http.defaults.headers,
342
482
  Accept: "text/event-stream",
483
+ "Content-Type": "application/json",
343
484
  };
344
485
  // Remove non-header axios defaults that got spread in
345
486
  delete headers.common;
@@ -361,8 +502,9 @@ class I3XClient extends EventEmitter {
361
502
  try {
362
503
  controller = new AbortController();
363
504
  const response = await axios({
364
- method: "get",
505
+ method: "post",
365
506
  url,
507
+ data: postBody,
366
508
  headers,
367
509
  responseType: "stream",
368
510
  signal: controller.signal,
@@ -395,6 +537,13 @@ class I3XClient extends EventEmitter {
395
537
  if (!closed) reconnect(err);
396
538
  });
397
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
+ }
398
547
  if (!closed) reconnect(err);
399
548
  }
400
549
  };
@@ -424,14 +573,24 @@ class I3XClient extends EventEmitter {
424
573
  }
425
574
 
426
575
  /**
427
- * Poll-based sync: returns and clears queued updates.
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.
428
580
  * @param {string} subscriptionId
581
+ * @param {object} [options]
582
+ * @param {number} [options.lastSequenceNumber] – acknowledge events up to this sequence number
583
+ * @param {string} [options.clientId] – overrides the client-level clientId
429
584
  */
430
- async syncSubscription(subscriptionId) {
431
- return this._post(
432
- `/subscriptions/${encodeURIComponent(subscriptionId)}/sync`,
433
- {}
434
- );
585
+ async syncSubscription(subscriptionId, options = {}) {
586
+ const body = {
587
+ clientId: options.clientId || this.clientId,
588
+ subscriptionId,
589
+ };
590
+ if (options.lastSequenceNumber !== undefined) {
591
+ body.lastSequenceNumber = options.lastSequenceNumber;
592
+ }
593
+ return this._post("/subscriptions/sync", body);
435
594
  }
436
595
 
437
596
  // ── Utility ────────────────────────────────────────────────────────
@@ -484,15 +643,38 @@ class I3XClient extends EventEmitter {
484
643
 
485
644
  /**
486
645
  * Central request dispatcher with retry logic, Retry-After support, and rate limiting.
646
+ * Returns unwrapped result data. Use _requestRaw for full response access.
487
647
  * @private
488
648
  */
489
649
  async _request(method, path, opts = {}) {
650
+ const res = await this._requestRaw(method, path, opts);
651
+ return I3XClient._unwrapEnvelope(res.data);
652
+ }
653
+
654
+ /**
655
+ * Unwrap the spec response envelope if present.
656
+ * SuccessResponse: {success, result} → result
657
+ * BulkResponse: {success, results} → results
658
+ * @private
659
+ */
660
+ static _unwrapEnvelope(data) {
661
+ if (data && typeof data === "object" && data.success === true) {
662
+ if ("result" in data) return data.result;
663
+ if ("results" in data) return data.results;
664
+ }
665
+ return data;
666
+ }
667
+
668
+ /**
669
+ * Central request dispatcher returning the full axios response.
670
+ * @private
671
+ */
672
+ async _requestRaw(method, path, opts = {}) {
490
673
  await this._rateLimiter.acquire();
491
674
  let lastErr;
492
675
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
493
676
  try {
494
- const res = await this.http.request({ method, url: path, ...opts });
495
- return res.data;
677
+ return await this.http.request({ method, url: path, ...opts });
496
678
  } catch (err) {
497
679
  lastErr = err;
498
680
  const status = err.response && err.response.status;
@@ -535,6 +717,13 @@ class I3XClient extends EventEmitter {
535
717
  delete sanitized[key];
536
718
  }
537
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
+ }
538
727
  } else {
539
728
  wrapped.body = body;
540
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) {
@@ -125,7 +153,7 @@ module.exports = function (RED) {
125
153
 
126
154
  if (node._subscriptionId && node.server && node.server.client) {
127
155
  try {
128
- await node.server.client.deleteSubscription(node._subscriptionId);
156
+ await node.server.client.deleteSubscriptions([node._subscriptionId]);
129
157
  } catch (_) {
130
158
  // best-effort cleanup
131
159
  }
@@ -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>
@@ -1,11 +1,4 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
2
2
  <rect width="40" height="40" rx="4" fill="#2E8B57"/>
3
- <text x="20" y="16" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="white">i3X</text>
4
- <line x1="8" y1="22" x2="32" y2="22" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
5
- <circle cx="12" cy="30" r="3" fill="none" stroke="white" stroke-width="1.5"/>
6
- <circle cx="20" cy="30" r="3" fill="none" stroke="white" stroke-width="1.5"/>
7
- <circle cx="28" cy="30" r="3" fill="none" stroke="white" stroke-width="1.5"/>
8
- <line x1="12" y1="27" x2="12" y2="22" stroke="white" stroke-width="1.5"/>
9
- <line x1="20" y1="27" x2="20" y2="22" stroke="white" stroke-width="1.5"/>
10
- <line x1="28" y1="27" x2="28" y2="22" stroke="white" stroke-width="1.5"/>
3
+ <text x="20" y="26" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white">i3X</text>
11
4
  </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-i3x",
3
- "version": "0.0.3",
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",
@@ -27,13 +27,13 @@
27
27
  "test:docker": "docker compose run --rm test"
28
28
  },
29
29
  "dependencies": {
30
- "axios": "^1.7.0"
30
+ "axios": "^1.15.0"
31
31
  },
32
32
  "devDependencies": {
33
- "mocha": "^10.0.0",
33
+ "mocha": "^11.0.0",
34
34
  "chai": "^4.0.0",
35
- "sinon": "^17.0.0",
36
- "nock": "^13.0.0",
35
+ "sinon": "^21.0.0",
36
+ "nock": "^14.0.0",
37
37
  "node-red": "^3.0.0",
38
38
  "node-red-node-test-helper": "^0.3.0"
39
39
  },
@@ -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
- }