miravo 0.3.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/README.md +14 -236
- package/console-dist/assets/index-C1oOHU1J.js +119 -0
- package/console-dist/assets/index-C3XMYq26.css +2 -0
- package/console-dist/favicon.svg +31 -14
- package/console-dist/index.html +2 -2
- package/console-dist/miravo-logo.png +0 -0
- package/package.json +8 -7
- package/src/__tests__/integration/executable-methods-runner.ts +1 -0
- package/src/__tests__/integration/executable-methods.integration.ts +45 -39
- package/src/commands/inject.test.ts +0 -24
- package/src/commands/models.test.ts +1 -1
- package/src/commands/models.ts +6 -0
- package/src/commands/start.test.ts +122 -0
- package/src/commands/start.ts +310 -12
- package/src/commands/status.test.ts +0 -26
- package/src/commands/templates.test.ts +0 -13
- package/src/lib/shutdown.ts +13 -3
- package/src/logger.ts +21 -4
- package/console-dist/assets/index-BXtW73cF.css +0 -2
- package/console-dist/assets/index-ChSocZFW.js +0 -119
package/src/commands/start.ts
CHANGED
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
discoverTemplates,
|
|
10
10
|
loadTemplate,
|
|
11
11
|
loadTwinModel,
|
|
12
|
+
resolveMiravoHome,
|
|
12
13
|
resolveModelPath,
|
|
13
14
|
validateFullTemplate,
|
|
14
15
|
} from "@miravo/core";
|
|
16
|
+
import { ModbusAdapter } from "@miravo/modbus";
|
|
15
17
|
import { MQTTAdapter } from "@miravo/mqtt";
|
|
16
18
|
import { OPCUAAdapter } from "@miravo/opcua";
|
|
17
19
|
import type {
|
|
@@ -24,6 +26,7 @@ import type {
|
|
|
24
26
|
import {
|
|
25
27
|
ConfigNotFoundError,
|
|
26
28
|
ConfigValidationError,
|
|
29
|
+
DEFAULT_MODBUS_PORT,
|
|
27
30
|
DEFAULT_MQTT_PORT,
|
|
28
31
|
DEFAULT_OPCUA_PORT,
|
|
29
32
|
DEFAULT_TICK_INTERVAL_MS,
|
|
@@ -31,10 +34,12 @@ import {
|
|
|
31
34
|
MIRAVO_VERSION,
|
|
32
35
|
MiravoError,
|
|
33
36
|
MiravoTemplateSchema,
|
|
37
|
+
PersistedStateSchema,
|
|
34
38
|
SimulationNameSchema,
|
|
35
39
|
TWIN_FILE_EXTENSION,
|
|
36
40
|
TwinModelSchema,
|
|
37
41
|
} from "@miravo/shared";
|
|
42
|
+
import { Glob } from "bun";
|
|
38
43
|
import type { Command } from "commander";
|
|
39
44
|
import { box, colors, stripAnsi } from "consola/utils";
|
|
40
45
|
import { parse as parseYAML } from "yaml";
|
|
@@ -77,6 +82,7 @@ import {
|
|
|
77
82
|
handleCommandError,
|
|
78
83
|
isPlain,
|
|
79
84
|
lifecycleLog,
|
|
85
|
+
modbusLog,
|
|
80
86
|
mqttLog,
|
|
81
87
|
opcuaLog,
|
|
82
88
|
setRuntimeLogContext,
|
|
@@ -101,6 +107,9 @@ interface StartOptions {
|
|
|
101
107
|
readonly opcuaPort?: number;
|
|
102
108
|
readonly opcuaHost?: string;
|
|
103
109
|
readonly opcua?: boolean;
|
|
110
|
+
readonly modbusPort?: number;
|
|
111
|
+
readonly modbusHost?: string;
|
|
112
|
+
readonly modbus?: boolean;
|
|
104
113
|
readonly adminHost?: string;
|
|
105
114
|
readonly adminPort?: number;
|
|
106
115
|
readonly dryRun?: boolean;
|
|
@@ -118,7 +127,7 @@ interface RestoreSummary {
|
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
interface ProtocolBootPlan {
|
|
121
|
-
readonly name: "mqtt" | "opcua";
|
|
130
|
+
readonly name: "mqtt" | "opcua" | "modbus";
|
|
122
131
|
readonly shouldEnable: boolean;
|
|
123
132
|
readonly overrideRestore: boolean;
|
|
124
133
|
}
|
|
@@ -126,6 +135,7 @@ interface ProtocolBootPlan {
|
|
|
126
135
|
type ProtocolOptionSource =
|
|
127
136
|
| "mqtt"
|
|
128
137
|
| "opcua"
|
|
138
|
+
| "modbus"
|
|
129
139
|
| "mqttPort"
|
|
130
140
|
| "mqttHost"
|
|
131
141
|
| "mqttMode"
|
|
@@ -133,7 +143,9 @@ type ProtocolOptionSource =
|
|
|
133
143
|
| "mqttUsername"
|
|
134
144
|
| "mqttPassword"
|
|
135
145
|
| "opcuaPort"
|
|
136
|
-
| "opcuaHost"
|
|
146
|
+
| "opcuaHost"
|
|
147
|
+
| "modbusPort"
|
|
148
|
+
| "modbusHost";
|
|
137
149
|
|
|
138
150
|
type MqttMode = "embedded" | "external";
|
|
139
151
|
|
|
@@ -158,7 +170,12 @@ function resolveConsoleDir(): string | undefined {
|
|
|
158
170
|
// @miravo/console not installed (npm/bunx) — fall through
|
|
159
171
|
}
|
|
160
172
|
|
|
161
|
-
// 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.
|
|
162
179
|
// start.ts is at packages/cli/src/commands/ → ../../console-dist/
|
|
163
180
|
const shipped = resolve(import.meta.dir, "..", "..", "console-dist");
|
|
164
181
|
if (existsSync(`${shipped}/index.html`)) return shipped;
|
|
@@ -215,6 +232,13 @@ export function registerStartCommand(program: Command): void {
|
|
|
215
232
|
parsePort,
|
|
216
233
|
)
|
|
217
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)")
|
|
218
242
|
.option("--admin-host <host>", "Admin server host (default: 127.0.0.1, env: MIRAVO_ADMIN_HOST)")
|
|
219
243
|
.option(
|
|
220
244
|
"--admin-port <number>",
|
|
@@ -274,6 +298,7 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
274
298
|
// Protocol selection: CLI flags decide which protocols run
|
|
275
299
|
const mqttEnabled = options.mqtt !== false; // --no-mqtt disables
|
|
276
300
|
const opcuaEnabled = options.opcua === true; // --opcua enables
|
|
301
|
+
const modbusEnabled = options.modbus === true; // --modbus enables
|
|
277
302
|
|
|
278
303
|
// Warn on ignored protocol-specific flags
|
|
279
304
|
if (!mqttEnabled) {
|
|
@@ -299,6 +324,18 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
299
324
|
);
|
|
300
325
|
}
|
|
301
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
|
+
}
|
|
302
339
|
|
|
303
340
|
const adminHost = options.adminHost ?? Bun.env["MIRAVO_ADMIN_HOST"] ?? "127.0.0.1";
|
|
304
341
|
const adminPortEnv = Bun.env["MIRAVO_ADMIN_PORT"];
|
|
@@ -389,6 +426,30 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
389
426
|
}
|
|
390
427
|
}
|
|
391
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
|
+
|
|
392
453
|
let adminPort = options.adminPort;
|
|
393
454
|
if (adminPort === undefined && adminPortEnv) {
|
|
394
455
|
try {
|
|
@@ -403,7 +464,29 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
403
464
|
adminPort ??= 8080;
|
|
404
465
|
|
|
405
466
|
// Step 1: Resolve template path(s)
|
|
406
|
-
|
|
467
|
+
// If the simulation name is explicitly known and no template was given,
|
|
468
|
+
// peek at persisted state to recover the template path — skipping the
|
|
469
|
+
// interactive picker on resume.
|
|
470
|
+
let effectiveTemplateOption = templateOption;
|
|
471
|
+
if (!effectiveTemplateOption && !options.resetState) {
|
|
472
|
+
const knownName = options.name ?? simulationNameEnv;
|
|
473
|
+
if (knownName) {
|
|
474
|
+
// Named: peek by explicit simulation name
|
|
475
|
+
const peeked = await peekStateTemplatePath(knownName);
|
|
476
|
+
if (peeked) {
|
|
477
|
+
effectiveTemplateOption = peeked;
|
|
478
|
+
configLog.debug(`Resuming from persisted state: template=${peeked}`);
|
|
479
|
+
}
|
|
480
|
+
} else {
|
|
481
|
+
// Unnamed: scan state dir — auto-resume if exactly one simulation exists
|
|
482
|
+
const peeked = await peekSingleStateTemplatePath();
|
|
483
|
+
if (peeked) {
|
|
484
|
+
effectiveTemplateOption = peeked;
|
|
485
|
+
configLog.debug(`Resuming from persisted state: template=${peeked}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const templatePath = await resolveTemplatePath(effectiveTemplateOption, catalog);
|
|
407
490
|
configLog.debug(`Resolved template: ${templatePath}`);
|
|
408
491
|
// Resolve any additional comma-separated templates upfront so errors surface before engine start
|
|
409
492
|
const extraTemplatePaths = await Promise.all(
|
|
@@ -432,6 +515,27 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
432
515
|
|
|
433
516
|
// Step 3b: Detach check — spawn background process and exit
|
|
434
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
|
+
|
|
435
539
|
await detachAction({
|
|
436
540
|
simulationName,
|
|
437
541
|
templatePath,
|
|
@@ -439,12 +543,16 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
439
543
|
seedExplicit,
|
|
440
544
|
adminHost,
|
|
441
545
|
adminPort,
|
|
442
|
-
mqttPort:
|
|
443
|
-
mqttHost:
|
|
546
|
+
mqttPort: detachAdapterConfigs.mqtt.port,
|
|
547
|
+
mqttHost: detachAdapterConfigs.mqtt.host,
|
|
548
|
+
mqttEmbedded: detachAdapterConfigs.mqtt.embedded,
|
|
444
549
|
mqttEnabled,
|
|
445
|
-
opcuaPort,
|
|
446
|
-
opcuaHost,
|
|
550
|
+
opcuaPort: detachAdapterConfigs.opcua.port,
|
|
551
|
+
opcuaHost: detachAdapterConfigs.opcua.host,
|
|
447
552
|
opcuaEnabled,
|
|
553
|
+
modbusPort: detachAdapterConfigs.modbus.port,
|
|
554
|
+
modbusHost: detachAdapterConfigs.modbus.host,
|
|
555
|
+
modbusEnabled,
|
|
448
556
|
});
|
|
449
557
|
return;
|
|
450
558
|
}
|
|
@@ -565,6 +673,17 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
565
673
|
opcuaDefaultConfig,
|
|
566
674
|
);
|
|
567
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
|
+
|
|
568
687
|
// Step 5b: Wire control plane (dynamically follows MQTT adapter lifecycle)
|
|
569
688
|
function wireControlPlane(): void {
|
|
570
689
|
// Stop old control plane before creating a new one (prevents listener leaks)
|
|
@@ -669,6 +788,8 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
669
788
|
availableAdapters.find((a) => a.name === "mqtt" && a.enabled)?.endpoint ?? null;
|
|
670
789
|
const opcuaEndpoint =
|
|
671
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;
|
|
672
793
|
printStartupBanner(
|
|
673
794
|
templatePath,
|
|
674
795
|
eng,
|
|
@@ -676,6 +797,7 @@ async function startAction(options: StartOptions): Promise<void> {
|
|
|
676
797
|
seedExplicit,
|
|
677
798
|
mqttEndpoint,
|
|
678
799
|
opcuaEndpoint,
|
|
800
|
+
modbusEndpoint,
|
|
679
801
|
simulationName,
|
|
680
802
|
runId,
|
|
681
803
|
adminServer.endpoint,
|
|
@@ -729,10 +851,30 @@ interface DetachOptions {
|
|
|
729
851
|
readonly adminPort: number;
|
|
730
852
|
readonly mqttPort: number | undefined;
|
|
731
853
|
readonly mqttHost: string | undefined;
|
|
854
|
+
readonly mqttEmbedded: boolean;
|
|
732
855
|
readonly mqttEnabled: boolean;
|
|
733
856
|
readonly opcuaPort: number | undefined;
|
|
734
857
|
readonly opcuaHost: string | undefined;
|
|
735
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
|
+
};
|
|
736
878
|
}
|
|
737
879
|
|
|
738
880
|
async function detachAction(opts: DetachOptions): Promise<void> {
|
|
@@ -766,10 +908,11 @@ async function detachActionLocked(
|
|
|
766
908
|
// 2. Check port conflicts
|
|
767
909
|
const ports = new Map<string, number>();
|
|
768
910
|
ports.set("admin", adminPort);
|
|
769
|
-
if (opts.mqttEnabled && opts.
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
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);
|
|
773
916
|
|
|
774
917
|
const conflicts = await checkPortConflicts(ports);
|
|
775
918
|
if (conflicts.length > 0) {
|
|
@@ -827,6 +970,10 @@ async function detachActionLocked(
|
|
|
827
970
|
const opcuaPort = opts.opcuaPort ?? DEFAULT_OPCUA_PORT;
|
|
828
971
|
adapters["opcua"] = { port: opcuaPort, host: opts.opcuaHost ?? "0.0.0.0" };
|
|
829
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
|
+
}
|
|
830
977
|
|
|
831
978
|
const entry: DaemonRegistryEntry = {
|
|
832
979
|
name: simulationName,
|
|
@@ -930,6 +1077,8 @@ function printDetachSummary(entry: DaemonRegistryEntry): void {
|
|
|
930
1077
|
lines.push(` MQTT mqtt://${host}:${adapter.port}`);
|
|
931
1078
|
} else if (name === "opcua") {
|
|
932
1079
|
lines.push(` OPC UA opc.tcp://${host}:${adapter.port}`);
|
|
1080
|
+
} else if (name === "modbus") {
|
|
1081
|
+
lines.push(` Modbus modbus://${host}:${adapter.port}`);
|
|
933
1082
|
} else {
|
|
934
1083
|
lines.push(` ${name} ${host}:${adapter.port}`);
|
|
935
1084
|
}
|
|
@@ -946,6 +1095,47 @@ function printDetachSummary(entry: DaemonRegistryEntry): void {
|
|
|
946
1095
|
// Step 1: Template resolution
|
|
947
1096
|
// ---------------------------------------------------------------------------
|
|
948
1097
|
|
|
1098
|
+
async function peekStateTemplatePath(simulationName: string): Promise<string | undefined> {
|
|
1099
|
+
const stateDir = `${resolveMiravoHome()}/state`;
|
|
1100
|
+
const file = Bun.file(`${stateDir}/${simulationName}.json`);
|
|
1101
|
+
if (!(await file.exists())) return undefined;
|
|
1102
|
+
try {
|
|
1103
|
+
const parsed = JSON.parse(await file.text()) as unknown;
|
|
1104
|
+
const result = PersistedStateSchema.safeParse(parsed);
|
|
1105
|
+
if (!result.success) return undefined;
|
|
1106
|
+
return result.data.templateRuns.find((r) => r.templatePath.trim().length > 0)?.templatePath;
|
|
1107
|
+
} catch {
|
|
1108
|
+
return undefined;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
async function peekSingleStateTemplatePath(): Promise<string | undefined> {
|
|
1113
|
+
const stateDir = `${resolveMiravoHome()}/state`;
|
|
1114
|
+
let bestTemplatePath: string | undefined;
|
|
1115
|
+
let bestSavedAt = "";
|
|
1116
|
+
try {
|
|
1117
|
+
const glob = new Glob("*.json");
|
|
1118
|
+
for await (const file of glob.scan({ cwd: stateDir, onlyFiles: true })) {
|
|
1119
|
+
try {
|
|
1120
|
+
const parsed = JSON.parse(await Bun.file(`${stateDir}/${file}`).text()) as unknown;
|
|
1121
|
+
const result = PersistedStateSchema.safeParse(parsed);
|
|
1122
|
+
if (!result.success) continue;
|
|
1123
|
+
const templatePath = result.data.templateRuns.find(
|
|
1124
|
+
(r) => r.templatePath.trim().length > 0,
|
|
1125
|
+
)?.templatePath;
|
|
1126
|
+
if (!templatePath) continue;
|
|
1127
|
+
if (result.data.savedAt > bestSavedAt) {
|
|
1128
|
+
bestSavedAt = result.data.savedAt;
|
|
1129
|
+
bestTemplatePath = templatePath;
|
|
1130
|
+
}
|
|
1131
|
+
} catch {}
|
|
1132
|
+
}
|
|
1133
|
+
} catch {
|
|
1134
|
+
return undefined; // state dir doesn't exist yet (first-ever run)
|
|
1135
|
+
}
|
|
1136
|
+
return bestTemplatePath;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
949
1139
|
async function resolveTemplatePath(
|
|
950
1140
|
templateOption: string | undefined,
|
|
951
1141
|
catalog: ContentCatalog,
|
|
@@ -1217,6 +1407,9 @@ function createAndPopulateEngine(
|
|
|
1217
1407
|
mqttPassword: getCommandOptionSource(options.command, "mqttPassword"),
|
|
1218
1408
|
opcuaPort: getCommandOptionSource(options.command, "opcuaPort"),
|
|
1219
1409
|
opcuaHost: getCommandOptionSource(options.command, "opcuaHost"),
|
|
1410
|
+
modbus: getCommandOptionSource(options.command, "modbus"),
|
|
1411
|
+
modbusPort: getCommandOptionSource(options.command, "modbusPort"),
|
|
1412
|
+
modbusHost: getCommandOptionSource(options.command, "modbusHost"),
|
|
1220
1413
|
},
|
|
1221
1414
|
restoredFromPersistence,
|
|
1222
1415
|
});
|
|
@@ -1251,6 +1444,7 @@ function createAndPopulateEngine(
|
|
|
1251
1444
|
variation: twin.variation,
|
|
1252
1445
|
unsBasePath,
|
|
1253
1446
|
...(twin.parameters !== undefined ? { parameterOverrides: twin.parameters } : {}),
|
|
1447
|
+
...(twin.starting_hours !== undefined ? { startingHours: twin.starting_hours } : {}),
|
|
1254
1448
|
templateRunId: bootRunId,
|
|
1255
1449
|
});
|
|
1256
1450
|
for (const node of spawned) {
|
|
@@ -1357,6 +1551,7 @@ function createRuntimeTemplateLoader(
|
|
|
1357
1551
|
variation: twin.variation,
|
|
1358
1552
|
unsBasePath,
|
|
1359
1553
|
...(twin.parameters !== undefined ? { parameterOverrides: twin.parameters } : {}),
|
|
1554
|
+
...(twin.starting_hours !== undefined ? { startingHours: twin.starting_hours } : {}),
|
|
1360
1555
|
templateRunId: runId,
|
|
1361
1556
|
});
|
|
1362
1557
|
for (const node of spawned) {
|
|
@@ -1565,6 +1760,23 @@ function buildOPCUAEndpoint(config: import("@miravo/shared").ProtocolConfig): st
|
|
|
1565
1760
|
return `opc.tcp://${displayHost}:${port}`;
|
|
1566
1761
|
}
|
|
1567
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
|
+
|
|
1568
1780
|
// ---------------------------------------------------------------------------
|
|
1569
1781
|
// Step 6: Startup banner
|
|
1570
1782
|
// ---------------------------------------------------------------------------
|
|
@@ -1576,6 +1788,7 @@ function printStartupBanner(
|
|
|
1576
1788
|
seedExplicit: boolean,
|
|
1577
1789
|
mqttEndpoint: string | null,
|
|
1578
1790
|
opcuaEndpoint: string | null,
|
|
1791
|
+
modbusEndpoint: string | null,
|
|
1579
1792
|
simulationName: string,
|
|
1580
1793
|
runId: string,
|
|
1581
1794
|
adminEndpoint: string,
|
|
@@ -1594,6 +1807,7 @@ function printStartupBanner(
|
|
|
1594
1807
|
|
|
1595
1808
|
const mqttValue = mqttEndpoint ? colors.cyan(mqttEndpoint) : colors.dim("disabled");
|
|
1596
1809
|
const opcuaValue = opcuaEndpoint ? colors.cyan(opcuaEndpoint) : colors.dim("disabled");
|
|
1810
|
+
const modbusValue = modbusEndpoint ? colors.cyan(modbusEndpoint) : colors.dim("disabled");
|
|
1597
1811
|
|
|
1598
1812
|
const allEntries: ReadonlyArray<readonly [string, string]> = [
|
|
1599
1813
|
[colors.dim("Simulation"), colors.bold(simulationName)],
|
|
@@ -1603,6 +1817,7 @@ function printStartupBanner(
|
|
|
1603
1817
|
[colors.dim("Instances"), colors.bold(String(state.instances.length))],
|
|
1604
1818
|
[colors.dim("MQTT"), mqttValue],
|
|
1605
1819
|
[colors.dim("OPC UA"), opcuaValue],
|
|
1820
|
+
[colors.dim("Modbus"), modbusValue],
|
|
1606
1821
|
[colors.dim("Admin"), colors.cyan(adminEndpoint)],
|
|
1607
1822
|
[colors.dim("Tick interval"), colors.bold(`${state.config.tickIntervalMs}ms`)],
|
|
1608
1823
|
[colors.dim("Time multiplier"), colors.bold(`${state.config.timeMultiplier}x`)],
|
|
@@ -1781,6 +1996,12 @@ export function resolveProtocolBootPlans(input: {
|
|
|
1781
1996
|
(option) => input.optionSources[option as ProtocolOptionSource] === "cli",
|
|
1782
1997
|
);
|
|
1783
1998
|
|
|
1999
|
+
const modbusConfigExplicit =
|
|
2000
|
+
input.options.modbus === true &&
|
|
2001
|
+
["modbusPort", "modbusHost"].some(
|
|
2002
|
+
(option) => input.optionSources[option as ProtocolOptionSource] === "cli",
|
|
2003
|
+
);
|
|
2004
|
+
|
|
1784
2005
|
return [
|
|
1785
2006
|
{
|
|
1786
2007
|
name: "mqtt",
|
|
@@ -1798,9 +2019,79 @@ export function resolveProtocolBootPlans(input: {
|
|
|
1798
2019
|
input.optionSources["opcua"] === "cli" ||
|
|
1799
2020
|
opcuaConfigExplicit,
|
|
1800
2021
|
},
|
|
2022
|
+
{
|
|
2023
|
+
name: "modbus",
|
|
2024
|
+
shouldEnable: input.options.modbus === true,
|
|
2025
|
+
overrideRestore:
|
|
2026
|
+
!input.restoredFromPersistence ||
|
|
2027
|
+
input.optionSources["modbus"] === "cli" ||
|
|
2028
|
+
modbusConfigExplicit,
|
|
2029
|
+
},
|
|
1801
2030
|
];
|
|
1802
2031
|
}
|
|
1803
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
|
+
|
|
1804
2095
|
async function reconcileProtocolBootPlan(engine: Engine, plan: ProtocolBootPlan): Promise<void> {
|
|
1805
2096
|
const adapter = engine.getAvailableAdapters().find((candidate) => candidate.name === plan.name);
|
|
1806
2097
|
const isEnabled = adapter?.enabled === true;
|
|
@@ -1853,6 +2144,13 @@ async function reconcileProtocolBootPlan(engine: Engine, plan: ProtocolBootPlan)
|
|
|
1853
2144
|
return;
|
|
1854
2145
|
}
|
|
1855
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
|
+
|
|
1856
2154
|
opcuaLog.ready(
|
|
1857
2155
|
`server ready · endpoint: ${colors.cyan(String((result.data as Record<string, unknown>)?.["endpoint"] ?? ""))}`,
|
|
1858
2156
|
);
|
|
@@ -5,32 +5,6 @@ beforeEach(resetExitCode);
|
|
|
5
5
|
afterEach(resetExitCode);
|
|
6
6
|
|
|
7
7
|
describe("miravo status", () => {
|
|
8
|
-
test("command is registered with correct name and options", async () => {
|
|
9
|
-
const { cmd: statusCmd } = await findCliCommand("status");
|
|
10
|
-
expect(statusCmd).toBeDefined();
|
|
11
|
-
expect(statusCmd?.description()).toBe(
|
|
12
|
-
"Show live status, metrics, and instances of a running simulation",
|
|
13
|
-
);
|
|
14
|
-
|
|
15
|
-
const optionNames = statusCmd?.options.map((o) => o.long);
|
|
16
|
-
expect(optionNames).toContain("--name");
|
|
17
|
-
expect(optionNames).toContain("--discover");
|
|
18
|
-
expect(optionNames).toContain("--watch");
|
|
19
|
-
expect(optionNames).toContain("--instance");
|
|
20
|
-
expect(optionNames).toContain("--json");
|
|
21
|
-
expect(optionNames).toContain("--admin-url");
|
|
22
|
-
expect(optionNames).toContain("--template");
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("--watch has short alias -w", async () => {
|
|
26
|
-
const { cmd: statusCmd } = await findCliCommand("status");
|
|
27
|
-
expect(statusCmd).toBeDefined();
|
|
28
|
-
|
|
29
|
-
const watchOpt = statusCmd?.options.find((o) => o.long === "--watch");
|
|
30
|
-
expect(watchOpt).toBeDefined();
|
|
31
|
-
expect(watchOpt?.short).toBe("-w");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
8
|
test("sets exit code 1 with invalid admin URL", async () => {
|
|
35
9
|
const { program } = await findCliCommand("status");
|
|
36
10
|
program.exitOverride();
|
|
@@ -41,19 +41,6 @@ describe("formatTemplateInspect", () => {
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
describe("miravo templates", () => {
|
|
44
|
-
test("command is registered with list and inspect subcommands", async () => {
|
|
45
|
-
const { createProgram } = await import("../index.ts");
|
|
46
|
-
const program = createProgram();
|
|
47
|
-
|
|
48
|
-
const templatesCmd = program.commands.find((c) => c.name() === "templates");
|
|
49
|
-
expect(templatesCmd).toBeDefined();
|
|
50
|
-
expect(templatesCmd?.description()).toBe("List and inspect Miravo templates");
|
|
51
|
-
|
|
52
|
-
const subcommandNames = templatesCmd?.commands.map((c) => c.name()) ?? [];
|
|
53
|
-
expect(subcommandNames).toContain("list");
|
|
54
|
-
expect(subcommandNames).toContain("inspect");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
44
|
test("templates inspect nonexistent template sets exit code 1", async () => {
|
|
58
45
|
await runCliCommand(["templates", "inspect", "nonexistent-template"]);
|
|
59
46
|
expect(process.exitCode).toBe(1);
|
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
|
);
|