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.
@@ -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-hUJSpKtB.js"></script>
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.4.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.4.0",
36
- "@miravo/mqtt": "0.4.0",
37
- "@miravo/opcua": "0.4.0",
38
- "@miravo/shared": "0.4.0",
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");
@@ -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. 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.
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: options.mqttPort,
468
- mqttHost: options.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.mqttPort) ports.set("mqtt", opts.mqttPort);
795
- if (opts.mqttEnabled && !opts.mqttPort) ports.set("mqtt", DEFAULT_MQTT_PORT);
796
- if (opts.opcuaEnabled && opts.opcuaPort) ports.set("opcua", opts.opcuaPort);
797
- 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);
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
  );
@@ -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
  );