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 +67 -0
- package/README.md +38 -33
- package/examples/i3x-complete-demo.json +2 -2
- package/lib/i3x-client.js +253 -64
- package/nodes/i3x-server.js +4 -0
- package/nodes/i3x-subscribe.html +6 -4
- package/nodes/i3x-subscribe.js +33 -4
- package/nodes/i3x-write.html +7 -4
- package/nodes/icons/i3x-icon.svg +1 -8
- package/package.json +5 -5
- package/.claude/settings.local.json +0 -14
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:**
|
|
7
|
+
> **Note:** This package targets the **i3X API 1.0 Release** (finalized 2026-06-09). The specification is stable; the next revision is not expected before the vNext working group convenes in late 2026.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -68,10 +68,12 @@ Write a current value or historical data to an i3X object.
|
|
|
68
68
|
- **Target:** `value` (default) or `history` – selectable via dropdown or `msg.writeTarget`
|
|
69
69
|
- **Output:** `msg.payload` – write confirmation from the API
|
|
70
70
|
|
|
71
|
-
| Target | API Endpoint
|
|
72
|
-
| --------- |
|
|
73
|
-
| `value` | `PUT /objects/
|
|
74
|
-
| `history` | `PUT /objects/
|
|
71
|
+
| Target | API Endpoint | Payload format |
|
|
72
|
+
| --------- | --------------------- | ------------------------------------- |
|
|
73
|
+
| `value` | `PUT /objects/value` | A value or VQT object `{value, quality?, timestamp?}` |
|
|
74
|
+
| `history` | `PUT /objects/history`| A VQT or array of VQT records `[{value, quality, timestamp}, …]` |
|
|
75
|
+
|
|
76
|
+
Both use the i3X 1.0 bulk update format (`{updates: [{elementId, value}]}`); the node builds it for you. For history writes a missing `quality` defaults to `"Good"` and a missing `timestamp` to the current UTC time.
|
|
75
77
|
|
|
76
78
|
### i3x-history
|
|
77
79
|
|
|
@@ -85,10 +87,10 @@ Query historical time-series data.
|
|
|
85
87
|
|
|
86
88
|
Subscribe to value changes via SSE streaming or polling.
|
|
87
89
|
|
|
88
|
-
- **SSE mode:** Opens a persistent Server-Sent Events stream (`
|
|
89
|
-
- **Polling mode:** Periodically calls `POST /subscriptions/
|
|
90
|
-
- **Fallback:** If SSE fails
|
|
91
|
-
- **Lifecycle:** Subscriptions are created on deploy and deleted on stop/re-deploy
|
|
90
|
+
- **SSE mode:** Opens a persistent Server-Sent Events stream (`POST /subscriptions/stream`)
|
|
91
|
+
- **Polling mode:** Periodically calls `POST /subscriptions/sync`, acknowledging received batches by sequence number
|
|
92
|
+
- **Fallback:** If SSE fails — or the server returns HTTP 501 (streaming not supported) — the node automatically falls back to polling
|
|
93
|
+
- **Lifecycle:** Subscriptions are created on deploy and deleted on stop/re-deploy; all calls are scoped by a `clientId` derived from the server config node (required by i3X 1.0)
|
|
92
94
|
|
|
93
95
|
## Built-in Resilience & Best Practices
|
|
94
96
|
|
|
@@ -108,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
|
|
112
|
-
|
|
113
|
-
| Category | Method | Endpoint
|
|
114
|
-
| --------- | ------ |
|
|
115
|
-
|
|
|
116
|
-
| Explore | GET | `/
|
|
117
|
-
| Explore |
|
|
118
|
-
| Explore |
|
|
119
|
-
| Explore |
|
|
120
|
-
| Explore |
|
|
121
|
-
| Explore |
|
|
122
|
-
| Explore | POST | `/objects/
|
|
123
|
-
|
|
|
124
|
-
| Query | POST | `/objects/
|
|
125
|
-
|
|
|
126
|
-
| Update | PUT | `/objects/
|
|
127
|
-
|
|
|
128
|
-
| Subscribe | POST | `/subscriptions`
|
|
129
|
-
| Subscribe |
|
|
130
|
-
| Subscribe |
|
|
131
|
-
| Subscribe | POST | `/subscriptions/
|
|
132
|
-
| Subscribe | POST | `/subscriptions/
|
|
133
|
-
| Subscribe |
|
|
134
|
-
| Subscribe | POST | `/subscriptions/
|
|
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/
|
|
604
|
+
"info": "The **i3x-write** node writes a value to an i3X object.\n\n**API endpoint:** `PUT /objects/value` (bulk update format)\n\n**Input:**\n- `msg.payload` - The value to write (must match the element's type schema)\n- `msg.elementId` or node property - Target element\n\n**Output:**\n- `msg.payload` - Server confirmation (`{ success, message }`)\n- `msg.elementId` - The element ID that was written to\n\n**Payload format depends on the element type:**\n- `state-type` → object: `{ description, color, type: { id, name, description }, metadata: { ... } }`\n- `measurement-value-type` / `sensor-type` → plain number (e.g. `72.5`)\n- `measurement-health-type` → integer (e.g. `95`)\n\n**Analogy:** Comparable to OPC UA Write or S7 OUT.",
|
|
605
605
|
"x": 270,
|
|
606
606
|
"y": 800,
|
|
607
607
|
"wires": []
|
|
@@ -974,7 +974,7 @@
|
|
|
974
974
|
"type": "comment",
|
|
975
975
|
"z": "i3x-demo-tab",
|
|
976
976
|
"name": "=== 5. SUBSCRIBE - Monitor value changes ===",
|
|
977
|
-
"info": "The **i3x-subscribe** node monitors value changes and outputs a message on each update.\n\n**Modes:**\n- **SSE** (Server-Sent Events) – Real-time stream via `
|
|
977
|
+
"info": "The **i3x-subscribe** node monitors value changes and outputs a message on each update.\n\n**Modes:**\n- **SSE** (Server-Sent Events) – Real-time stream via `POST /subscriptions/stream`\n- **Polling** – Periodic sync via `POST /subscriptions/sync`\n\n**Behaviour:**\n- Automatically creates a subscription on deploy\n- Registers the specified element IDs as monitored items\n- Automatically deletes the subscription on stop/re-deploy\n- If SSE fails, automatically falls back to polling\n\n**Analogy:** Comparable to MQTT IN or OPC UA Subscription.\n\n**Note:** This node has no input – it starts automatically on deploy.",
|
|
978
978
|
"x": 310,
|
|
979
979
|
"y": 1320,
|
|
980
980
|
"wires": []
|
package/lib/i3x-client.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* I3XClient – Shared HTTP client for the i3X (CESMII) API.
|
|
3
3
|
*
|
|
4
|
-
* Wraps all REST endpoints described in the i3X OpenAPI
|
|
5
|
-
* Used by every node-red-contrib-i3x node via the
|
|
4
|
+
* Wraps all REST endpoints described in the i3X OpenAPI 1.0 Release spec
|
|
5
|
+
* (released 2026-06-09). Used by every node-red-contrib-i3x node via the
|
|
6
|
+
* i3x-server config node.
|
|
6
7
|
*
|
|
7
|
-
* @see https://i3x.
|
|
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}
|
|
170
|
-
* @param {string} [options.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 –
|
|
313
|
+
* @param {*} data – VQT object, array of VQTs, or primitive value
|
|
246
314
|
*/
|
|
247
315
|
async writeHistory(elementId, data) {
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
262
|
-
const
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
/**
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
/**
|
|
287
|
-
|
|
288
|
-
|
|
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 {
|
|
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,
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 = {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
451
|
+
* @param {function} [callbacks.onError] – called on stream errors
|
|
324
452
|
* @param {function} [callbacks.onReconnect] – called when a reconnection attempt starts
|
|
325
|
-
* @param {
|
|
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,
|
|
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: "
|
|
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:
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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
|
}
|
package/nodes/i3x-server.js
CHANGED
|
@@ -22,6 +22,10 @@ module.exports = function (RED) {
|
|
|
22
22
|
apiVersion: node.apiVersion,
|
|
23
23
|
authType: node.authType,
|
|
24
24
|
timeout: node.timeout,
|
|
25
|
+
// 1.0 spec: clientId is required on all subscription endpoints and
|
|
26
|
+
// scopes subscriptions per client. The config-node id is stable
|
|
27
|
+
// across restarts, so subscriptions survive a redeploy cleanly.
|
|
28
|
+
clientId: "node-red-" + node.id.replace(/\./g, "-"),
|
|
25
29
|
};
|
|
26
30
|
|
|
27
31
|
if (node.credentials) {
|
package/nodes/i3x-subscribe.html
CHANGED
|
@@ -43,11 +43,13 @@
|
|
|
43
43
|
</dl>
|
|
44
44
|
|
|
45
45
|
<h3>Details</h3>
|
|
46
|
-
<p>Creates a server-side subscription via the i3X Subscribe API and monitors the specified elements
|
|
47
|
-
<
|
|
46
|
+
<p>Creates a server-side subscription via the i3X Subscribe API and monitors the specified elements.
|
|
47
|
+
Subscriptions are scoped by a <code>clientId</code> derived from the server config node (required by i3X 1.0).</p>
|
|
48
|
+
<p><b>SSE mode</b> opens a persistent Server-Sent Events stream (<code>POST /subscriptions/stream</code>)
|
|
48
49
|
and emits messages in real-time as values change.</p>
|
|
49
|
-
<p><b>Polling mode</b> periodically calls <code>POST /subscriptions/
|
|
50
|
-
|
|
50
|
+
<p><b>Polling mode</b> periodically calls <code>POST /subscriptions/sync</code> to retrieve queued
|
|
51
|
+
update batches; previously received batches are acknowledged via their sequence number. If SSE setup
|
|
52
|
+
fails — or the server does not support streaming (HTTP 501) — the node automatically falls back to polling.</p>
|
|
51
53
|
<p>The subscription is automatically cleaned up (deleted from the server) when the node is stopped or re-deployed.</p>
|
|
52
54
|
<p>Use the <b>Browse</b> button to visually select elements from the server.</p>
|
|
53
55
|
</script>
|
package/nodes/i3x-subscribe.js
CHANGED
|
@@ -19,6 +19,7 @@ module.exports = function (RED) {
|
|
|
19
19
|
node._sseHandle = null;
|
|
20
20
|
node._pollTimer = null;
|
|
21
21
|
node._closing = false;
|
|
22
|
+
node._lastSequenceNumber = undefined;
|
|
22
23
|
|
|
23
24
|
if (!bindServer(node, RED, config.server)) return;
|
|
24
25
|
|
|
@@ -77,6 +78,13 @@ module.exports = function (RED) {
|
|
|
77
78
|
},
|
|
78
79
|
onError: (err) => {
|
|
79
80
|
if (node._closing) return;
|
|
81
|
+
// 1.0 spec: poll-only servers return 501 for /stream
|
|
82
|
+
if (err.statusCode === 501) {
|
|
83
|
+
node.warn("Server does not support SSE streaming (501) – falling back to polling");
|
|
84
|
+
node._sseHandle = null;
|
|
85
|
+
startPolling(client);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
80
88
|
node.status({ fill: "red", shape: "ring", text: "stream error" });
|
|
81
89
|
node.error("SSE stream error: " + err.message);
|
|
82
90
|
},
|
|
@@ -94,9 +102,29 @@ module.exports = function (RED) {
|
|
|
94
102
|
async function poll() {
|
|
95
103
|
if (node._closing) return;
|
|
96
104
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
// Acknowledge everything received so far, then fetch pending batches
|
|
106
|
+
const opts = {};
|
|
107
|
+
if (node._lastSequenceNumber !== undefined) {
|
|
108
|
+
opts.lastSequenceNumber = node._lastSequenceNumber;
|
|
109
|
+
}
|
|
110
|
+
const batches = await client.syncSubscription(node._subscriptionId, opts);
|
|
111
|
+
// 1.0 spec: [{sequenceNumber, updates: [...]}, ...]
|
|
112
|
+
const updates = [];
|
|
113
|
+
for (const batch of Array.isArray(batches) ? batches : []) {
|
|
114
|
+
if (batch && typeof batch === "object" && Array.isArray(batch.updates)) {
|
|
115
|
+
updates.push(...batch.updates);
|
|
116
|
+
if (typeof batch.sequenceNumber === "number") {
|
|
117
|
+
node._lastSequenceNumber = node._lastSequenceNumber === undefined
|
|
118
|
+
? batch.sequenceNumber
|
|
119
|
+
: Math.max(node._lastSequenceNumber, batch.sequenceNumber);
|
|
120
|
+
}
|
|
121
|
+
} else if (batch !== null && batch !== undefined) {
|
|
122
|
+
// tolerate pre-1.0 servers returning a flat update list
|
|
123
|
+
updates.push(batch);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (updates.length > 0) {
|
|
127
|
+
node.send({ payload: updates, topic: "i3x/subscription" });
|
|
100
128
|
}
|
|
101
129
|
} catch (err) {
|
|
102
130
|
if (!node._closing) {
|
|
@@ -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.
|
|
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
|
});
|
package/nodes/i3x-write.html
CHANGED
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
<h3>Inputs</h3>
|
|
30
30
|
<dl class="message-properties">
|
|
31
31
|
<dt>payload <span class="property-type">any</span></dt>
|
|
32
|
-
<dd>The value to write.
|
|
32
|
+
<dd>The value to write. Either a plain value (wrapped automatically) or a
|
|
33
|
+
VQT object <code>{value, quality, timestamp}</code>. For history writes an
|
|
34
|
+
array of VQTs writes multiple records at once; missing <code>quality</code>
|
|
35
|
+
defaults to <code>"Good"</code>, missing <code>timestamp</code> to now.</dd>
|
|
33
36
|
<dt class="optional">elementId <span class="property-type">string</span></dt>
|
|
34
37
|
<dd>Target element ID. Overrides the configured value. Also accepts <code>msg.nodeId</code>.</dd>
|
|
35
38
|
<dt class="optional">writeTarget <span class="property-type">string</span></dt>
|
|
@@ -45,10 +48,10 @@
|
|
|
45
48
|
</dl>
|
|
46
49
|
|
|
47
50
|
<h3>Details</h3>
|
|
48
|
-
<p>When <b>Target</b> is set to <i>Current Value</i>, uses <code>PUT /objects/
|
|
51
|
+
<p>When <b>Target</b> is set to <i>Current Value</i>, uses <code>PUT /objects/value</code>
|
|
49
52
|
to update the current value of the object.</p>
|
|
50
|
-
<p>When <b>Target</b> is set to <i>Historical Data</i>, uses <code>PUT /objects/
|
|
51
|
-
to write historical time-series records.</p>
|
|
53
|
+
<p>When <b>Target</b> is set to <i>Historical Data</i>, uses <code>PUT /objects/history</code>
|
|
54
|
+
to write historical time-series records (i3X 1.0 bulk update format).</p>
|
|
52
55
|
<p>Use the <b>Browse</b> button to visually select the target element from the server,
|
|
53
56
|
or provide the element ID via <code>msg.elementId</code> at runtime.</p>
|
|
54
57
|
</script>
|
package/nodes/icons/i3x-icon.svg
CHANGED
|
@@ -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="
|
|
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
|
+
"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.
|
|
30
|
+
"axios": "^1.15.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"mocha": "^
|
|
33
|
+
"mocha": "^11.0.0",
|
|
34
34
|
"chai": "^4.0.0",
|
|
35
|
-
"sinon": "^
|
|
36
|
-
"nock": "^
|
|
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
|
-
}
|