node-red-contrib-i3x 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.4 (2026-04-12)
4
+
5
+ Migration to i3X API 1.0-Beta specification with enhanced query capabilities and improved API alignment.
6
+
7
+ ### Added
8
+
9
+ - **Server Info Endpoint** – New `getInfo()` method retrieves server metadata (spec version, server version, capabilities) from `GET /info` endpoint with TTL caching
10
+ - **Single-Object History Query** – New `getHistory(elementId, options)` method for `GET /objects/{elementId}/history` endpoint
11
+ - **Partial Response Handling** – History queries now detect HTTP 206 status and set `_partial: true` flag on results when server returns incomplete data
12
+ - **Root Objects Filter** – `getObjects()` now supports `root: true` parameter to retrieve only root-level objects
13
+ - **Enhanced Subscribe Options** – Subscribe node now supports `maxDepth`, `includeMetadata`, and `returnMode` parameters for fine-grained control
14
+
15
+ ### Changed
16
+
17
+ - **API Version Update** – Updated from i3X v0.0.1 to 1.0-Beta specification
18
+ - **API Documentation URL** – Changed from `https://i3x.cesmii.net/docs` to `https://api.i3x.dev/v1/docs`
19
+ - **Parameter Naming** – `typeId` parameter renamed to `typeElementId` in `getObjects()` (legacy `typeId` still supported as alias)
20
+ - **Relationship Type Parameter** – Fixed casing from `relationshiptype` to `relationshipType` in `getRelatedObjects()`
21
+ - **History Query Implementation** – `getHistoryBulk()` now uses `_requestRaw()` to access HTTP status codes for partial response detection
22
+
23
+ ### Tests
24
+
25
+ - Updated all test cases to reflect 1.0-Beta API changes
26
+ - Added tests for new `getInfo()` endpoint
27
+ - Added tests for single-object history query
28
+ - Added tests for partial response handling (HTTP 206)
29
+ - Updated integration tests for new parameter names
30
+
3
31
  ## 0.0.3 (2026-03-10)
4
32
 
5
33
  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:** The i3X API is currently in **beta (v1.0-Beta)**. Response structures may change as the specification evolves.
8
8
 
9
9
  ## Installation
10
10
 
@@ -108,10 +108,11 @@ The shared HTTP client (`lib/i3x-client.js`) implements all [i3X Client Develope
108
108
 
109
109
  ## API Endpoints Used
110
110
 
111
- This package targets the [i3X API Prototype v0.0.1](https://api.i3x.dev/v0/docs):
111
+ This package targets the [i3X API 1.0-Beta](https://api.i3x.dev/v1/docs):
112
112
 
113
113
  | Category | Method | Endpoint |
114
114
  | --------- | ------ | -------------------------------------------- |
115
+ | Info | GET | `/info` |
115
116
  | Explore | GET | `/namespaces` |
116
117
  | Explore | GET | `/objecttypes` |
117
118
  | Explore | POST | `/objecttypes/query` |
@@ -121,6 +122,7 @@ This package targets the [i3X API Prototype v0.0.1](https://api.i3x.dev/v0/docs)
121
122
  | Explore | POST | `/objects/list` |
122
123
  | Explore | POST | `/objects/related` |
123
124
  | Query | POST | `/objects/value` |
125
+ | Query | GET | `/objects/{elementId}/history` |
124
126
  | Query | POST | `/objects/history` |
125
127
  | Update | PUT | `/objects/{elementId}/value` |
126
128
  | Update | PUT | `/objects/{elementId}/history` |
package/lib/i3x-client.js CHANGED
@@ -1,10 +1,10 @@
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.
4
+ * Wraps all REST endpoints described in the i3X OpenAPI 1.0-Beta spec.
5
5
  * Used by every node-red-contrib-i3x node via the i3x-server config node.
6
6
  *
7
- * @see https://i3x.cesmii.net/docs
7
+ * @see https://api.i3x.dev/v1/docs
8
8
  */
9
9
  "use strict";
10
10
 
@@ -118,6 +118,21 @@ class I3XClient extends EventEmitter {
118
118
  this._rateLimiter = new RateLimiter();
119
119
  }
120
120
 
121
+ // ── Server Info ──────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Retrieve server information (no authentication required).
125
+ * @returns {Promise<{specVersion:string, serverVersion:string, serverName:string, capabilities:object}>}
126
+ */
127
+ async getInfo() {
128
+ const cacheKey = "info";
129
+ const cached = this._cache.get(cacheKey);
130
+ if (cached) return cached;
131
+ const result = await this._get("/info");
132
+ this._cache.set(cacheKey, result);
133
+ return result;
134
+ }
135
+
121
136
  // ── Explore ────────────────────────────────────────────────────────
122
137
 
123
138
  /** @returns {Promise<Array<{uri:string, displayName:string}>>} */
@@ -166,14 +181,18 @@ class I3XClient extends EventEmitter {
166
181
  }
167
182
 
168
183
  /**
169
- * @param {object} [options]
170
- * @param {string} [options.typeId]
184
+ * @param {object} [options]
185
+ * @param {string} [options.typeElementId] – filter by type (Beta spec name)
186
+ * @param {string} [options.typeId] – legacy alias for typeElementId
171
187
  * @param {boolean} [options.includeMetadata]
188
+ * @param {boolean} [options.root] – return only root objects
172
189
  */
173
190
  async getObjects(options = {}) {
174
191
  const params = {};
175
- if (options.typeId) params.typeId = options.typeId;
192
+ const typeFilter = options.typeElementId || options.typeId;
193
+ if (typeFilter) params.typeElementId = typeFilter;
176
194
  if (options.includeMetadata) params.includeMetadata = true;
195
+ if (options.root) params.root = true;
177
196
  return this._get("/objects", params);
178
197
  }
179
198
 
@@ -196,7 +215,7 @@ class I3XClient extends EventEmitter {
196
215
  */
197
216
  async getRelatedObjects(elementIds, options = {}) {
198
217
  const body = { elementIds };
199
- if (options.relationshipType) body.relationshiptype = options.relationshipType;
218
+ if (options.relationshipType) body.relationshipType = options.relationshipType;
200
219
  if (options.includeMetadata) body.includeMetadata = true;
201
220
  return this._post("/objects/related", body);
202
221
  }
@@ -215,6 +234,7 @@ class I3XClient extends EventEmitter {
215
234
  }
216
235
 
217
236
  /**
237
+ * Bulk historical values query (POST).
218
238
  * @param {string[]} elementIds
219
239
  * @param {object} [options]
220
240
  * @param {string} [options.startTime] – ISO 8601
@@ -226,7 +246,37 @@ class I3XClient extends EventEmitter {
226
246
  if (options.startTime) body.startTime = options.startTime;
227
247
  if (options.endTime) body.endTime = options.endTime;
228
248
  if (options.maxDepth !== undefined) body.maxDepth = options.maxDepth;
229
- return this._post("/objects/history", body);
249
+ const res = await this._requestRaw("post", "/objects/history", { data: body });
250
+ const result = I3XClient._unwrapEnvelope(res.data);
251
+ if (res.status === 206) {
252
+ result._partial = true;
253
+ }
254
+ return result;
255
+ }
256
+
257
+ /**
258
+ * Single-object historical values query (GET).
259
+ * @param {string} elementId
260
+ * @param {object} [options]
261
+ * @param {string} [options.startTime] – ISO 8601
262
+ * @param {string} [options.endTime] – ISO 8601
263
+ * @param {number} [options.maxDepth]
264
+ */
265
+ 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;
230
280
  }
231
281
 
232
282
  // ── Update ─────────────────────────────────────────────────────────
@@ -267,71 +317,106 @@ class I3XClient extends EventEmitter {
267
317
  }
268
318
  }
269
319
 
270
- // ── Subscribe ──────────────────────────────────────────────────────
271
-
272
- async listSubscriptions() {
273
- return this._get("/subscriptions");
274
- }
320
+ // ── Subscribe (Beta-Spec: body-based endpoints) ──────────────────
275
321
 
276
- /** @returns {Promise<{subscriptionId:string, message:string}>} */
277
- async createSubscription() {
278
- return this._post("/subscriptions", {});
322
+ /**
323
+ * Create a new subscription.
324
+ * @param {object} [options]
325
+ * @param {string} [options.clientId] – optional client identifier
326
+ * @param {string} [options.displayName] – optional human-readable name
327
+ * @returns {Promise<{subscriptionId:string, clientId?:string, displayName?:string}>}
328
+ */
329
+ async createSubscription(options = {}) {
330
+ const body = {};
331
+ if (options.clientId) body.clientId = options.clientId;
332
+ if (options.displayName) body.displayName = options.displayName;
333
+ return this._post("/subscriptions", body);
279
334
  }
280
335
 
281
- /** @param {string} subscriptionId */
282
- async getSubscription(subscriptionId) {
283
- return this._get(`/subscriptions/${encodeURIComponent(subscriptionId)}`);
336
+ /**
337
+ * List subscriptions by IDs.
338
+ * @param {string[]} subscriptionIds
339
+ * @param {object} [options]
340
+ * @param {string} [options.clientId]
341
+ */
342
+ async listSubscriptions(subscriptionIds, options = {}) {
343
+ const body = { subscriptionIds };
344
+ if (options.clientId) body.clientId = options.clientId;
345
+ return this._post("/subscriptions/list", body);
284
346
  }
285
347
 
286
- /** @param {string} subscriptionId */
287
- async deleteSubscription(subscriptionId) {
288
- return this._delete(`/subscriptions/${encodeURIComponent(subscriptionId)}`);
348
+ /**
349
+ * Delete one or more subscriptions.
350
+ * @param {string[]} subscriptionIds
351
+ * @param {object} [options]
352
+ * @param {string} [options.clientId]
353
+ */
354
+ async deleteSubscriptions(subscriptionIds, options = {}) {
355
+ const body = { subscriptionIds };
356
+ if (options.clientId) body.clientId = options.clientId;
357
+ return this._post("/subscriptions/delete", body);
289
358
  }
290
359
 
291
360
  /**
361
+ * Register monitored items on a subscription.
292
362
  * @param {string} subscriptionId
293
363
  * @param {string[]} elementIds
294
- * @param {number} [maxDepth]
364
+ * @param {object} [options]
365
+ * @param {number} [options.maxDepth] – default 1
366
+ * @param {string} [options.clientId]
295
367
  */
296
- async registerMonitoredItems(subscriptionId, elementIds, maxDepth = 1) {
297
- const body = { elementIds, maxDepth };
298
- return this._post(
299
- `/subscriptions/${encodeURIComponent(subscriptionId)}/register`,
300
- body
301
- );
368
+ async registerMonitoredItems(subscriptionId, elementIds, options = {}) {
369
+ // Support legacy positional maxDepth: registerMonitoredItems(id, ids, 2)
370
+ if (typeof options === "number") {
371
+ options = { maxDepth: options };
372
+ }
373
+ const body = { subscriptionId, elementIds, maxDepth: options.maxDepth !== undefined ? options.maxDepth : 1 };
374
+ if (options.clientId) body.clientId = options.clientId;
375
+ return this._post("/subscriptions/register", body);
302
376
  }
303
377
 
304
378
  /**
379
+ * Unregister monitored items from a subscription.
305
380
  * @param {string} subscriptionId
306
381
  * @param {string[]} elementIds
382
+ * @param {object} [options]
383
+ * @param {string} [options.clientId]
307
384
  */
308
- async unregisterMonitoredItems(subscriptionId, elementIds) {
309
- const body = { elementIds };
310
- return this._post(
311
- `/subscriptions/${encodeURIComponent(subscriptionId)}/unregister`,
312
- body
313
- );
385
+ async unregisterMonitoredItems(subscriptionId, elementIds, options = {}) {
386
+ const body = { subscriptionId, elementIds };
387
+ if (options.clientId) body.clientId = options.clientId;
388
+ return this._post("/subscriptions/unregister", body);
314
389
  }
315
390
 
316
391
  /**
317
- * Open an SSE stream for the given subscription.
392
+ * Open an SSE stream for the given subscription (POST).
318
393
  * Supports automatic reconnection on stream errors.
319
394
  *
320
395
  * @param {string} subscriptionId
321
396
  * @param {object} callbacks
322
397
  * @param {function} callbacks.onData – called with each parsed SSE event
323
- * @param {function} [callbacks.onError] – called on stream errors (instead of EventEmitter)
398
+ * @param {function} [callbacks.onError] – called on stream errors
324
399
  * @param {function} [callbacks.onReconnect] – called when a reconnection attempt starts
325
- * @param {number} [maxReconnects=5] – max consecutive reconnection attempts
400
+ * @param {object} [options]
401
+ * @param {string} [options.clientId]
402
+ * @param {number} [options.maxReconnects=5]
326
403
  * @returns {{ close: function }} handle to close the stream
327
404
  */
328
- streamSubscription(subscriptionId, callbacks, maxReconnects = 5) {
405
+ streamSubscription(subscriptionId, callbacks, options = {}) {
329
406
  if (typeof callbacks === "function") {
330
407
  callbacks = { onData: callbacks };
331
408
  }
409
+ // Support legacy positional maxReconnects number
410
+ if (typeof options === "number") {
411
+ options = { maxReconnects: options };
412
+ }
332
413
  const { onData, onError, onReconnect } = callbacks;
414
+ const maxReconnects = options.maxReconnects !== undefined ? options.maxReconnects : 5;
415
+
416
+ const url = `${this._prefix()}/subscriptions/stream`;
417
+ const postBody = { subscriptionId };
418
+ if (options.clientId) postBody.clientId = options.clientId;
333
419
 
334
- const url = `${this._prefix()}/subscriptions/${encodeURIComponent(subscriptionId)}/stream`;
335
420
  let controller = new AbortController();
336
421
  let closed = false;
337
422
  let reconnectCount = 0;
@@ -340,6 +425,7 @@ class I3XClient extends EventEmitter {
340
425
  ...this.http.defaults.headers.common,
341
426
  ...this.http.defaults.headers,
342
427
  Accept: "text/event-stream",
428
+ "Content-Type": "application/json",
343
429
  };
344
430
  // Remove non-header axios defaults that got spread in
345
431
  delete headers.common;
@@ -361,8 +447,9 @@ class I3XClient extends EventEmitter {
361
447
  try {
362
448
  controller = new AbortController();
363
449
  const response = await axios({
364
- method: "get",
450
+ method: "post",
365
451
  url,
452
+ data: postBody,
366
453
  headers,
367
454
  responseType: "stream",
368
455
  signal: controller.signal,
@@ -424,14 +511,19 @@ class I3XClient extends EventEmitter {
424
511
  }
425
512
 
426
513
  /**
427
- * Poll-based sync: returns and clears queued updates.
514
+ * Poll-based sync: acknowledge previously received updates and return pending ones.
428
515
  * @param {string} subscriptionId
516
+ * @param {object} [options]
517
+ * @param {number} [options.lastSequenceNumber] – acknowledge events up to this sequence number
518
+ * @param {string} [options.clientId]
429
519
  */
430
- async syncSubscription(subscriptionId) {
431
- return this._post(
432
- `/subscriptions/${encodeURIComponent(subscriptionId)}/sync`,
433
- {}
434
- );
520
+ async syncSubscription(subscriptionId, options = {}) {
521
+ const body = { subscriptionId };
522
+ if (options.lastSequenceNumber !== undefined) {
523
+ body.lastSequenceNumber = options.lastSequenceNumber;
524
+ }
525
+ if (options.clientId) body.clientId = options.clientId;
526
+ return this._post("/subscriptions/sync", body);
435
527
  }
436
528
 
437
529
  // ── Utility ────────────────────────────────────────────────────────
@@ -484,15 +576,38 @@ class I3XClient extends EventEmitter {
484
576
 
485
577
  /**
486
578
  * Central request dispatcher with retry logic, Retry-After support, and rate limiting.
579
+ * Returns unwrapped result data. Use _requestRaw for full response access.
487
580
  * @private
488
581
  */
489
582
  async _request(method, path, opts = {}) {
583
+ const res = await this._requestRaw(method, path, opts);
584
+ return I3XClient._unwrapEnvelope(res.data);
585
+ }
586
+
587
+ /**
588
+ * Unwrap the Beta-Spec response envelope if present.
589
+ * SuccessResponse: {success, result} → result
590
+ * BulkResponse: {success, results} → results
591
+ * @private
592
+ */
593
+ static _unwrapEnvelope(data) {
594
+ if (data && typeof data === "object" && data.success === true) {
595
+ if ("result" in data) return data.result;
596
+ if ("results" in data) return data.results;
597
+ }
598
+ return data;
599
+ }
600
+
601
+ /**
602
+ * Central request dispatcher returning the full axios response.
603
+ * @private
604
+ */
605
+ async _requestRaw(method, path, opts = {}) {
490
606
  await this._rateLimiter.acquire();
491
607
  let lastErr;
492
608
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
493
609
  try {
494
- const res = await this.http.request({ method, url: path, ...opts });
495
- return res.data;
610
+ return await this.http.request({ method, url: path, ...opts });
496
611
  } catch (err) {
497
612
  lastErr = err;
498
613
  const status = err.response && err.response.status;
@@ -125,7 +125,7 @@ module.exports = function (RED) {
125
125
 
126
126
  if (node._subscriptionId && node.server && node.server.client) {
127
127
  try {
128
- await node.server.client.deleteSubscription(node._subscriptionId);
128
+ await node.server.client.deleteSubscriptions([node._subscriptionId]);
129
129
  } catch (_) {
130
130
  // best-effort cleanup
131
131
  }
@@ -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.4",
4
4
  "description": "Node-RED nodes for the i3X (Industrial Information Interoperability eXchange) API by CESMII",
5
5
  "keywords": [
6
6
  "node-red",
@@ -27,13 +27,13 @@
27
27
  "test:docker": "docker compose run --rm test"
28
28
  },
29
29
  "dependencies": {
30
- "axios": "^1.7.0"
30
+ "axios": "^1.15.0"
31
31
  },
32
32
  "devDependencies": {
33
- "mocha": "^10.0.0",
33
+ "mocha": "^11.0.0",
34
34
  "chai": "^4.0.0",
35
- "sinon": "^17.0.0",
36
- "nock": "^13.0.0",
35
+ "sinon": "^21.0.0",
36
+ "nock": "^14.0.0",
37
37
  "node-red": "^3.0.0",
38
38
  "node-red-node-test-helper": "^0.3.0"
39
39
  },