miravo 0.4.0 → 0.5.0
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/console-dist/assets/index-C1oOHU1J.js +119 -0
- package/console-dist/index.html +1 -1
- package/package.json +6 -5
- package/src/commands/start.test.ts +122 -0
- package/src/commands/start.ts +241 -11
- package/src/lib/shutdown.ts +13 -3
- package/src/logger.ts +21 -4
- package/console-dist/assets/index-hUJSpKtB.js +0 -119
package/console-dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>Miravo</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-C1oOHU1J.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-C3XMYq26.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "miravo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Industrial environment platform with canonical asset models and MQTT + OPC UA protocol adapters",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -32,10 +32,11 @@
|
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@clack/prompts": "^1.1.0",
|
|
35
|
-
"@miravo/core": "0.
|
|
36
|
-
"@miravo/mqtt": "0.
|
|
37
|
-
"@miravo/opcua": "0.
|
|
38
|
-
"@miravo/
|
|
35
|
+
"@miravo/core": "0.5.0",
|
|
36
|
+
"@miravo/mqtt": "0.5.0",
|
|
37
|
+
"@miravo/opcua": "0.5.0",
|
|
38
|
+
"@miravo/modbus": "0.5.0",
|
|
39
|
+
"@miravo/shared": "0.5.0",
|
|
39
40
|
"commander": "^13.0.0",
|
|
40
41
|
"consola": "^3.4.0",
|
|
41
42
|
"mqtt": "^5.15.0",
|
|
@@ -221,6 +221,7 @@ describe("resolveProtocolBootPlans", () => {
|
|
|
221
221
|
).toEqual([
|
|
222
222
|
{ name: "mqtt", shouldEnable: true, overrideRestore: false },
|
|
223
223
|
{ name: "opcua", shouldEnable: false, overrideRestore: false },
|
|
224
|
+
{ name: "modbus", shouldEnable: false, overrideRestore: false },
|
|
224
225
|
]);
|
|
225
226
|
});
|
|
226
227
|
|
|
@@ -236,6 +237,7 @@ describe("resolveProtocolBootPlans", () => {
|
|
|
236
237
|
).toEqual([
|
|
237
238
|
{ name: "mqtt", shouldEnable: true, overrideRestore: true },
|
|
238
239
|
{ name: "opcua", shouldEnable: false, overrideRestore: true },
|
|
240
|
+
{ name: "modbus", shouldEnable: false, overrideRestore: true },
|
|
239
241
|
]);
|
|
240
242
|
});
|
|
241
243
|
|
|
@@ -300,6 +302,126 @@ describe("resolveProtocolBootPlans", () => {
|
|
|
300
302
|
});
|
|
301
303
|
});
|
|
302
304
|
|
|
305
|
+
describe("resolveDetachAdapterConfigs", () => {
|
|
306
|
+
test("uses template adapter configs when no CLI/env overrides are provided", async () => {
|
|
307
|
+
const { resolveDetachAdapterConfigs } = await import("./start.ts");
|
|
308
|
+
|
|
309
|
+
const template = createTemplateWithNamespace({ prefix: "factory" });
|
|
310
|
+
const withProtocols: MiravoTemplate = {
|
|
311
|
+
...template,
|
|
312
|
+
protocols: {
|
|
313
|
+
mqtt: { port: 1889, embedded: true, retain: false, metadata: "base" },
|
|
314
|
+
opcua: { port: 4851, host: "127.0.0.1", security: "none" },
|
|
315
|
+
modbus: { port: 1602, host: "127.0.0.1" },
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
expect(
|
|
320
|
+
resolveDetachAdapterConfigs({
|
|
321
|
+
template: withProtocols,
|
|
322
|
+
simulationName: "factory-sim",
|
|
323
|
+
mqtt: { enabled: true, portOverride: undefined, overrides: {} },
|
|
324
|
+
opcua: {
|
|
325
|
+
enabled: true,
|
|
326
|
+
portOverride: undefined,
|
|
327
|
+
hostOverride: undefined,
|
|
328
|
+
writeBudgetOverride: undefined,
|
|
329
|
+
},
|
|
330
|
+
modbus: { enabled: true, portOverride: undefined, hostOverride: undefined },
|
|
331
|
+
}),
|
|
332
|
+
).toEqual({
|
|
333
|
+
mqtt: { port: 1889, host: "127.0.0.1", embedded: true },
|
|
334
|
+
opcua: { port: 4851, host: "127.0.0.1" },
|
|
335
|
+
modbus: { port: 1602, host: "127.0.0.1" },
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("CLI/env overrides take precedence over template values", async () => {
|
|
340
|
+
const { resolveDetachAdapterConfigs } = await import("./start.ts");
|
|
341
|
+
|
|
342
|
+
const template = createTemplateWithNamespace({ prefix: "factory" });
|
|
343
|
+
const withProtocols: MiravoTemplate = {
|
|
344
|
+
...template,
|
|
345
|
+
protocols: {
|
|
346
|
+
mqtt: { port: 1889, embedded: true, retain: false, metadata: "base" },
|
|
347
|
+
opcua: { port: 4851, host: "127.0.0.1", security: "none" },
|
|
348
|
+
modbus: { port: 1602, host: "127.0.0.1" },
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
expect(
|
|
353
|
+
resolveDetachAdapterConfigs({
|
|
354
|
+
template: withProtocols,
|
|
355
|
+
simulationName: "factory-sim",
|
|
356
|
+
mqtt: {
|
|
357
|
+
enabled: true,
|
|
358
|
+
portOverride: 1999,
|
|
359
|
+
overrides: { host: "0.0.0.0" },
|
|
360
|
+
},
|
|
361
|
+
opcua: {
|
|
362
|
+
enabled: true,
|
|
363
|
+
portOverride: 4852,
|
|
364
|
+
hostOverride: "0.0.0.0",
|
|
365
|
+
writeBudgetOverride: undefined,
|
|
366
|
+
},
|
|
367
|
+
modbus: { enabled: true, portOverride: 1702, hostOverride: "0.0.0.0" },
|
|
368
|
+
}),
|
|
369
|
+
).toEqual({
|
|
370
|
+
mqtt: { port: 1999, host: "0.0.0.0", embedded: true },
|
|
371
|
+
opcua: { port: 4852, host: "0.0.0.0" },
|
|
372
|
+
modbus: { port: 1702, host: "0.0.0.0" },
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("returns disabled placeholders when adapters are disabled", async () => {
|
|
377
|
+
const { resolveDetachAdapterConfigs } = await import("./start.ts");
|
|
378
|
+
const template = createTemplateWithNamespace({ prefix: "factory" });
|
|
379
|
+
|
|
380
|
+
expect(
|
|
381
|
+
resolveDetachAdapterConfigs({
|
|
382
|
+
template,
|
|
383
|
+
simulationName: "factory-sim",
|
|
384
|
+
mqtt: { enabled: false, portOverride: 1999, overrides: { host: "0.0.0.0" } },
|
|
385
|
+
opcua: {
|
|
386
|
+
enabled: false,
|
|
387
|
+
portOverride: 4852,
|
|
388
|
+
hostOverride: "0.0.0.0",
|
|
389
|
+
writeBudgetOverride: 4096,
|
|
390
|
+
},
|
|
391
|
+
modbus: { enabled: false, portOverride: 1702, hostOverride: "0.0.0.0" },
|
|
392
|
+
}),
|
|
393
|
+
).toEqual({
|
|
394
|
+
mqtt: { port: undefined, host: undefined, embedded: false },
|
|
395
|
+
opcua: { port: undefined, host: undefined },
|
|
396
|
+
modbus: { port: undefined, host: undefined },
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("marks external MQTT mode as non-embedded for detach conflict checks", async () => {
|
|
401
|
+
const { resolveDetachAdapterConfigs } = await import("./start.ts");
|
|
402
|
+
const template = createTemplateWithNamespace({ prefix: "factory" });
|
|
403
|
+
|
|
404
|
+
expect(
|
|
405
|
+
resolveDetachAdapterConfigs({
|
|
406
|
+
template,
|
|
407
|
+
simulationName: "factory-sim",
|
|
408
|
+
mqtt: {
|
|
409
|
+
enabled: true,
|
|
410
|
+
portOverride: undefined,
|
|
411
|
+
overrides: { mode: "external", url: "mqtt://broker.example.com:1883" },
|
|
412
|
+
},
|
|
413
|
+
opcua: {
|
|
414
|
+
enabled: false,
|
|
415
|
+
portOverride: undefined,
|
|
416
|
+
hostOverride: undefined,
|
|
417
|
+
writeBudgetOverride: undefined,
|
|
418
|
+
},
|
|
419
|
+
modbus: { enabled: false, portOverride: undefined, hostOverride: undefined },
|
|
420
|
+
}).mqtt.embedded,
|
|
421
|
+
).toBe(false);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
303
425
|
describe("error context aggregation", () => {
|
|
304
426
|
test("normalizeErrorContext redacts high-cardinality fragments", async () => {
|
|
305
427
|
const { normalizeErrorContext } = await import("./start.ts");
|
package/src/commands/start.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
resolveModelPath,
|
|
14
14
|
validateFullTemplate,
|
|
15
15
|
} from "@miravo/core";
|
|
16
|
+
import { ModbusAdapter } from "@miravo/modbus";
|
|
16
17
|
import { MQTTAdapter } from "@miravo/mqtt";
|
|
17
18
|
import { OPCUAAdapter } from "@miravo/opcua";
|
|
18
19
|
import type {
|
|
@@ -25,6 +26,7 @@ import type {
|
|
|
25
26
|
import {
|
|
26
27
|
ConfigNotFoundError,
|
|
27
28
|
ConfigValidationError,
|
|
29
|
+
DEFAULT_MODBUS_PORT,
|
|
28
30
|
DEFAULT_MQTT_PORT,
|
|
29
31
|
DEFAULT_OPCUA_PORT,
|
|
30
32
|
DEFAULT_TICK_INTERVAL_MS,
|
|
@@ -80,6 +82,7 @@ import {
|
|
|
80
82
|
handleCommandError,
|
|
81
83
|
isPlain,
|
|
82
84
|
lifecycleLog,
|
|
85
|
+
modbusLog,
|
|
83
86
|
mqttLog,
|
|
84
87
|
opcuaLog,
|
|
85
88
|
setRuntimeLogContext,
|
|
@@ -104,6 +107,9 @@ interface StartOptions {
|
|
|
104
107
|
readonly opcuaPort?: number;
|
|
105
108
|
readonly opcuaHost?: string;
|
|
106
109
|
readonly opcua?: boolean;
|
|
110
|
+
readonly modbusPort?: number;
|
|
111
|
+
readonly modbusHost?: string;
|
|
112
|
+
readonly modbus?: boolean;
|
|
107
113
|
readonly adminHost?: string;
|
|
108
114
|
readonly adminPort?: number;
|
|
109
115
|
readonly dryRun?: boolean;
|
|
@@ -121,7 +127,7 @@ interface RestoreSummary {
|
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
interface ProtocolBootPlan {
|
|
124
|
-
readonly name: "mqtt" | "opcua";
|
|
130
|
+
readonly name: "mqtt" | "opcua" | "modbus";
|
|
125
131
|
readonly shouldEnable: boolean;
|
|
126
132
|
readonly overrideRestore: boolean;
|
|
127
133
|
}
|
|
@@ -129,6 +135,7 @@ interface ProtocolBootPlan {
|
|
|
129
135
|
type ProtocolOptionSource =
|
|
130
136
|
| "mqtt"
|
|
131
137
|
| "opcua"
|
|
138
|
+
| "modbus"
|
|
132
139
|
| "mqttPort"
|
|
133
140
|
| "mqttHost"
|
|
134
141
|
| "mqttMode"
|
|
@@ -136,7 +143,9 @@ type ProtocolOptionSource =
|
|
|
136
143
|
| "mqttUsername"
|
|
137
144
|
| "mqttPassword"
|
|
138
145
|
| "opcuaPort"
|
|
139
|
-
| "opcuaHost"
|
|
146
|
+
| "opcuaHost"
|
|
147
|
+
| "modbusPort"
|
|
148
|
+
| "modbusHost";
|
|
140
149
|
|
|
141
150
|
type MqttMode = "embedded" | "external";
|
|
142
151
|
|
|
@@ -161,7 +170,12 @@ function resolveConsoleDir(): string | undefined {
|
|
|
161
170
|
// @miravo/console not installed (npm/bunx) — fall through
|
|
162
171
|
}
|
|
163
172
|
|
|
164
|
-
// 3.
|
|
173
|
+
// 3. Docker / monorepo: workspace symlinks may not exist (production install).
|
|
174
|
+
// start.ts is at packages/cli/src/commands/ → ../../../console/dist
|
|
175
|
+
const monorepo = resolve(import.meta.dir, "..", "..", "..", "console", "dist");
|
|
176
|
+
if (existsSync(`${monorepo}/index.html`)) return monorepo;
|
|
177
|
+
|
|
178
|
+
// 4. npm install / bunx: look for console-dist/ shipped inside the published package.
|
|
165
179
|
// start.ts is at packages/cli/src/commands/ → ../../console-dist/
|
|
166
180
|
const shipped = resolve(import.meta.dir, "..", "..", "console-dist");
|
|
167
181
|
if (existsSync(`${shipped}/index.html`)) return shipped;
|
|
@@ -218,6 +232,13 @@ export function registerStartCommand(program: Command): void {
|
|
|
218
232
|
parsePort,
|
|
219
233
|
)
|
|
220
234
|
.option("--opcua-host <host>", "OPC UA server bind host (env: MIRAVO_OPCUA_HOST)")
|
|
235
|
+
.option("--modbus", "Enable Modbus TCP server adapter")
|
|
236
|
+
.option(
|
|
237
|
+
"--modbus-port <number>",
|
|
238
|
+
`Modbus TCP port (default: ${DEFAULT_MODBUS_PORT}, env: MIRAVO_MODBUS_PORT)`,
|
|
239
|
+
parsePort,
|
|
240
|
+
)
|
|
241
|
+
.option("--modbus-host <host>", "Modbus TCP bind host (env: MIRAVO_MODBUS_HOST)")
|
|
221
242
|
.option("--admin-host <host>", "Admin server host (default: 127.0.0.1, env: MIRAVO_ADMIN_HOST)")
|
|
222
243
|
.option(
|
|
223
244
|
"--admin-port <number>",
|
|
@@ -277,6 +298,7 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
277
298
|
// Protocol selection: CLI flags decide which protocols run
|
|
278
299
|
const mqttEnabled = options.mqtt !== false; // --no-mqtt disables
|
|
279
300
|
const opcuaEnabled = options.opcua === true; // --opcua enables
|
|
301
|
+
const modbusEnabled = options.modbus === true; // --modbus enables
|
|
280
302
|
|
|
281
303
|
// Warn on ignored protocol-specific flags
|
|
282
304
|
if (!mqttEnabled) {
|
|
@@ -302,6 +324,18 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
302
324
|
);
|
|
303
325
|
}
|
|
304
326
|
}
|
|
327
|
+
if (!modbusEnabled) {
|
|
328
|
+
if (options.modbusPort !== undefined) {
|
|
329
|
+
configLog.warn(
|
|
330
|
+
"--modbus-port ignored because Modbus is not enabled. Use --modbus to enable it.",
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
if (options.modbusHost !== undefined) {
|
|
334
|
+
configLog.warn(
|
|
335
|
+
"--modbus-host ignored because Modbus is not enabled. Use --modbus to enable it.",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
305
339
|
|
|
306
340
|
const adminHost = options.adminHost ?? Bun.env["MIRAVO_ADMIN_HOST"] ?? "127.0.0.1";
|
|
307
341
|
const adminPortEnv = Bun.env["MIRAVO_ADMIN_PORT"];
|
|
@@ -392,6 +426,30 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
392
426
|
}
|
|
393
427
|
}
|
|
394
428
|
|
|
429
|
+
// Modbus env overrides — only parsed when Modbus is enabled
|
|
430
|
+
let modbusPort: number | undefined = options.modbusPort;
|
|
431
|
+
let modbusHost: string | undefined;
|
|
432
|
+
if (modbusEnabled) {
|
|
433
|
+
const modbusPortEnv = Bun.env["MIRAVO_MODBUS_PORT"];
|
|
434
|
+
if (modbusPort === undefined && modbusPortEnv) {
|
|
435
|
+
try {
|
|
436
|
+
modbusPort = parsePort(modbusPortEnv);
|
|
437
|
+
} catch {
|
|
438
|
+
throw new ConfigValidationError({
|
|
439
|
+
message: `Invalid MIRAVO_MODBUS_PORT environment variable: "${modbusPortEnv}".`,
|
|
440
|
+
fix: "Set MIRAVO_MODBUS_PORT to a valid port number between 0 and 65535.",
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
modbusHost = options.modbusHost ?? Bun.env["MIRAVO_MODBUS_HOST"] ?? undefined;
|
|
445
|
+
if (modbusHost !== undefined && modbusHost.trim().length === 0) {
|
|
446
|
+
throw new ConfigValidationError({
|
|
447
|
+
message: "Invalid Modbus TCP bind host: value is empty.",
|
|
448
|
+
fix: "Set --modbus-host / MIRAVO_MODBUS_HOST to a valid host (for example, 0.0.0.0 or 127.0.0.1).",
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
395
453
|
let adminPort = options.adminPort;
|
|
396
454
|
if (adminPort === undefined && adminPortEnv) {
|
|
397
455
|
try {
|
|
@@ -457,6 +515,27 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
457
515
|
|
|
458
516
|
// Step 3b: Detach check — spawn background process and exit
|
|
459
517
|
if (options.detach) {
|
|
518
|
+
const detachAdapterConfigs = resolveDetachAdapterConfigs({
|
|
519
|
+
template,
|
|
520
|
+
simulationName,
|
|
521
|
+
mqtt: {
|
|
522
|
+
enabled: mqttEnabled,
|
|
523
|
+
portOverride: portOption,
|
|
524
|
+
overrides: mqttOverrides,
|
|
525
|
+
},
|
|
526
|
+
opcua: {
|
|
527
|
+
enabled: opcuaEnabled,
|
|
528
|
+
portOverride: opcuaPort,
|
|
529
|
+
hostOverride: opcuaHost,
|
|
530
|
+
writeBudgetOverride: opcuaWriteBudget,
|
|
531
|
+
},
|
|
532
|
+
modbus: {
|
|
533
|
+
enabled: modbusEnabled,
|
|
534
|
+
portOverride: modbusPort,
|
|
535
|
+
hostOverride: modbusHost,
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
|
|
460
539
|
await detachAction({
|
|
461
540
|
simulationName,
|
|
462
541
|
templatePath,
|
|
@@ -464,12 +543,16 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
464
543
|
seedExplicit,
|
|
465
544
|
adminHost,
|
|
466
545
|
adminPort,
|
|
467
|
-
mqttPort:
|
|
468
|
-
mqttHost:
|
|
546
|
+
mqttPort: detachAdapterConfigs.mqtt.port,
|
|
547
|
+
mqttHost: detachAdapterConfigs.mqtt.host,
|
|
548
|
+
mqttEmbedded: detachAdapterConfigs.mqtt.embedded,
|
|
469
549
|
mqttEnabled,
|
|
470
|
-
opcuaPort,
|
|
471
|
-
opcuaHost,
|
|
550
|
+
opcuaPort: detachAdapterConfigs.opcua.port,
|
|
551
|
+
opcuaHost: detachAdapterConfigs.opcua.host,
|
|
472
552
|
opcuaEnabled,
|
|
553
|
+
modbusPort: detachAdapterConfigs.modbus.port,
|
|
554
|
+
modbusHost: detachAdapterConfigs.modbus.host,
|
|
555
|
+
modbusEnabled,
|
|
473
556
|
});
|
|
474
557
|
return;
|
|
475
558
|
}
|
|
@@ -590,6 +673,17 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
590
673
|
opcuaDefaultConfig,
|
|
591
674
|
);
|
|
592
675
|
|
|
676
|
+
const modbusDefaultConfig = buildModbusDefaultConfig(template, modbusPort, modbusHost);
|
|
677
|
+
eng.registerAvailableAdapter(
|
|
678
|
+
"modbus",
|
|
679
|
+
async (config) => {
|
|
680
|
+
const adapter = new ModbusAdapter(eng.bus, eng);
|
|
681
|
+
await adapter.start(config);
|
|
682
|
+
return { adapter, endpoint: buildModbusEndpoint(config) };
|
|
683
|
+
},
|
|
684
|
+
modbusDefaultConfig,
|
|
685
|
+
);
|
|
686
|
+
|
|
593
687
|
// Step 5b: Wire control plane (dynamically follows MQTT adapter lifecycle)
|
|
594
688
|
function wireControlPlane(): void {
|
|
595
689
|
// Stop old control plane before creating a new one (prevents listener leaks)
|
|
@@ -694,6 +788,8 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
694
788
|
availableAdapters.find((a) => a.name === "mqtt" && a.enabled)?.endpoint ?? null;
|
|
695
789
|
const opcuaEndpoint =
|
|
696
790
|
availableAdapters.find((a) => a.name === "opcua" && a.enabled)?.endpoint ?? null;
|
|
791
|
+
const modbusEndpoint =
|
|
792
|
+
availableAdapters.find((a) => a.name === "modbus" && a.enabled)?.endpoint ?? null;
|
|
697
793
|
printStartupBanner(
|
|
698
794
|
templatePath,
|
|
699
795
|
eng,
|
|
@@ -701,6 +797,7 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
701
797
|
seedExplicit,
|
|
702
798
|
mqttEndpoint,
|
|
703
799
|
opcuaEndpoint,
|
|
800
|
+
modbusEndpoint,
|
|
704
801
|
simulationName,
|
|
705
802
|
runId,
|
|
706
803
|
adminServer.endpoint,
|
|
@@ -754,10 +851,30 @@ interface DetachOptions {
|
|
|
754
851
|
readonly adminPort: number;
|
|
755
852
|
readonly mqttPort: number | undefined;
|
|
756
853
|
readonly mqttHost: string | undefined;
|
|
854
|
+
readonly mqttEmbedded: boolean;
|
|
757
855
|
readonly mqttEnabled: boolean;
|
|
758
856
|
readonly opcuaPort: number | undefined;
|
|
759
857
|
readonly opcuaHost: string | undefined;
|
|
760
858
|
readonly opcuaEnabled: boolean;
|
|
859
|
+
readonly modbusPort: number | undefined;
|
|
860
|
+
readonly modbusHost: string | undefined;
|
|
861
|
+
readonly modbusEnabled: boolean;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
interface ResolvedDetachAdapterConfigs {
|
|
865
|
+
readonly mqtt: {
|
|
866
|
+
readonly port: number | undefined;
|
|
867
|
+
readonly host: string | undefined;
|
|
868
|
+
readonly embedded: boolean;
|
|
869
|
+
};
|
|
870
|
+
readonly opcua: {
|
|
871
|
+
readonly port: number | undefined;
|
|
872
|
+
readonly host: string | undefined;
|
|
873
|
+
};
|
|
874
|
+
readonly modbus: {
|
|
875
|
+
readonly port: number | undefined;
|
|
876
|
+
readonly host: string | undefined;
|
|
877
|
+
};
|
|
761
878
|
}
|
|
762
879
|
|
|
763
880
|
async function detachAction(opts: DetachOptions): Promise<void> {
|
|
@@ -791,10 +908,11 @@ async function detachActionLocked(
|
|
|
791
908
|
// 2. Check port conflicts
|
|
792
909
|
const ports = new Map<string, number>();
|
|
793
910
|
ports.set("admin", adminPort);
|
|
794
|
-
if (opts.mqttEnabled && opts.
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
if (opts.opcuaEnabled &&
|
|
911
|
+
if (opts.mqttEnabled && opts.mqttEmbedded && opts.mqttPort !== undefined) {
|
|
912
|
+
ports.set("mqtt", opts.mqttPort);
|
|
913
|
+
}
|
|
914
|
+
if (opts.opcuaEnabled && opts.opcuaPort !== undefined) ports.set("opcua", opts.opcuaPort);
|
|
915
|
+
if (opts.modbusEnabled && opts.modbusPort !== undefined) ports.set("modbus", opts.modbusPort);
|
|
798
916
|
|
|
799
917
|
const conflicts = await checkPortConflicts(ports);
|
|
800
918
|
if (conflicts.length > 0) {
|
|
@@ -852,6 +970,10 @@ async function detachActionLocked(
|
|
|
852
970
|
const opcuaPort = opts.opcuaPort ?? DEFAULT_OPCUA_PORT;
|
|
853
971
|
adapters["opcua"] = { port: opcuaPort, host: opts.opcuaHost ?? "0.0.0.0" };
|
|
854
972
|
}
|
|
973
|
+
if (opts.modbusEnabled) {
|
|
974
|
+
const modbusPort = opts.modbusPort ?? DEFAULT_MODBUS_PORT;
|
|
975
|
+
adapters["modbus"] = { port: modbusPort, host: opts.modbusHost ?? "0.0.0.0" };
|
|
976
|
+
}
|
|
855
977
|
|
|
856
978
|
const entry: DaemonRegistryEntry = {
|
|
857
979
|
name: simulationName,
|
|
@@ -955,6 +1077,8 @@ function printDetachSummary(entry: DaemonRegistryEntry): void {
|
|
|
955
1077
|
lines.push(` MQTT mqtt://${host}:${adapter.port}`);
|
|
956
1078
|
} else if (name === "opcua") {
|
|
957
1079
|
lines.push(` OPC UA opc.tcp://${host}:${adapter.port}`);
|
|
1080
|
+
} else if (name === "modbus") {
|
|
1081
|
+
lines.push(` Modbus modbus://${host}:${adapter.port}`);
|
|
958
1082
|
} else {
|
|
959
1083
|
lines.push(` ${name} ${host}:${adapter.port}`);
|
|
960
1084
|
}
|
|
@@ -1283,6 +1407,9 @@ function createAndPopulateEngine(
|
|
|
1283
1407
|
mqttPassword: getCommandOptionSource(options.command, "mqttPassword"),
|
|
1284
1408
|
opcuaPort: getCommandOptionSource(options.command, "opcuaPort"),
|
|
1285
1409
|
opcuaHost: getCommandOptionSource(options.command, "opcuaHost"),
|
|
1410
|
+
modbus: getCommandOptionSource(options.command, "modbus"),
|
|
1411
|
+
modbusPort: getCommandOptionSource(options.command, "modbusPort"),
|
|
1412
|
+
modbusHost: getCommandOptionSource(options.command, "modbusHost"),
|
|
1286
1413
|
},
|
|
1287
1414
|
restoredFromPersistence,
|
|
1288
1415
|
});
|
|
@@ -1633,6 +1760,23 @@ function buildOPCUAEndpoint(config: import("@miravo/shared").ProtocolConfig): st
|
|
|
1633
1760
|
return `opc.tcp://${displayHost}:${port}`;
|
|
1634
1761
|
}
|
|
1635
1762
|
|
|
1763
|
+
function buildModbusDefaultConfig(
|
|
1764
|
+
template: MiravoTemplate,
|
|
1765
|
+
portOverride: number | undefined,
|
|
1766
|
+
hostOverride: string | undefined,
|
|
1767
|
+
): Record<string, unknown> {
|
|
1768
|
+
const modbusConfig = template.protocols?.modbus;
|
|
1769
|
+
const port = portOverride ?? modbusConfig?.port ?? DEFAULT_MODBUS_PORT;
|
|
1770
|
+
const host = hostOverride ?? modbusConfig?.host ?? "0.0.0.0";
|
|
1771
|
+
return { name: "modbus", enabled: true, port, host };
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
function buildModbusEndpoint(config: Record<string, unknown>): string {
|
|
1775
|
+
const port = (config as { port?: number }).port ?? DEFAULT_MODBUS_PORT;
|
|
1776
|
+
const host = (config as { host?: string }).host ?? "0.0.0.0";
|
|
1777
|
+
return `modbus://${displayHost(String(host))}:${port}`;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1636
1780
|
// ---------------------------------------------------------------------------
|
|
1637
1781
|
// Step 6: Startup banner
|
|
1638
1782
|
// ---------------------------------------------------------------------------
|
|
@@ -1644,6 +1788,7 @@ function printStartupBanner(
|
|
|
1644
1788
|
seedExplicit: boolean,
|
|
1645
1789
|
mqttEndpoint: string | null,
|
|
1646
1790
|
opcuaEndpoint: string | null,
|
|
1791
|
+
modbusEndpoint: string | null,
|
|
1647
1792
|
simulationName: string,
|
|
1648
1793
|
runId: string,
|
|
1649
1794
|
adminEndpoint: string,
|
|
@@ -1662,6 +1807,7 @@ function printStartupBanner(
|
|
|
1662
1807
|
|
|
1663
1808
|
const mqttValue = mqttEndpoint ? colors.cyan(mqttEndpoint) : colors.dim("disabled");
|
|
1664
1809
|
const opcuaValue = opcuaEndpoint ? colors.cyan(opcuaEndpoint) : colors.dim("disabled");
|
|
1810
|
+
const modbusValue = modbusEndpoint ? colors.cyan(modbusEndpoint) : colors.dim("disabled");
|
|
1665
1811
|
|
|
1666
1812
|
const allEntries: ReadonlyArray<readonly [string, string]> = [
|
|
1667
1813
|
[colors.dim("Simulation"), colors.bold(simulationName)],
|
|
@@ -1671,6 +1817,7 @@ function printStartupBanner(
|
|
|
1671
1817
|
[colors.dim("Instances"), colors.bold(String(state.instances.length))],
|
|
1672
1818
|
[colors.dim("MQTT"), mqttValue],
|
|
1673
1819
|
[colors.dim("OPC UA"), opcuaValue],
|
|
1820
|
+
[colors.dim("Modbus"), modbusValue],
|
|
1674
1821
|
[colors.dim("Admin"), colors.cyan(adminEndpoint)],
|
|
1675
1822
|
[colors.dim("Tick interval"), colors.bold(`${state.config.tickIntervalMs}ms`)],
|
|
1676
1823
|
[colors.dim("Time multiplier"), colors.bold(`${state.config.timeMultiplier}x`)],
|
|
@@ -1849,6 +1996,12 @@ export function resolveProtocolBootPlans(input: {
|
|
|
1849
1996
|
(option) => input.optionSources[option as ProtocolOptionSource] === "cli",
|
|
1850
1997
|
);
|
|
1851
1998
|
|
|
1999
|
+
const modbusConfigExplicit =
|
|
2000
|
+
input.options.modbus === true &&
|
|
2001
|
+
["modbusPort", "modbusHost"].some(
|
|
2002
|
+
(option) => input.optionSources[option as ProtocolOptionSource] === "cli",
|
|
2003
|
+
);
|
|
2004
|
+
|
|
1852
2005
|
return [
|
|
1853
2006
|
{
|
|
1854
2007
|
name: "mqtt",
|
|
@@ -1866,9 +2019,79 @@ export function resolveProtocolBootPlans(input: {
|
|
|
1866
2019
|
input.optionSources["opcua"] === "cli" ||
|
|
1867
2020
|
opcuaConfigExplicit,
|
|
1868
2021
|
},
|
|
2022
|
+
{
|
|
2023
|
+
name: "modbus",
|
|
2024
|
+
shouldEnable: input.options.modbus === true,
|
|
2025
|
+
overrideRestore:
|
|
2026
|
+
!input.restoredFromPersistence ||
|
|
2027
|
+
input.optionSources["modbus"] === "cli" ||
|
|
2028
|
+
modbusConfigExplicit,
|
|
2029
|
+
},
|
|
1869
2030
|
];
|
|
1870
2031
|
}
|
|
1871
2032
|
|
|
2033
|
+
export function resolveDetachAdapterConfigs(input: {
|
|
2034
|
+
readonly template: MiravoTemplate;
|
|
2035
|
+
readonly simulationName: string;
|
|
2036
|
+
readonly mqtt: {
|
|
2037
|
+
readonly enabled: boolean;
|
|
2038
|
+
readonly portOverride: number | undefined;
|
|
2039
|
+
readonly overrides: MqttRunOverrides;
|
|
2040
|
+
};
|
|
2041
|
+
readonly opcua: {
|
|
2042
|
+
readonly enabled: boolean;
|
|
2043
|
+
readonly portOverride: number | undefined;
|
|
2044
|
+
readonly hostOverride: string | undefined;
|
|
2045
|
+
readonly writeBudgetOverride: number | undefined;
|
|
2046
|
+
};
|
|
2047
|
+
readonly modbus: {
|
|
2048
|
+
readonly enabled: boolean;
|
|
2049
|
+
readonly portOverride: number | undefined;
|
|
2050
|
+
readonly hostOverride: string | undefined;
|
|
2051
|
+
};
|
|
2052
|
+
}): ResolvedDetachAdapterConfigs {
|
|
2053
|
+
const mqttConfig = input.mqtt.enabled
|
|
2054
|
+
? buildMQTTDefaultConfig(
|
|
2055
|
+
input.template,
|
|
2056
|
+
input.mqtt.portOverride,
|
|
2057
|
+
input.mqtt.overrides,
|
|
2058
|
+
input.simulationName,
|
|
2059
|
+
)
|
|
2060
|
+
: null;
|
|
2061
|
+
const opcuaConfig = input.opcua.enabled
|
|
2062
|
+
? buildOPCUADefaultConfig(
|
|
2063
|
+
input.template,
|
|
2064
|
+
input.opcua.portOverride,
|
|
2065
|
+
input.opcua.hostOverride,
|
|
2066
|
+
input.simulationName,
|
|
2067
|
+
input.opcua.writeBudgetOverride,
|
|
2068
|
+
)
|
|
2069
|
+
: null;
|
|
2070
|
+
const modbusConfig = input.modbus.enabled
|
|
2071
|
+
? buildModbusDefaultConfig(input.template, input.modbus.portOverride, input.modbus.hostOverride)
|
|
2072
|
+
: null;
|
|
2073
|
+
|
|
2074
|
+
return {
|
|
2075
|
+
mqtt: {
|
|
2076
|
+
port: mqttConfig ? ((mqttConfig as { port?: number }).port ?? DEFAULT_MQTT_PORT) : undefined,
|
|
2077
|
+
host: mqttConfig ? ((mqttConfig as { host?: string }).host ?? "127.0.0.1") : undefined,
|
|
2078
|
+
embedded: mqttConfig ? ((mqttConfig as { embedded?: boolean }).embedded ?? true) : false,
|
|
2079
|
+
},
|
|
2080
|
+
opcua: {
|
|
2081
|
+
port: opcuaConfig
|
|
2082
|
+
? ((opcuaConfig as { port?: number }).port ?? DEFAULT_OPCUA_PORT)
|
|
2083
|
+
: undefined,
|
|
2084
|
+
host: opcuaConfig ? ((opcuaConfig as { host?: string }).host ?? "0.0.0.0") : undefined,
|
|
2085
|
+
},
|
|
2086
|
+
modbus: {
|
|
2087
|
+
port: modbusConfig
|
|
2088
|
+
? ((modbusConfig as { port?: number }).port ?? DEFAULT_MODBUS_PORT)
|
|
2089
|
+
: undefined,
|
|
2090
|
+
host: modbusConfig ? ((modbusConfig as { host?: string }).host ?? "0.0.0.0") : undefined,
|
|
2091
|
+
},
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1872
2095
|
async function reconcileProtocolBootPlan(engine: Engine, plan: ProtocolBootPlan): Promise<void> {
|
|
1873
2096
|
const adapter = engine.getAvailableAdapters().find((candidate) => candidate.name === plan.name);
|
|
1874
2097
|
const isEnabled = adapter?.enabled === true;
|
|
@@ -1921,6 +2144,13 @@ async function reconcileProtocolBootPlan(engine: Engine, plan: ProtocolBootPlan)
|
|
|
1921
2144
|
return;
|
|
1922
2145
|
}
|
|
1923
2146
|
|
|
2147
|
+
if (plan.name === "modbus") {
|
|
2148
|
+
modbusLog.ready(
|
|
2149
|
+
`server ready · endpoint: ${colors.cyan(String((result.data as Record<string, unknown>)?.["endpoint"] ?? ""))}`,
|
|
2150
|
+
);
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
1924
2154
|
opcuaLog.ready(
|
|
1925
2155
|
`server ready · endpoint: ${colors.cyan(String((result.data as Record<string, unknown>)?.["endpoint"] ?? ""))}`,
|
|
1926
2156
|
);
|
package/src/lib/shutdown.ts
CHANGED
|
@@ -2,7 +2,15 @@ import type { Engine, EngineMetricsSnapshot } from "@miravo/core";
|
|
|
2
2
|
import type { AdapterHealthSnapshot } from "@miravo/shared";
|
|
3
3
|
import type { TreeItem } from "consola/utils";
|
|
4
4
|
import { box, colors, formatTree, stripAnsi } from "consola/utils";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
engineLog,
|
|
7
|
+
isJsonLogFormat,
|
|
8
|
+
isPlain,
|
|
9
|
+
isQuiet,
|
|
10
|
+
modbusLog,
|
|
11
|
+
mqttLog,
|
|
12
|
+
opcuaLog,
|
|
13
|
+
} from "../logger.ts";
|
|
6
14
|
import { computeAverageThroughput, formatCount, formatDuration } from "../output.ts";
|
|
7
15
|
|
|
8
16
|
const SHUTDOWN_TASK_TIMEOUT_MS = 6_000;
|
|
@@ -261,11 +269,13 @@ function healthStateLabel(state: AdapterHealthSnapshot["state"]): string {
|
|
|
261
269
|
function adapterLogger(name: string) {
|
|
262
270
|
if (name === "mqtt") return mqttLog;
|
|
263
271
|
if (name === "opcua") return opcuaLog;
|
|
272
|
+
if (name === "modbus") return modbusLog;
|
|
264
273
|
return engineLog;
|
|
265
274
|
}
|
|
266
275
|
|
|
267
276
|
function adapterLabel(name: string): string {
|
|
268
277
|
if (name === "opcua") return "OPC UA";
|
|
278
|
+
if (name === "modbus") return "Modbus";
|
|
269
279
|
return name.toUpperCase();
|
|
270
280
|
}
|
|
271
281
|
|
|
@@ -281,7 +291,7 @@ function formatAdapterLine(
|
|
|
281
291
|
: colors.yellow(formatCount(m.errorsCount));
|
|
282
292
|
const avgThroughput = computeAverageThroughput(m.messagesPublished, uptimeMs);
|
|
283
293
|
const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
|
|
284
|
-
const verb = name === "opcua" ? "updates" : "published";
|
|
294
|
+
const verb = name === "opcua" || name === "modbus" ? "updates" : "published";
|
|
285
295
|
const health = m.health ? `${sep}${healthStateLabel(m.health.state)}` : "";
|
|
286
296
|
return `${connStr}${sep}${formatCount(m.messagesPublished)} ${verb}${sep}${errStr} errors${throughputStr}${health}`;
|
|
287
297
|
}
|
|
@@ -351,7 +361,7 @@ function buildSummaryLines(input: SummaryInput): string[] {
|
|
|
351
361
|
: colors.yellow(formatCount(m.errorsCount));
|
|
352
362
|
const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
|
|
353
363
|
const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
|
|
354
|
-
const verb = adapter.name === "opcua" ? "updates" : "published";
|
|
364
|
+
const verb = adapter.name === "opcua" || adapter.name === "modbus" ? "updates" : "published";
|
|
355
365
|
lines.push(
|
|
356
366
|
`${label}: ${connStr}${sep}${colors.bold(formatCount(m.messagesPublished))} ${verb}${sep}${errStr} errors${throughputStr}`,
|
|
357
367
|
);
|