miravo 0.1.4 → 0.2.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.
@@ -12,11 +12,13 @@ import {
12
12
  validateFullTemplate,
13
13
  } from "@miravo/core";
14
14
  import { MQTTAdapter } from "@miravo/mqtt";
15
- import type { DigitalTwinModel, DiscoveredTemplate, MiravoTemplate } from "@miravo/shared";
15
+ import { OPCUAAdapter } from "@miravo/opcua";
16
+ import type { DiscoveredTemplate, MiravoTemplate, TwinModel } from "@miravo/shared";
16
17
  import {
17
18
  ConfigNotFoundError,
18
19
  ConfigValidationError,
19
20
  DEFAULT_MQTT_PORT,
21
+ DEFAULT_OPCUA_PORT,
20
22
  DEFAULT_TICK_INTERVAL_MS,
21
23
  DEFAULT_TIME_MULTIPLIER,
22
24
  MIRAVO_VERSION,
@@ -24,8 +26,8 @@ import {
24
26
  SimulationNameSchema,
25
27
  } from "@miravo/shared";
26
28
  import type { Command } from "commander";
27
- import { box, colors } from "consola/utils";
28
- import { startHealthServer } from "../lib/health-server.ts";
29
+ import { box, colors, stripAnsi } from "consola/utils";
30
+ import { startAdminServer } from "../lib/admin-server.ts";
29
31
  import {
30
32
  parseDurationMs,
31
33
  parseNonNegativeInt,
@@ -33,9 +35,10 @@ import {
33
35
  parsePositiveFloat,
34
36
  parsePositiveInt,
35
37
  } from "../lib/parse.ts";
36
- import { installShutdownHandlers } from "../lib/shutdown.ts";
38
+ import { installShutdownHandlers, printSummaryBox } from "../lib/shutdown.ts";
37
39
  import {
38
40
  groupTemplatesByCategory,
41
+ isStressBenchmarkTemplate,
39
42
  sortTemplatesByCategoryAndName,
40
43
  } from "../lib/template-category.ts";
41
44
  import {
@@ -53,27 +56,31 @@ import {
53
56
  isPlain,
54
57
  lifecycleLog,
55
58
  mqttLog,
59
+ opcuaLog,
56
60
  setRuntimeLogContext,
57
61
  writeOutput,
58
62
  } from "../logger.ts";
59
- import { formatCount, formatKeyValue, formatTable } from "../output.ts";
63
+ import { formatKeyValue, formatTable } from "../output.ts";
60
64
 
61
65
  interface StartOptions {
62
66
  readonly name?: string;
63
67
  readonly template?: string;
64
68
  readonly modelsDir?: string;
65
- readonly contentDir?: string;
66
69
  readonly speed?: number;
67
70
  readonly seed?: number;
68
71
  readonly tick?: number;
69
- readonly port?: number;
72
+ readonly mqttPort?: number;
70
73
  readonly mqttHost?: string;
71
74
  readonly mqttMode?: string;
72
75
  readonly mqttUrl?: string;
73
76
  readonly mqttUsername?: string;
74
77
  readonly mqttPassword?: string;
75
- readonly healthHost?: string;
76
- readonly healthPort?: number;
78
+ readonly mqtt?: boolean;
79
+ readonly opcuaPort?: number;
80
+ readonly opcuaHost?: string;
81
+ readonly opcua?: boolean;
82
+ readonly adminHost?: string;
83
+ readonly adminPort?: number;
77
84
  readonly dryRun?: boolean;
78
85
  readonly summaryInterval?: number;
79
86
  }
@@ -98,47 +105,41 @@ const ERROR_CONTEXT_OVERFLOW = "__overflow__";
98
105
  export function registerStartCommand(program: Command): void {
99
106
  program
100
107
  .command("start")
101
- .description("Start the digital twin simulation")
102
- .option("--name <name>", "Simulation name for control topics (env: MIRAVO_NAME)")
108
+ .description("Start the simulation engine")
109
+ .option("-n, --name <name>", "Simulation name for control topics (env: MIRAVO_NAME)")
103
110
  .option(
104
- "--template <id|name|path>",
111
+ "-t, --template <id|name|path>",
105
112
  "Template identifier, display name, or .miravo.yaml path (env: MIRAVO_TEMPLATE)",
106
113
  )
107
114
  .option("--models-dir <path>", "Additional directory to search for .twin.yaml models")
108
- .option(
109
- "--content-dir <path>",
110
- "User content root with /templates and /models (env: MIRAVO_CONTENT_DIR)",
111
- )
112
- .option("--speed <multiplier>", "Time multiplier override (default: 1)", parsePositiveFloat)
115
+ .option("-s, --speed <multiplier>", "Time multiplier (default: 1)", parsePositiveFloat)
113
116
  .option("--seed <number>", "RNG seed for deterministic simulation", parseNonNegativeInt)
117
+ .option("--tick <ms>", "Tick interval in milliseconds (default: 1000)", parseDurationMs)
114
118
  .option(
115
- "--tick <ms>",
116
- "Tick interval override in milliseconds (default: 1000)",
117
- parseDurationMs,
118
- )
119
- .option(
120
- "--port <number>",
121
- "MQTT broker port override (default: 1883, env: MIRAVO_MQTT_PORT)",
119
+ "-p, --mqtt-port <number>",
120
+ "MQTT broker port (default: 1883, env: MIRAVO_MQTT_PORT)",
122
121
  parsePort,
123
122
  )
124
- .option(
125
- "--mqtt-host <host>",
126
- "Embedded MQTT bind host override (default: 127.0.0.1, env: MIRAVO_MQTT_HOST)",
127
- )
128
- .option("--mqtt-mode <mode>", "MQTT mode override: embedded | external (env: MIRAVO_MQTT_MODE)")
123
+ .option("--mqtt-host <host>", "MQTT bind host (default: 127.0.0.1, env: MIRAVO_MQTT_HOST)")
124
+ .option("--mqtt-mode <mode>", "MQTT mode: embedded | external (env: MIRAVO_MQTT_MODE)")
129
125
  .option("--mqtt-url <url>", "External MQTT broker URL (env: MIRAVO_MQTT_URL)")
130
126
  .option("--mqtt-username <username>", "External MQTT username (env: MIRAVO_MQTT_USERNAME)")
131
127
  .option("--mqtt-password <password>", "External MQTT password (env: MIRAVO_MQTT_PASSWORD)")
128
+ .option("--no-mqtt", "Disable MQTT broker")
129
+ .option("--opcua", "Enable OPC UA server")
132
130
  .option(
133
- "--health-host <host>",
134
- "Health server host (default: 0.0.0.0, env: MIRAVO_HEALTH_HOST)",
131
+ "--opcua-port <number>",
132
+ "OPC UA server port (default: 4840, env: MIRAVO_OPCUA_PORT)",
133
+ parsePort,
135
134
  )
135
+ .option("--opcua-host <host>", "OPC UA server bind host (env: MIRAVO_OPCUA_HOST)")
136
+ .option("--admin-host <host>", "Admin server host (default: 127.0.0.1, env: MIRAVO_ADMIN_HOST)")
136
137
  .option(
137
- "--health-port <number>",
138
- "Health server port (default: 8080, env: MIRAVO_HEALTH_PORT)",
138
+ "--admin-port <number>",
139
+ "Admin server port (default: 8080, env: MIRAVO_ADMIN_PORT)",
139
140
  parsePort,
140
141
  )
141
- .option("--dry-run", "Validate config and print summary without starting")
142
+ .option("-d, --dry-run", "Validate config and print summary without starting")
142
143
  .option(
143
144
  "--summary-interval <seconds>",
144
145
  "Seconds between periodic summary lines (default: 5)",
@@ -155,62 +156,127 @@ export function registerStartCommand(program: Command): void {
155
156
 
156
157
  async function startAction(options: StartOptions): Promise<void> {
157
158
  let runtimeLogContextSet = false;
159
+ /** Adapter stop functions accumulated during setup. Drained on partial startup failure. */
160
+ const pendingAdapterStops: Array<() => Promise<void>> = [];
158
161
  try {
159
- const catalog = createCatalog(options.contentDir);
162
+ const catalog = createCatalog();
160
163
 
161
164
  // Resolve env-var fallbacks for common options
162
165
  const templateOption = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
163
166
  const simulationNameEnv = Bun.env["MIRAVO_NAME"];
164
- const portEnv = Bun.env["MIRAVO_MQTT_PORT"];
165
- const mqttHost = options.mqttHost ?? Bun.env["MIRAVO_MQTT_HOST"];
166
- const mqttMode = parseMqttModeOption(options.mqttMode ?? Bun.env["MIRAVO_MQTT_MODE"]);
167
- const mqttUrl = options.mqttUrl ?? Bun.env["MIRAVO_MQTT_URL"];
168
- const mqttUsername = options.mqttUsername ?? Bun.env["MIRAVO_MQTT_USERNAME"];
169
- const mqttPassword = options.mqttPassword ?? Bun.env["MIRAVO_MQTT_PASSWORD"];
170
- const healthHost = options.healthHost ?? Bun.env["MIRAVO_HEALTH_HOST"] ?? "0.0.0.0";
171
- const healthPortEnv = Bun.env["MIRAVO_HEALTH_PORT"];
172
- const mqttOverrides: MqttRunOverrides = {
173
- ...(mqttMode !== undefined ? { mode: mqttMode } : {}),
174
- ...(mqttHost !== undefined && mqttHost.length > 0 ? { host: mqttHost } : {}),
175
- ...(mqttUrl !== undefined && mqttUrl.length > 0 ? { url: mqttUrl } : {}),
176
- ...(mqttUsername !== undefined && mqttUsername.length > 0 ? { username: mqttUsername } : {}),
177
- ...(mqttPassword !== undefined && mqttPassword.length > 0 ? { password: mqttPassword } : {}),
178
- };
179
- let portOption = options.port;
180
- if (portOption === undefined && portEnv) {
181
- try {
182
- portOption = parsePort(portEnv);
183
- } catch {
184
- throw new ConfigValidationError({
185
- message: `Invalid MIRAVO_MQTT_PORT environment variable: "${portEnv}".`,
186
- fix: "Set MIRAVO_MQTT_PORT to a valid port number between 1 and 65535 (for example, 1883).",
187
- });
167
+ // Protocol selection: CLI flags decide which protocols run
168
+ const mqttEnabled = options.mqtt !== false; // --no-mqtt disables
169
+ const opcuaEnabled = options.opcua === true; // --opcua enables
170
+
171
+ // Warn on ignored protocol-specific flags
172
+ if (!mqttEnabled) {
173
+ if (options.mqttPort !== undefined) {
174
+ configLog.warn("--mqtt-port ignored because MQTT is disabled.");
175
+ }
176
+ if (options.mqttHost !== undefined) {
177
+ configLog.warn("--mqtt-host ignored because MQTT is disabled.");
178
+ }
179
+ if (options.mqttMode !== undefined) {
180
+ configLog.warn("--mqtt-mode ignored because MQTT is disabled.");
188
181
  }
189
182
  }
190
- if (healthHost.trim().length === 0) {
191
- throw new ConfigValidationError({
192
- message: "Invalid health server host: value is empty.",
193
- fix: "Set --health-host / MIRAVO_HEALTH_HOST to a valid host (for example, 0.0.0.0).",
194
- });
183
+ if (!opcuaEnabled) {
184
+ if (options.opcuaPort !== undefined) {
185
+ configLog.warn(
186
+ "--opcua-port ignored because OPC UA is not enabled. Use --opcua to enable it.",
187
+ );
188
+ }
189
+ if (options.opcuaHost !== undefined) {
190
+ configLog.warn(
191
+ "--opcua-host ignored because OPC UA is not enabled. Use --opcua to enable it.",
192
+ );
193
+ }
195
194
  }
196
- if (mqttHost !== undefined && mqttHost.trim().length === 0) {
195
+
196
+ const adminHost = options.adminHost ?? Bun.env["MIRAVO_ADMIN_HOST"] ?? "127.0.0.1";
197
+ const adminPortEnv = Bun.env["MIRAVO_ADMIN_PORT"];
198
+ if (adminHost.trim().length === 0) {
197
199
  throw new ConfigValidationError({
198
- message: "Invalid MQTT bind host: value is empty.",
199
- fix: "Set --mqtt-host / MIRAVO_MQTT_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
200
+ message: "Invalid admin server host: value is empty.",
201
+ fix: "Set --admin-host / MIRAVO_ADMIN_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
200
202
  });
201
203
  }
202
- let healthPort = options.healthPort;
203
- if (healthPort === undefined && healthPortEnv) {
204
+
205
+ // MQTT env overrides — only parsed when MQTT is enabled
206
+ let portOption = options.mqttPort;
207
+ let mqttOverrides: MqttRunOverrides = {};
208
+ if (mqttEnabled) {
209
+ const portEnv = Bun.env["MIRAVO_MQTT_PORT"];
210
+ const mqttHost = options.mqttHost ?? Bun.env["MIRAVO_MQTT_HOST"];
211
+ const mqttMode = parseMqttModeOption(options.mqttMode ?? Bun.env["MIRAVO_MQTT_MODE"]);
212
+ const mqttUrl = options.mqttUrl ?? Bun.env["MIRAVO_MQTT_URL"];
213
+ const mqttUsername = options.mqttUsername ?? Bun.env["MIRAVO_MQTT_USERNAME"];
214
+ const mqttPassword = options.mqttPassword ?? Bun.env["MIRAVO_MQTT_PASSWORD"];
215
+ mqttOverrides = {
216
+ ...(mqttMode !== undefined ? { mode: mqttMode } : {}),
217
+ ...(mqttHost !== undefined && mqttHost.length > 0 ? { host: mqttHost } : {}),
218
+ ...(mqttUrl !== undefined && mqttUrl.length > 0 ? { url: mqttUrl } : {}),
219
+ ...(mqttUsername !== undefined && mqttUsername.length > 0
220
+ ? { username: mqttUsername }
221
+ : {}),
222
+ ...(mqttPassword !== undefined && mqttPassword.length > 0
223
+ ? { password: mqttPassword }
224
+ : {}),
225
+ };
226
+ if (portOption === undefined && portEnv) {
227
+ try {
228
+ portOption = parsePort(portEnv);
229
+ } catch {
230
+ throw new ConfigValidationError({
231
+ message: `Invalid MIRAVO_MQTT_PORT environment variable: "${portEnv}".`,
232
+ fix: "Set MIRAVO_MQTT_PORT to a valid port number between 1 and 65535 (for example, 1883).",
233
+ });
234
+ }
235
+ }
236
+ if (mqttHost !== undefined && mqttHost.trim().length === 0) {
237
+ throw new ConfigValidationError({
238
+ message: "Invalid MQTT bind host: value is empty.",
239
+ fix: "Set --mqtt-host / MIRAVO_MQTT_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
240
+ });
241
+ }
242
+ }
243
+
244
+ // OPC UA env overrides — only parsed when OPC UA is enabled
245
+ let opcuaPort: number | undefined = options.opcuaPort;
246
+ let opcuaHost: string | undefined;
247
+ if (opcuaEnabled) {
248
+ const opcuaPortEnv = Bun.env["MIRAVO_OPCUA_PORT"];
249
+ if (opcuaPort === undefined && opcuaPortEnv) {
250
+ try {
251
+ opcuaPort = parsePort(opcuaPortEnv);
252
+ } catch {
253
+ throw new ConfigValidationError({
254
+ message: `Invalid MIRAVO_OPCUA_PORT environment variable: "${opcuaPortEnv}".`,
255
+ fix: "Set MIRAVO_OPCUA_PORT to a valid port number between 1 and 65535 (for example, 4840).",
256
+ });
257
+ }
258
+ }
259
+ opcuaHost = options.opcuaHost ?? Bun.env["MIRAVO_OPCUA_HOST"] ?? undefined;
260
+ if (opcuaHost !== undefined && opcuaHost.trim().length === 0) {
261
+ throw new ConfigValidationError({
262
+ message: "Invalid OPC UA bind host: value is empty.",
263
+ fix: "Set --opcua-host / MIRAVO_OPCUA_HOST to a valid host (for example, 0.0.0.0).",
264
+ });
265
+ }
266
+ }
267
+
268
+ let adminPort = options.adminPort;
269
+ if (adminPort === undefined && adminPortEnv) {
204
270
  try {
205
- healthPort = parsePort(healthPortEnv);
271
+ adminPort = parsePort(adminPortEnv);
206
272
  } catch {
207
273
  throw new ConfigValidationError({
208
- message: `Invalid MIRAVO_HEALTH_PORT environment variable: "${healthPortEnv}".`,
209
- fix: "Set MIRAVO_HEALTH_PORT to a valid port number between 1 and 65535 (for example, 8080).",
274
+ message: `Invalid MIRAVO_ADMIN_PORT environment variable: "${adminPortEnv}".`,
275
+ fix: "Set MIRAVO_ADMIN_PORT to a valid port number between 1 and 65535 (for example, 8080).",
210
276
  });
211
277
  }
212
278
  }
213
- healthPort ??= 8080;
279
+ adminPort ??= 8080;
214
280
 
215
281
  // Step 1: Resolve template path
216
282
  const templatePath = await resolveTemplatePath(templateOption, catalog);
@@ -251,15 +317,18 @@ async function startAction(options: StartOptions): Promise<void> {
251
317
  simulationName,
252
318
  );
253
319
 
254
- // Step 5: Start MQTT adapter
255
- const mqttStartResult = await startMQTTAdapter(
256
- engine,
257
- template,
258
- portOption,
259
- mqttOverrides,
260
- simulationName,
261
- );
320
+ // Step 5: Start MQTT adapter (unless --no-mqtt)
321
+ const mqttStartResult = mqttEnabled
322
+ ? await startMQTTAdapter(engine, template, portOption, mqttOverrides, simulationName)
323
+ : null;
262
324
  const adapter = mqttStartResult?.adapter ?? null;
325
+ if (adapter) pendingAdapterStops.push(() => adapter.stop());
326
+
327
+ // Step 5a: Start OPC UA adapter (only when --opcua)
328
+ const opcuaResult = opcuaEnabled
329
+ ? await startOPCUAAdapter(engine, template, opcuaPort, opcuaHost, simulationName)
330
+ : null;
331
+ if (opcuaResult) pendingAdapterStops.push(() => opcuaResult.adapter.stop());
263
332
 
264
333
  // Step 5b: Wire control plane
265
334
  let controlPlane: ControlPlane | null = null;
@@ -273,33 +342,36 @@ async function startAction(options: StartOptions): Promise<void> {
273
342
  bus: engine.bus,
274
343
  });
275
344
  controlPlane.start();
276
- controlLog.info("Control plane active on $miravo/* topics");
345
+ controlLog.info(`active on $miravo/${simulationName}/* topics`);
277
346
  }
278
347
  }
279
348
 
280
- // Step 5c: Start health probe server
281
- let healthServer: { endpoint: string; stop: () => void } | null = null;
282
- let lastTickProgressAt = Date.now();
349
+ // Step 5c: Start admin HTTP server
350
+ let adminServer: { endpoint: string; stop: () => void } | null = null;
351
+ let lastTickProgressAt = performance.now();
283
352
  const onTickProgress = (): void => {
284
- lastTickProgressAt = Date.now();
353
+ lastTickProgressAt = performance.now();
285
354
  };
286
355
  engine.bus.on("tick:complete", onTickProgress);
287
356
  try {
288
- healthServer = startHealthServer({
357
+ adminServer = startAdminServer({
289
358
  engine,
290
- host: healthHost,
291
- port: healthPort,
359
+ host: adminHost,
360
+ port: adminPort,
361
+ tickIntervalMs: engine.getState().config.tickIntervalMs,
292
362
  mqttEnabled: adapter !== null,
293
363
  isMqttConnected: () => (adapter ? adapter.getMetrics().connected : false),
364
+ opcuaEnabled: opcuaResult !== null,
365
+ isOpcuaConnected: () => (opcuaResult ? opcuaResult.adapter.getMetrics().connected : false),
294
366
  getLastTickProgressAt: () => lastTickProgressAt,
295
367
  });
296
- controlLog.info(`Health server active on ${healthServer.endpoint}`);
368
+ controlLog.info(`admin server ready · endpoint: ${colors.cyan(adminServer.endpoint)}`);
297
369
  } catch (error) {
298
370
  engine.bus.off("tick:complete", onTickProgress);
299
371
  const message = error instanceof Error ? error.message : String(error);
300
372
  throw new MiravoError(
301
- `Health server failed to start on ${healthHost}:${healthPort}: ${message}`,
302
- "Set --health-port / MIRAVO_HEALTH_PORT to a free port, or change --health-host.",
373
+ `Admin server failed to start on ${adminHost}:${adminPort}: ${message}`,
374
+ "Set --admin-port / MIRAVO_ADMIN_PORT to a free port, or change --admin-host.",
303
375
  );
304
376
  }
305
377
 
@@ -310,9 +382,10 @@ async function startAction(options: StartOptions): Promise<void> {
310
382
  seed,
311
383
  seedExplicit,
312
384
  mqttStartResult?.endpoint ?? null,
385
+ opcuaResult?.endpoint ?? null,
313
386
  simulationName,
314
387
  runId,
315
- healthServer.endpoint,
388
+ adminServer.endpoint,
316
389
  );
317
390
 
318
391
  // Step 7: Subscribe to events + periodic summary
@@ -328,7 +401,7 @@ async function startAction(options: StartOptions): Promise<void> {
328
401
  seedExplicit,
329
402
  cleanup: () => {
330
403
  controlPlane?.stop();
331
- healthServer?.stop();
404
+ adminServer?.stop();
332
405
  engine.bus.off("tick:complete", onTickProgress);
333
406
  clearInterval(summaryTimer);
334
407
  unsubscribeEvents();
@@ -343,7 +416,7 @@ async function startAction(options: StartOptions): Promise<void> {
343
416
  } catch (error) {
344
417
  teardownShutdown();
345
418
  controlPlane?.stop();
346
- healthServer?.stop();
419
+ adminServer?.stop();
347
420
  engine.bus.off("tick:complete", onTickProgress);
348
421
  clearInterval(summaryTimer);
349
422
  unsubscribeEvents();
@@ -351,8 +424,12 @@ async function startAction(options: StartOptions): Promise<void> {
351
424
  runtimeLogContextSet = false;
352
425
  throw error;
353
426
  }
427
+ // Engine running — shutdown handlers now own adapter lifecycle
428
+ pendingAdapterStops.length = 0;
354
429
  engineLog.success("Simulation started.");
355
430
  } catch (error) {
431
+ // Stop adapters started before engine/shutdown handlers took ownership
432
+ await Promise.allSettled(pendingAdapterStops.map((fn) => fn()));
356
433
  if (runtimeLogContextSet) {
357
434
  clearRuntimeLogContext();
358
435
  runtimeLogContextSet = false;
@@ -404,9 +481,11 @@ async function resolveTemplatePath(
404
481
  const builtinTemplateDir = catalog.getBuiltinTemplatesDir();
405
482
  let builtinTemplates: readonly DiscoveredTemplate[] = [];
406
483
  try {
407
- builtinTemplates = await discoverTemplates(builtinTemplateDir);
484
+ const discovered = await discoverTemplates(builtinTemplateDir);
485
+ // Exclude stress/benchmark templates from built-in list (same as standalone binaries).
486
+ builtinTemplates = discovered.filter((t) => !isStressBenchmarkTemplate(t.id));
408
487
  } catch {
409
- // Builtin dir may not exist (e.g. MIRAVO_BUILTIN_DIR pointing to a missing path)
488
+ // Builtin dir may not exist in edge cases (e.g. standalone binary extraction failure)
410
489
  }
411
490
  if (builtinTemplates.length > 0) {
412
491
  if (process.stdout.isTTY && process.stdin.isTTY) {
@@ -495,7 +574,7 @@ async function loadAndValidateTemplate(
495
574
  catalog: ContentCatalog,
496
575
  ): Promise<{
497
576
  template: MiravoTemplate;
498
- models: Map<string, DigitalTwinModel>;
577
+ models: Map<string, TwinModel>;
499
578
  seed: number;
500
579
  seedExplicit: boolean;
501
580
  }> {
@@ -528,13 +607,10 @@ async function loadAndValidateTemplate(
528
607
  }
529
608
 
530
609
  // Build inline models map
531
- const models = new Map<string, DigitalTwinModel>();
610
+ const models = new Map<string, TwinModel>();
532
611
  if (template.models) {
533
612
  for (const inlineModel of template.models) {
534
- models.set(inlineModel.name, {
535
- ...inlineModel,
536
- version: inlineModel.version ?? "1",
537
- });
613
+ models.set(inlineModel.model.name, inlineModel);
538
614
  }
539
615
  }
540
616
 
@@ -605,7 +681,7 @@ export function resolveSimulationName(input: {
605
681
 
606
682
  function createAndPopulateEngine(
607
683
  template: MiravoTemplate,
608
- models: Map<string, DigitalTwinModel>,
684
+ models: Map<string, TwinModel>,
609
685
  seed: number,
610
686
  options: StartOptions,
611
687
  templatePath: string,
@@ -728,7 +804,9 @@ async function startMQTTAdapter(
728
804
  }
729
805
 
730
806
  const endpoint = embedded ? `mqtt://localhost:${port}` : (external as string);
731
- mqttLog.debug(`Starting MQTT adapter on ${endpoint}`);
807
+ mqttLog.debug(
808
+ `starting adapter · mode: ${embedded ? "embedded" : "external"} · endpoint: ${endpoint}`,
809
+ );
732
810
 
733
811
  const adapter = new MQTTAdapter(engine.bus);
734
812
  try {
@@ -745,24 +823,72 @@ async function startMQTTAdapter(
745
823
  ...(password !== undefined ? { password } : {}),
746
824
  ...(mqttConfig?.reconnect !== undefined ? { reconnect: mqttConfig.reconnect } : {}),
747
825
  ...(mqttConfig?.rbe !== undefined ? { rbe: mqttConfig.rbe } : {}),
826
+ ...(mqttConfig?.metadata !== undefined ? { metadata: mqttConfig.metadata } : {}),
748
827
  };
749
828
 
750
829
  await adapter.start({
751
830
  ...resolvedConfig,
752
831
  });
753
832
  engine.registerAdapter(adapter);
754
- mqttLog.ready(`MQTT connected: ${colors.cyan(endpoint)}`);
833
+ mqttLog.ready(`connected · broker: ${colors.cyan(endpoint)}`);
755
834
  return { adapter, endpoint };
756
835
  } catch (error) {
757
836
  const message = error instanceof Error ? error.message : String(error);
758
837
  const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
759
838
  const fix = isPortConflict
760
- ? `Port ${port} is in use. Use '--port ${port + 1}' or set protocols.mqtt.port in template.`
839
+ ? `Port ${port} is in use. Use '--mqtt-port ${port + 1}' or set protocols.mqtt.port in template.`
761
840
  : "Check the MQTT configuration in your template file.";
762
841
  throw new MiravoError(`MQTT adapter failed to start: ${message}`, fix);
763
842
  }
764
843
  }
765
844
 
845
+ // ---------------------------------------------------------------------------
846
+ // Step 5a: OPC UA adapter
847
+ // ---------------------------------------------------------------------------
848
+
849
+ async function startOPCUAAdapter(
850
+ engine: Engine,
851
+ template: MiravoTemplate,
852
+ portOverride: number | undefined,
853
+ hostOverride: string | undefined,
854
+ simulationName: string,
855
+ ): Promise<{ adapter: OPCUAAdapter; endpoint: string } | null> {
856
+ const opcuaConfig = template.protocols?.opcua;
857
+
858
+ const port = portOverride ?? opcuaConfig?.port ?? DEFAULT_OPCUA_PORT;
859
+ const host = hostOverride ?? opcuaConfig?.host ?? "0.0.0.0";
860
+
861
+ opcuaLog.debug(`starting adapter · endpoint: opc.tcp://${host}:${port}`);
862
+
863
+ const opcuaAdapter = new OPCUAAdapter(engine.bus, engine);
864
+
865
+ try {
866
+ const resolvedConfig = {
867
+ name: "opcua" as const,
868
+ enabled: true,
869
+ port,
870
+ host,
871
+ security: opcuaConfig?.security ?? "none",
872
+ simulationName,
873
+ ...(opcuaConfig?.buildInfo ? { buildInfo: opcuaConfig.buildInfo } : {}),
874
+ };
875
+
876
+ await opcuaAdapter.start(resolvedConfig);
877
+ engine.registerAdapter(opcuaAdapter);
878
+
879
+ const endpoint = `opc.tcp://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
880
+ opcuaLog.ready(`server ready · endpoint: ${colors.cyan(endpoint)}`);
881
+ return { adapter: opcuaAdapter, endpoint };
882
+ } catch (error) {
883
+ const message = error instanceof Error ? error.message : String(error);
884
+ const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
885
+ const fix = isPortConflict
886
+ ? `Port ${port} is in use. Use '--opcua-port ${port + 1}'.`
887
+ : "Check the OPC UA port/network settings and retry.";
888
+ throw new MiravoError(`OPC UA adapter failed to start: ${message}`, fix);
889
+ }
890
+ }
891
+
766
892
  // ---------------------------------------------------------------------------
767
893
  // Step 6: Startup banner
768
894
  // ---------------------------------------------------------------------------
@@ -773,32 +899,55 @@ function printStartupBanner(
773
899
  seed: number,
774
900
  seedExplicit: boolean,
775
901
  mqttEndpoint: string | null,
902
+ opcuaEndpoint: string | null,
776
903
  simulationName: string,
777
904
  runId: string,
778
- healthEndpoint: string,
905
+ adminEndpoint: string,
779
906
  ): void {
780
907
  const state = engine.getState();
781
- const modelSummary =
782
- state.models
783
- .map((m) => `${colors.bold(m.name)} ${colors.dim(`(${m.sensorCount} sensors)`)}`)
784
- .join(", ") || colors.dim("(none)");
908
+
909
+ // Build model summary: one model per line for readability
910
+ const modelLines = state.models.map(
911
+ (m) => `${colors.bold(m.name)} ${colors.dim(`(${m.memberCount} members)`)}`,
912
+ );
913
+ const modelCount = `${colors.bold(String(state.models.length))} ${colors.dim("models")}`;
785
914
 
786
915
  const seedValue = seedExplicit
787
916
  ? colors.bold(String(seed))
788
917
  : `${colors.bold(String(seed))} ${colors.gray("(random — add seed to config to reproduce)")}`;
789
918
 
790
- const content = formatKeyValue([
919
+ const mqttValue = mqttEndpoint ? colors.cyan(mqttEndpoint) : colors.dim("disabled");
920
+ const opcuaValue = opcuaEndpoint ? colors.cyan(opcuaEndpoint) : colors.dim("disabled");
921
+
922
+ const allEntries: ReadonlyArray<readonly [string, string]> = [
791
923
  [colors.dim("Simulation"), colors.bold(simulationName)],
792
924
  [colors.dim("Run ID"), colors.bold(runId)],
793
925
  [colors.dim("Template"), templatePath],
794
- [colors.dim("Models"), modelSummary],
926
+ [colors.dim("Models"), modelCount],
795
927
  [colors.dim("Instances"), colors.bold(String(state.instances.length))],
796
- [colors.dim("MQTT"), mqttEndpoint ? colors.cyan(mqttEndpoint) : colors.dim("disabled")],
797
- [colors.dim("Health"), colors.cyan(`${healthEndpoint}/healthz`)],
928
+ [colors.dim("MQTT"), mqttValue],
929
+ [colors.dim("OPC UA"), opcuaValue],
930
+ [colors.dim("Admin"), colors.cyan(adminEndpoint)],
798
931
  [colors.dim("Tick interval"), colors.bold(`${state.config.tickIntervalMs}ms`)],
799
932
  [colors.dim("Time multiplier"), colors.bold(`${state.config.timeMultiplier}x`)],
800
933
  [colors.dim("Seed"), seedValue],
801
- ]);
934
+ ];
935
+
936
+ const kvBlock = formatKeyValue(allEntries);
937
+
938
+ // Compute the value column offset used by formatKeyValue so model detail
939
+ // lines align with the value of the "Models" row.
940
+ const maxKeyWidth = Math.max(...allEntries.map(([key]) => stripAnsi(key).length));
941
+ const valueIndent = 4 + maxKeyWidth + 3; // " " + key + ":" + padding(2) + " "
942
+
943
+ // Inject per-model detail lines right after the "Models" row
944
+ const kvLines = kvBlock.split("\n");
945
+ const modelsIdx = kvLines.findIndex((line) => stripAnsi(line).includes("Models:"));
946
+ if (modelsIdx !== -1 && modelLines.length > 0) {
947
+ const detailLines = modelLines.map((line) => `${"".padEnd(valueIndent)}${line}`);
948
+ kvLines.splice(modelsIdx + 1, 0, ...detailLines);
949
+ }
950
+ const content = kvLines.join("\n");
802
951
 
803
952
  const header = `${colors.cyan(colors.bold("Miravo"))} ${colors.dim(`v${MIRAVO_VERSION}`)}`;
804
953
 
@@ -856,7 +1005,11 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
856
1005
  };
857
1006
 
858
1007
  const onFaultCleared = (event: { instanceId: string; fault: string }): void => {
859
- faultLog.success(`${event.instanceId} ${event.fault} cleared`);
1008
+ faultLog.debug(`${event.instanceId} ${event.fault} cleared`);
1009
+ };
1010
+
1011
+ const onWarn = (event: { message: string; context: string }): void => {
1012
+ engineLog.warn(event.message, { context: event.context });
860
1013
  };
861
1014
 
862
1015
  const onError = (event: { context: string; error: Error }): void => {
@@ -875,12 +1028,14 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
875
1028
  engine.bus.on("lifecycle:changed", onLifecycle);
876
1029
  engine.bus.on("fault:triggered", onFaultTriggered);
877
1030
  engine.bus.on("fault:cleared", onFaultCleared);
1031
+ engine.bus.on("engine:warn", onWarn);
878
1032
  engine.bus.on("engine:error", onError);
879
1033
 
880
1034
  return () => {
881
1035
  engine.bus.off("lifecycle:changed", onLifecycle);
882
1036
  engine.bus.off("fault:triggered", onFaultTriggered);
883
1037
  engine.bus.off("fault:cleared", onFaultCleared);
1038
+ engine.bus.off("engine:warn", onWarn);
884
1039
  engine.bus.off("engine:error", onError);
885
1040
  };
886
1041
  }
@@ -913,29 +1068,7 @@ function startPeriodicSummary(
913
1068
  ): ReturnType<typeof setInterval> {
914
1069
  return setInterval(() => {
915
1070
  const metrics = engine.getMetrics();
916
- const { core } = metrics;
917
- const mqttAdapter = metrics.adapters.find((a) => a.name === "mqtt");
918
-
919
- const sep = colors.dim(" · ");
920
-
921
- let mqttStatus: string;
922
- if (mqttAdapter) {
923
- const errCount = mqttAdapter.metrics.errorsCount;
924
- const errStr =
925
- errCount === 0 ? colors.green(formatCount(errCount)) : colors.yellow(formatCount(errCount));
926
- mqttStatus = `MQTT: ${colors.bold(formatCount(mqttAdapter.metrics.messagesPublished))} published${sep}${errStr} errors`;
927
- } else {
928
- mqttStatus = colors.dim("MQTT: not connected");
929
- }
930
-
931
- const tps =
932
- core.actualTicksPerSecond > 0
933
- ? `${colors.bold(core.actualTicksPerSecond.toFixed(1))} tps`
934
- : "";
935
-
936
- engineLog.info(
937
- `tick #${colors.bold(formatCount(core.tickCount))}${sep}${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.unsEntryCount))} UNS topics${sep}sim: ${colors.bold(core.simulationTimeSeconds.toFixed(1))}s${sep}${tps}${tps ? sep : ""}${mqttStatus}`,
938
- );
1071
+ printSummaryBox("Summary", { metrics }, "Periodic summary");
939
1072
  }, intervalSeconds * 1_000);
940
1073
  }
941
1074
 
@@ -945,7 +1078,7 @@ function startPeriodicSummary(
945
1078
 
946
1079
  function printDryRunSummary(
947
1080
  template: MiravoTemplate,
948
- models: Map<string, DigitalTwinModel>,
1081
+ models: Map<string, TwinModel>,
949
1082
  seed: number,
950
1083
  seedExplicit: boolean,
951
1084
  templatePath: string,
@@ -967,8 +1100,8 @@ function printDryRunSummary(
967
1100
 
968
1101
  // Model table
969
1102
  const modelRows = [...models.values()].map((m) => ({
970
- name: colors.cyan(m.name),
971
- sensors: String(m.sensors.length),
1103
+ name: colors.cyan(m.model.name),
1104
+ members: String(Object.keys(m.members).length),
972
1105
  faults: String(m.faults?.length ?? 0),
973
1106
  lifecycle: m.lifecycle ? `${m.lifecycle.stages.length} stages` : colors.dim("none"),
974
1107
  }));
@@ -980,7 +1113,7 @@ function printDryRunSummary(
980
1113
  formatTable(
981
1114
  {
982
1115
  name: colors.dim("Name"),
983
- sensors: colors.dim("Sensors"),
1116
+ members: colors.dim("Members"),
984
1117
  faults: colors.dim("Faults"),
985
1118
  lifecycle: colors.dim("Lifecycle"),
986
1119
  },
@@ -991,14 +1124,14 @@ function printDryRunSummary(
991
1124
 
992
1125
  // Instance summary
993
1126
  let totalInstances = 0;
994
- let totalSensors = 0;
1127
+ let totalMembers = 0;
995
1128
  for (const area of template.areas) {
996
1129
  for (const line of area.lines) {
997
1130
  for (const twin of line.twins) {
998
1131
  totalInstances += twin.count;
999
1132
  const model = models.get(twin.model);
1000
1133
  if (model) {
1001
- totalSensors += twin.count * model.sensors.length;
1134
+ totalMembers += twin.count * Object.keys(model.members).length;
1002
1135
  }
1003
1136
  }
1004
1137
  }
@@ -1008,8 +1141,7 @@ function printDryRunSummary(
1008
1141
  lines.push(
1009
1142
  formatKeyValue([
1010
1143
  [colors.dim("Total instances"), colors.bold(String(totalInstances))],
1011
- [colors.dim("Total sensors"), colors.bold(String(totalSensors))],
1012
- [colors.dim("Total UNS paths"), colors.bold(String(totalSensors))],
1144
+ [colors.dim("Total members"), colors.bold(String(totalMembers))],
1013
1145
  ]),
1014
1146
  );
1015
1147