@node-i3x/demo-embedded 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node-i3x/demo-embedded",
3
- "version": "0.1.0",
3
+ "version": "0.2.4",
4
4
  "license": "AGPL-3.0-or-later OR LicenseRef-Sterfive-Commercial",
5
5
  "author": "Sterfive SAS <contact@sterfive.com> (https://sterfive.com)",
6
6
  "homepage": "https://sterfive.com",
@@ -14,16 +14,24 @@
14
14
  "main": "./dist/index.js",
15
15
  "types": "./dist/index.d.ts",
16
16
  "bin": {
17
- "i3x-demo": "./dist/index.js"
17
+ "i3x-demo": "./dist/index.js",
18
+ "i3x-demo-client": "./dist/client.js"
18
19
  },
19
20
  "exports": {
20
21
  ".": {
22
+ "types": "./dist/index.d.ts",
21
23
  "import": "./dist/index.js"
22
24
  }
23
25
  },
24
26
  "publishConfig": {
25
27
  "access": "public"
26
28
  },
29
+ "files": [
30
+ "dist",
31
+ "LICENSE",
32
+ "LICENSE-AGPL-3.0",
33
+ "LICENSING.md"
34
+ ],
27
35
  "scripts": {
28
36
  "build": "tsup",
29
37
  "start": "npx tsx src/index.ts",
@@ -32,17 +40,17 @@
32
40
  "client": "npx tsx src/client.ts"
33
41
  },
34
42
  "dependencies": {
35
- "@node-i3x/core": "*",
36
- "@node-i3x/opcua-connector": "*",
37
- "@node-i3x/pseudo-session-connector": "*",
38
- "@node-i3x/rest-server": "*",
39
- "fastify": "^5.0.0",
40
- "@fastify/cors": "^11.0.0",
41
- "fastify-plugin": "^5.0.0",
42
- "node-opcua": "^2.128.0"
43
+ "@node-i3x/core": "0.2.4",
44
+ "@node-i3x/opcua-connector": "0.2.4",
45
+ "@node-i3x/pseudo-session-connector": "0.2.4",
46
+ "@node-i3x/rest-server": "0.2.4",
47
+ "fastify": "^5.8.5",
48
+ "@fastify/cors": "^11.2.0",
49
+ "fastify-plugin": "^6.0.0",
50
+ "node-opcua": "^2.173.1"
43
51
  },
44
52
  "devDependencies": {
45
- "tsup": "^8.0.0",
46
- "tsx": "^4.16.0"
53
+ "tsup": "^8.5.1",
54
+ "tsx": "^4.22.4"
47
55
  }
48
56
  }
package/src/client.ts DELETED
@@ -1,570 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────
2
- // i3X REST Client — live dashboard with refreshing cards
3
- //
4
- // Run this AFTER the embedded demo is running:
5
- // npm run demo -w packages/demo-embedded (terminal 1)
6
- // npm run client -w packages/demo-embedded (terminal 2)
7
- // ─────────────────────────────────────────────────────────────
8
-
9
- import { parseArgs } from 'node:util';
10
-
11
- const { values: clientArgs } = parseArgs({
12
- options: {
13
- url: { type: 'string', default: process.env.I3X_URL ?? 'http://127.0.0.1:8080' },
14
- },
15
- });
16
-
17
- const BASE_URL = clientArgs.url!;
18
- const BASE = BASE_URL;
19
-
20
- // ── ANSI helpers ─────────────────────────────────────────────
21
-
22
- const ESC = '\x1b';
23
- const ansi = {
24
- clear: `${ESC}[2J${ESC}[H`,
25
- home: `${ESC}[H`,
26
- hideCursor: `${ESC}[?25l`,
27
- showCursor: `${ESC}[?25h`,
28
- bold: `${ESC}[1m`,
29
- dim: `${ESC}[2m`,
30
- reset: `${ESC}[0m`,
31
- // Foreground
32
- white: `${ESC}[97m`,
33
- gray: `${ESC}[90m`,
34
- cyan: `${ESC}[96m`,
35
- green: `${ESC}[92m`,
36
- red: `${ESC}[91m`,
37
- yellow: `${ESC}[93m`,
38
- blue: `${ESC}[94m`,
39
- magenta: `${ESC}[95m`,
40
- // Background
41
- bgDark: `${ESC}[48;5;236m`,
42
- bgCard: `${ESC}[48;5;238m`,
43
- bgHeader: `${ESC}[48;5;24m`,
44
- };
45
-
46
- // ── Fetch helpers ────────────────────────────────────────────
47
-
48
- async function get<T>(path: string): Promise<T> {
49
- const res = await fetch(`${BASE}${path}`);
50
- if (!res.ok) throw new Error(`GET ${path} → ${res.status}`);
51
- return res.json() as Promise<T>;
52
- }
53
-
54
- async function post<T>(path: string, body: unknown): Promise<T> {
55
- const res = await fetch(`${BASE}${path}`, {
56
- method: 'POST',
57
- headers: { 'Content-Type': 'application/json' },
58
- body: JSON.stringify(body),
59
- });
60
- if (!res.ok) throw new Error(`POST ${path} → ${res.status}`);
61
- return res.json() as Promise<T>;
62
- }
63
-
64
- // ── Types ────────────────────────────────────────────────────
65
-
66
- interface ObjectInstance {
67
- elementId: string;
68
- displayName: string;
69
- parentId: string | null;
70
- isComposition: boolean;
71
- }
72
-
73
- interface RelatedResult {
74
- sourceRelationship: string;
75
- object: ObjectInstance;
76
- }
77
-
78
- interface VQT {
79
- value: unknown;
80
- quality: string;
81
- timestamp: string;
82
- }
83
-
84
- interface ValueResult {
85
- isComposition?: boolean;
86
- components?: Record<string, VQT>;
87
- value?: unknown;
88
- quality?: string;
89
- timestamp?: string;
90
- }
91
-
92
- interface SubscriptionUpdate {
93
- sequenceNumber: number;
94
- elementId: string;
95
- value: ValueResult;
96
- quality: string;
97
- timestamp: string;
98
- }
99
-
100
- // ── State ────────────────────────────────────────────────────
101
-
102
- interface AssetCard {
103
- id: string;
104
- name: string;
105
- icon: string;
106
- properties: PropertyEntry[];
107
- }
108
-
109
- interface PropertyEntry {
110
- id: string;
111
- name: string;
112
- value: unknown;
113
- quality: string;
114
- timestamp: string;
115
- changed: boolean; // flash on recent change
116
- }
117
-
118
- const nameById = new Map<string, string>();
119
- const cards: AssetCard[] = [];
120
- let subId = '';
121
- let lastSeq = 0;
122
- let updateCount = 0;
123
- let totalChanges = 0;
124
- let serverName = '';
125
- let lastError = '';
126
-
127
- // ── Card width ───────────────────────────────────────────────
128
-
129
- const CARD_W = 44;
130
-
131
- // ── Box drawing ──────────────────────────────────────────────
132
-
133
- function boxTop(w: number): string {
134
- return `┌${'─'.repeat(w - 2)}┐`;
135
- }
136
- function boxMid(w: number): string {
137
- return `├${'─'.repeat(w - 2)}┤`;
138
- }
139
- function boxBot(w: number): string {
140
- return `└${'─'.repeat(w - 2)}┘`;
141
- }
142
- function boxRow(content: string, w: number): string {
143
- // Strip ANSI for length calculation
144
- // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape stripping
145
- const visible = content.replace(/\x1b\[[0-9;]*m/g, '');
146
- const pad = Math.max(0, w - 4 - visible.length);
147
- return `│ ${content}${' '.repeat(pad)} │`;
148
- }
149
-
150
- // ── Discovery ────────────────────────────────────────────────
151
-
152
- async function discover(): Promise<string[]> {
153
- process.stdout.write(ansi.clear);
154
- process.stdout.write(ansi.hideCursor);
155
- process.stdout.write(
156
- `\n ${ansi.cyan}${ansi.bold}📡 Discovering i3X model...${ansi.reset}\n\n`,
157
- );
158
-
159
- const info = await get<{
160
- result: { serverName: string; specVersion: string };
161
- }>('/v1/info');
162
- serverName = info.result.serverName;
163
-
164
- // Root objects — skip OPC UA standard
165
- const roots = await get<{
166
- result: ObjectInstance[];
167
- }>('/v1/objects?root=true');
168
- const skipNames = ['Locations', 'Server', 'Aliases'];
169
- const userRoots = roots.result.filter((r) => !skipNames.includes(r.displayName));
170
-
171
- process.stdout.write(` Found ${userRoots.length} user-defined root(s)\n`);
172
-
173
- // Walk tree for each root
174
- const compositeIds: string[] = [];
175
- for (const root of userRoots) {
176
- await walkTree(root, compositeIds);
177
- }
178
-
179
- // Build cards for leaf assets (ones with properties)
180
- for (const card of cards) {
181
- process.stdout.write(` 📦 ${card.name} ` + `(${card.properties.length} props)\n`);
182
- }
183
-
184
- return compositeIds;
185
- }
186
-
187
- async function walkTree(obj: ObjectInstance, compositeIds: string[]): Promise<void> {
188
- nameById.set(obj.elementId, obj.displayName);
189
-
190
- if (!obj.isComposition) return;
191
-
192
- compositeIds.push(obj.id);
193
-
194
- const related = await post<{
195
- results: Array<{
196
- success: boolean;
197
- result: RelatedResult[];
198
- }>;
199
- }>('/v1/objects/related', {
200
- elementIds: [obj.elementId],
201
- });
202
-
203
- if (!related.results[0]?.success) return;
204
-
205
- const children = related.results[0]?.result.filter(
206
- (r) => r.sourceRelationship === 'HasComponent',
207
- );
208
-
209
- // Separate assets vs properties
210
- const childAssets = children.filter((c) => c.object.isComposition);
211
- const childProps = children.filter((c) => !c.object.isComposition);
212
-
213
- // If this node has properties, create a card
214
- if (childProps.length > 0) {
215
- const icon = iconForAsset(obj.displayName);
216
- const card: AssetCard = {
217
- id: obj.elementId,
218
- name: obj.displayName,
219
- icon,
220
- properties: childProps.map((c) => {
221
- nameById.set(c.object.elementId, c.object.displayName);
222
- return {
223
- id: c.object.elementId,
224
- name: c.object.displayName,
225
- value: '—',
226
- quality: 'Unknown',
227
- timestamp: '',
228
- changed: false,
229
- };
230
- }),
231
- };
232
- cards.push(card);
233
- }
234
-
235
- // Recurse into child assets
236
- for (const child of childAssets) {
237
- await walkTree(child.object, compositeIds);
238
- }
239
- }
240
-
241
- function iconForAsset(name: string): string {
242
- const n = name.toLowerCase();
243
- if (n.includes('pump')) return '💧';
244
- if (n.includes('heater')) return '🔥';
245
- if (n.includes('conveyor')) return '🏭';
246
- if (n.includes('factory')) return '🏗️';
247
- return '📦';
248
- }
249
-
250
- // ── Read initial values ──────────────────────────────────────
251
-
252
- async function readInitialValues(): Promise<void> {
253
- const ids = cards.map((c) => c.id);
254
- if (ids.length === 0) return;
255
-
256
- const values = await post<{
257
- results: Array<{
258
- success: boolean;
259
- elementId: string;
260
- result: ValueResult;
261
- }>;
262
- }>('/v1/objects/value', {
263
- elementIds: ids,
264
- maxDepth: 3,
265
- });
266
-
267
- for (const entry of values.results) {
268
- if (!entry.success) continue;
269
- if (!entry.result.isComposition) continue;
270
- if (!entry.result.components) continue;
271
-
272
- const card = cards.find((c) => c.id === entry.elementId);
273
- if (!card) continue;
274
-
275
- for (const prop of card.properties) {
276
- const vqt = entry.result.components[prop.id];
277
- if (vqt) {
278
- prop.value = vqt.value;
279
- prop.quality = vqt.quality;
280
- prop.timestamp = vqt.timestamp;
281
- }
282
- }
283
- }
284
- }
285
-
286
- // ── Create subscription ──────────────────────────────────────
287
-
288
- async function createSubscription(): Promise<void> {
289
- const ids = cards.map((c) => c.id);
290
- if (ids.length === 0) return;
291
-
292
- const createRes = await post<{
293
- result: { subscriptionId: string };
294
- }>('/v1/subscriptions', {
295
- clientId: 'dashboard-client',
296
- displayName: 'Dashboard Monitor',
297
- });
298
- subId = createRes.result.subscriptionId;
299
-
300
- await post('/v1/subscriptions/register', {
301
- subscriptionId: subId,
302
- elementIds: ids,
303
- maxDepth: 3,
304
- });
305
- }
306
-
307
- // ── Sync subscription updates ────────────────────────────────
308
-
309
- async function syncUpdates(): Promise<void> {
310
- try {
311
- const syncRes = await post<{
312
- result: SubscriptionUpdate[];
313
- }>('/v1/subscriptions/sync', {
314
- subscriptionId: subId,
315
- acknowledgeSequence: lastSeq,
316
- });
317
-
318
- const updates = syncRes.result;
319
- if (!updates || updates.length === 0) return;
320
-
321
- totalChanges += updates.length;
322
- updateCount++;
323
-
324
- // Clear all flash states
325
- for (const card of cards) {
326
- for (const prop of card.properties) {
327
- prop.changed = false;
328
- }
329
- }
330
-
331
- for (const u of updates) {
332
- if (u.sequenceNumber > lastSeq) {
333
- lastSeq = u.sequenceNumber;
334
- }
335
-
336
- if (!u.value?.isComposition || !u.value?.components) {
337
- continue;
338
- }
339
-
340
- const card = cards.find((c) => c.id === u.elementId);
341
- if (!card) continue;
342
-
343
- for (const [propId, vqt] of Object.entries(u.value.components)) {
344
- const prop = card.properties.find((p) => p.id === propId);
345
- if (prop) {
346
- prop.value = vqt.value;
347
- prop.quality = vqt.quality;
348
- prop.timestamp = vqt.timestamp;
349
- prop.changed = true;
350
- }
351
- }
352
- }
353
- lastError = '';
354
- } catch (err) {
355
- lastError = String(err);
356
- }
357
- }
358
-
359
- // ── Render ───────────────────────────────────────────────────
360
-
361
- function render(): void {
362
- const lines: string[] = [];
363
- const r = ansi.reset;
364
- const now = new Date().toLocaleTimeString();
365
-
366
- // Header bar
367
- lines.push('');
368
- lines.push(
369
- ` ${ansi.bgHeader}${ansi.white}${ansi.bold}` +
370
- ` 📡 i3X Dashboard — ${serverName} ` +
371
- `${r}` +
372
- ` ${ansi.dim}${now}${r}`,
373
- );
374
- lines.push('');
375
-
376
- // Status line
377
- const statusParts: string[] = [];
378
- statusParts.push(`${ansi.green}● Connected${r}`);
379
- statusParts.push(`${ansi.dim}Updates: ${updateCount}${r}`);
380
- statusParts.push(`${ansi.dim}Changes: ${totalChanges}${r}`);
381
- statusParts.push(`${ansi.dim}Seq: ${lastSeq}${r}`);
382
- lines.push(` ${statusParts.join(' │ ')}`);
383
- lines.push('');
384
-
385
- // Render cards in pairs (2 per row)
386
- for (let i = 0; i < cards.length; i += 2) {
387
- const left = renderCard(cards[i]!);
388
- const right = i + 1 < cards.length ? renderCard(cards[i + 1]!) : null;
389
-
390
- const maxLines = Math.max(left.length, right?.length ?? 0);
391
-
392
- for (let row = 0; row < maxLines; row++) {
393
- const l = left[row] ?? ' '.repeat(CARD_W);
394
- const rr = right ? (right[row] ?? ' '.repeat(CARD_W)) : '';
395
- lines.push(` ${l} ${rr}`);
396
- }
397
- lines.push('');
398
- }
399
-
400
- // Error line
401
- if (lastError) {
402
- lines.push(` ${ansi.red}⚠ ${lastError}${r}`);
403
- }
404
-
405
- // Footer
406
- lines.push(` ${ansi.dim}Press Ctrl+C to stop${r}`);
407
- lines.push('');
408
-
409
- // Write in one shot — move cursor home, overwrite
410
- process.stdout.write(ansi.home + lines.join('\n'));
411
- }
412
-
413
- function renderCard(card: AssetCard): string[] {
414
- const lines: string[] = [];
415
- const r = ansi.reset;
416
- const w = CARD_W;
417
-
418
- // Top border
419
- lines.push(`${ansi.dim}${boxTop(w)}${r}`);
420
-
421
- // Title
422
- const title = `${card.icon} ${ansi.bold}${ansi.cyan}` + `${card.name}${r}`;
423
- lines.push(`${ansi.dim}${boxRow(title, w)}${r}`);
424
- lines.push(`${ansi.dim}${boxMid(w)}${r}`);
425
-
426
- // Properties
427
- for (const prop of card.properties) {
428
- const label = cleanLabel(prop.name);
429
- const { text: valText, color } = formatPropValue(prop.value, prop.name);
430
-
431
- const flashColor = prop.changed ? ansi.yellow : ansi.dim;
432
-
433
- const line = `${flashColor}${label.padEnd(18)}${r} ` + `${color}${valText}${r}`;
434
-
435
- lines.push(`${ansi.dim}${boxRow(line, w)}${r}`);
436
- }
437
-
438
- // Bottom border
439
- lines.push(`${ansi.dim}${boxBot(w)}${r}`);
440
-
441
- return lines;
442
- }
443
-
444
- function cleanLabel(name: string): string {
445
- // Shorten common suffixes for compact display
446
- return name
447
- .replace(' (°C)', ' °C')
448
- .replace(' (bar)', ' bar')
449
- .replace(' (L/min)', ' L/min')
450
- .replace(' (m/s)', ' m/s')
451
- .replace(' (%)', ' %');
452
- }
453
-
454
- function formatPropValue(v: unknown, name: string): { text: string; color: string } {
455
- if (typeof v === 'boolean') {
456
- if (name.toLowerCase().includes('heater')) {
457
- return v
458
- ? { text: '🔥 ON', color: ansi.red }
459
- : { text: ' OFF', color: ansi.gray };
460
- }
461
- return v ? { text: '● ON', color: ansi.green } : { text: '○ OFF', color: ansi.gray };
462
- }
463
-
464
- if (typeof v === 'number') {
465
- const n = name.toLowerCase();
466
- let color = ansi.white;
467
-
468
- // Color-code by range
469
- if (n.includes('temperature') || n.includes('temp')) {
470
- if (v > 180) color = ansi.red;
471
- else if (v > 100) color = ansi.yellow;
472
- else color = ansi.green;
473
- } else if (n.includes('pressure')) {
474
- if (v > 5.5) color = ansi.red;
475
- else if (v > 4.5) color = ansi.yellow;
476
- else color = ansi.green;
477
- } else if (n.includes('power')) {
478
- if (v > 80) color = ansi.red;
479
- else if (v > 0) color = ansi.yellow;
480
- else color = ansi.gray;
481
- }
482
-
483
- const formatted = Number.isInteger(v) ? v.toLocaleString() : v.toFixed(2);
484
- return { text: formatted, color };
485
- }
486
-
487
- return {
488
- text: String(v ?? '—'),
489
- color: ansi.white,
490
- };
491
- }
492
-
493
- // ── Main ─────────────────────────────────────────────────────
494
-
495
- async function main() {
496
- // Check server
497
- try {
498
- await get('/health');
499
- } catch {
500
- console.error(`\n ❌ Cannot reach i3X server at ${BASE}`);
501
- console.error(
502
- ' Start the demo first:\n' + ' npm run demo -w packages/demo-embedded\n',
503
- );
504
- process.exit(1);
505
- }
506
-
507
- // Discovery phase (scrolling output)
508
- const _compositeIds = await discover();
509
-
510
- // Initial values
511
- process.stdout.write(`\n Reading initial values...\n`);
512
- await readInitialValues();
513
-
514
- // Subscription
515
- process.stdout.write(` Creating subscription...\n`);
516
- await createSubscription();
517
- process.stdout.write(` ✅ Monitoring ${cards.length} assets\n\n`);
518
-
519
- await sleep(1000);
520
-
521
- // Clear and enter dashboard mode
522
- process.stdout.write(ansi.clear);
523
- process.stdout.write(ansi.hideCursor);
524
-
525
- // Graceful shutdown
526
- const cleanup = async () => {
527
- process.stdout.write(ansi.showCursor);
528
- process.stdout.write('\n\n');
529
- if (subId) {
530
- try {
531
- await post('/v1/subscriptions/delete', {
532
- subscriptionIds: [subId],
533
- });
534
- } catch {
535
- /* ignore */
536
- }
537
- }
538
- console.log(' Subscription cleaned up. Bye!\n');
539
- process.exit(0);
540
- };
541
- process.on('SIGINT', () => {
542
- void cleanup();
543
- });
544
- process.on('SIGTERM', () => {
545
- void cleanup();
546
- });
547
-
548
- // Render loop
549
- render();
550
- let iteration = 0;
551
- while (iteration < 600) {
552
- // max ~20 minutes
553
- await sleep(2000);
554
- iteration++;
555
- await syncUpdates();
556
- render();
557
- }
558
-
559
- await cleanup();
560
- }
561
-
562
- function sleep(ms: number): Promise<void> {
563
- return new Promise((r) => setTimeout(r, ms));
564
- }
565
-
566
- main().catch((err) => {
567
- process.stdout.write(ansi.showCursor);
568
- console.error('Fatal:', err);
569
- process.exit(1);
570
- });