@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/LICENSE +54 -0
- package/LICENSE-AGPL-3.0 +235 -0
- package/LICENSING.md +107 -0
- package/README.md +9 -2
- package/dist/client.js +1 -1
- package/dist/client.js.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/package.json +20 -12
- package/src/client.ts +0 -570
- package/src/demo-remote.ts +0 -148
- package/src/index.ts +0 -350
- package/tsconfig.json +0 -13
- package/tsup.config.ts +0 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@node-i3x/demo-embedded",
|
|
3
|
-
"version": "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.
|
|
40
|
-
"@fastify/cors": "^11.
|
|
41
|
-
"fastify-plugin": "^
|
|
42
|
-
"node-opcua": "^2.
|
|
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.
|
|
46
|
-
"tsx": "^4.
|
|
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
|
-
});
|