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.
@@ -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. npm install / bunx: look for console-dist/ shipped inside the published package.
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
- const templatePath = await resolveTemplatePath(templateOption, catalog);
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: options.mqttPort,
443
- mqttHost: options.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.mqttPort) ports.set("mqtt", opts.mqttPort);
770
- if (opts.mqttEnabled && !opts.mqttPort) ports.set("mqtt", DEFAULT_MQTT_PORT);
771
- if (opts.opcuaEnabled && opts.opcuaPort) ports.set("opcua", opts.opcuaPort);
772
- if (opts.opcuaEnabled && !opts.opcuaPort) ports.set("opcua", DEFAULT_OPCUA_PORT);
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);
@@ -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 { engineLog, isJsonLogFormat, isPlain, isQuiet, mqttLog, opcuaLog } from "../logger.ts";
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
  );