miravo 0.1.4 → 0.2.1

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 { createShutdownController, printSummaryLine } 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,128 @@ export function registerStartCommand(program: Command): void {
155
156
 
156
157
  async function startAction(options: StartOptions): Promise<void> {
157
158
  let runtimeLogContextSet = false;
159
+ let shutdown: ReturnType<typeof createShutdownController> | null = null;
160
+ let engine: Engine | undefined;
161
+ let stopCliResources = (): void => {};
158
162
  try {
159
- const catalog = createCatalog(options.contentDir);
163
+ const catalog = createCatalog();
160
164
 
161
165
  // Resolve env-var fallbacks for common options
162
166
  const templateOption = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
163
167
  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
- });
168
+ // Protocol selection: CLI flags decide which protocols run
169
+ const mqttEnabled = options.mqtt !== false; // --no-mqtt disables
170
+ const opcuaEnabled = options.opcua === true; // --opcua enables
171
+
172
+ // Warn on ignored protocol-specific flags
173
+ if (!mqttEnabled) {
174
+ if (options.mqttPort !== undefined) {
175
+ configLog.warn("--mqtt-port ignored because MQTT is disabled.");
176
+ }
177
+ if (options.mqttHost !== undefined) {
178
+ configLog.warn("--mqtt-host ignored because MQTT is disabled.");
179
+ }
180
+ if (options.mqttMode !== undefined) {
181
+ configLog.warn("--mqtt-mode ignored because MQTT is disabled.");
188
182
  }
189
183
  }
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
- });
184
+ if (!opcuaEnabled) {
185
+ if (options.opcuaPort !== undefined) {
186
+ configLog.warn(
187
+ "--opcua-port ignored because OPC UA is not enabled. Use --opcua to enable it.",
188
+ );
189
+ }
190
+ if (options.opcuaHost !== undefined) {
191
+ configLog.warn(
192
+ "--opcua-host ignored because OPC UA is not enabled. Use --opcua to enable it.",
193
+ );
194
+ }
195
195
  }
196
- if (mqttHost !== undefined && mqttHost.trim().length === 0) {
196
+
197
+ const adminHost = options.adminHost ?? Bun.env["MIRAVO_ADMIN_HOST"] ?? "127.0.0.1";
198
+ const adminPortEnv = Bun.env["MIRAVO_ADMIN_PORT"];
199
+ if (adminHost.trim().length === 0) {
197
200
  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).",
201
+ message: "Invalid admin server host: value is empty.",
202
+ fix: "Set --admin-host / MIRAVO_ADMIN_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
200
203
  });
201
204
  }
202
- let healthPort = options.healthPort;
203
- if (healthPort === undefined && healthPortEnv) {
205
+
206
+ // MQTT env overrides — only parsed when MQTT is enabled
207
+ let portOption = options.mqttPort;
208
+ let mqttOverrides: MqttRunOverrides = {};
209
+ if (mqttEnabled) {
210
+ const portEnv = Bun.env["MIRAVO_MQTT_PORT"];
211
+ const mqttHost = options.mqttHost ?? Bun.env["MIRAVO_MQTT_HOST"];
212
+ const mqttMode = parseMqttModeOption(options.mqttMode ?? Bun.env["MIRAVO_MQTT_MODE"]);
213
+ const mqttUrl = options.mqttUrl ?? Bun.env["MIRAVO_MQTT_URL"];
214
+ const mqttUsername = options.mqttUsername ?? Bun.env["MIRAVO_MQTT_USERNAME"];
215
+ const mqttPassword = options.mqttPassword ?? Bun.env["MIRAVO_MQTT_PASSWORD"];
216
+ mqttOverrides = {
217
+ ...(mqttMode !== undefined ? { mode: mqttMode } : {}),
218
+ ...(mqttHost !== undefined && mqttHost.length > 0 ? { host: mqttHost } : {}),
219
+ ...(mqttUrl !== undefined && mqttUrl.length > 0 ? { url: mqttUrl } : {}),
220
+ ...(mqttUsername !== undefined && mqttUsername.length > 0
221
+ ? { username: mqttUsername }
222
+ : {}),
223
+ ...(mqttPassword !== undefined && mqttPassword.length > 0
224
+ ? { password: mqttPassword }
225
+ : {}),
226
+ };
227
+ if (portOption === undefined && portEnv) {
228
+ try {
229
+ portOption = parsePort(portEnv);
230
+ } catch {
231
+ throw new ConfigValidationError({
232
+ message: `Invalid MIRAVO_MQTT_PORT environment variable: "${portEnv}".`,
233
+ fix: "Set MIRAVO_MQTT_PORT to a valid port number between 1 and 65535 (for example, 1883).",
234
+ });
235
+ }
236
+ }
237
+ if (mqttHost !== undefined && mqttHost.trim().length === 0) {
238
+ throw new ConfigValidationError({
239
+ message: "Invalid MQTT bind host: value is empty.",
240
+ fix: "Set --mqtt-host / MIRAVO_MQTT_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
241
+ });
242
+ }
243
+ }
244
+
245
+ // OPC UA env overrides — only parsed when OPC UA is enabled
246
+ let opcuaPort: number | undefined = options.opcuaPort;
247
+ let opcuaHost: string | undefined;
248
+ if (opcuaEnabled) {
249
+ const opcuaPortEnv = Bun.env["MIRAVO_OPCUA_PORT"];
250
+ if (opcuaPort === undefined && opcuaPortEnv) {
251
+ try {
252
+ opcuaPort = parsePort(opcuaPortEnv);
253
+ } catch {
254
+ throw new ConfigValidationError({
255
+ message: `Invalid MIRAVO_OPCUA_PORT environment variable: "${opcuaPortEnv}".`,
256
+ fix: "Set MIRAVO_OPCUA_PORT to a valid port number between 1 and 65535 (for example, 4840).",
257
+ });
258
+ }
259
+ }
260
+ opcuaHost = options.opcuaHost ?? Bun.env["MIRAVO_OPCUA_HOST"] ?? undefined;
261
+ if (opcuaHost !== undefined && opcuaHost.trim().length === 0) {
262
+ throw new ConfigValidationError({
263
+ message: "Invalid OPC UA bind host: value is empty.",
264
+ fix: "Set --opcua-host / MIRAVO_OPCUA_HOST to a valid host (for example, 0.0.0.0).",
265
+ });
266
+ }
267
+ }
268
+
269
+ let adminPort = options.adminPort;
270
+ if (adminPort === undefined && adminPortEnv) {
204
271
  try {
205
- healthPort = parsePort(healthPortEnv);
272
+ adminPort = parsePort(adminPortEnv);
206
273
  } catch {
207
274
  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).",
275
+ message: `Invalid MIRAVO_ADMIN_PORT environment variable: "${adminPortEnv}".`,
276
+ fix: "Set MIRAVO_ADMIN_PORT to a valid port number between 1 and 65535 (for example, 8080).",
210
277
  });
211
278
  }
212
279
  }
213
- healthPort ??= 8080;
280
+ adminPort ??= 8080;
214
281
 
215
282
  // Step 1: Resolve template path
216
283
  const templatePath = await resolveTemplatePath(templateOption, catalog);
@@ -241,28 +308,60 @@ async function startAction(options: StartOptions): Promise<void> {
241
308
  runtimeLogContextSet = true;
242
309
  controlLog.info(`Run ID: ${runId}`);
243
310
 
244
- // Step 4: Create engine and populate
245
- const engine = createAndPopulateEngine(
246
- template,
247
- models,
311
+ shutdown = createShutdownController({
248
312
  seed,
249
- options,
250
- templatePath,
251
- simulationName,
252
- );
313
+ seedExplicit,
314
+ });
315
+ const shutdownRequested = shutdown.waitForShutdownRequest();
316
+ shutdown.addTask("finalize", "runtime log context", () => {
317
+ if (runtimeLogContextSet) {
318
+ clearRuntimeLogContext();
319
+ runtimeLogContextSet = false;
320
+ }
321
+ });
253
322
 
254
- // Step 5: Start MQTT adapter
255
- const mqttStartResult = await startMQTTAdapter(
256
- engine,
257
- template,
258
- portOption,
259
- mqttOverrides,
260
- simulationName,
261
- );
323
+ // Step 4: Create engine and populate
324
+ engine = createAndPopulateEngine(template, models, seed, options, templatePath, simulationName);
325
+ shutdown.setEngine(engine);
326
+
327
+ let controlPlane: ControlPlane | null = null;
328
+ let adminServer: { endpoint: string; stop: () => void } | null = null;
329
+ let lastTickProgressAt = performance.now();
330
+ let tickProgressSubscribed = false;
331
+ const onTickProgress = (): void => {
332
+ lastTickProgressAt = performance.now();
333
+ };
334
+ let unsubscribeEvents = (): void => {};
335
+ let summaryTimer: ReturnType<typeof setInterval> | null = null;
336
+
337
+ stopCliResources = (): void => {
338
+ controlPlane?.stop();
339
+ adminServer?.stop();
340
+ if (summaryTimer !== null) {
341
+ clearInterval(summaryTimer);
342
+ summaryTimer = null;
343
+ }
344
+ if (tickProgressSubscribed && engine) {
345
+ engine.bus.off("tick:complete", onTickProgress);
346
+ tickProgressSubscribed = false;
347
+ }
348
+ unsubscribeEvents();
349
+ };
350
+
351
+ shutdown.addTask("cleanup", "CLI resources", stopCliResources);
352
+
353
+ // Step 5: Start MQTT adapter (unless --no-mqtt)
354
+ const mqttStartResult = mqttEnabled
355
+ ? await startMQTTAdapter(engine, template, portOption, mqttOverrides, simulationName)
356
+ : null;
262
357
  const adapter = mqttStartResult?.adapter ?? null;
263
358
 
359
+ // Step 5a: Start OPC UA adapter (only when --opcua)
360
+ const opcuaResult = opcuaEnabled
361
+ ? await startOPCUAAdapter(engine, template, opcuaPort, opcuaHost, simulationName)
362
+ : null;
363
+
264
364
  // Step 5b: Wire control plane
265
- let controlPlane: ControlPlane | null = null;
266
365
  if (adapter) {
267
366
  const transport = adapter.getControlTransport();
268
367
  if (transport) {
@@ -273,33 +372,33 @@ async function startAction(options: StartOptions): Promise<void> {
273
372
  bus: engine.bus,
274
373
  });
275
374
  controlPlane.start();
276
- controlLog.info("Control plane active on $miravo/* topics");
375
+ controlLog.info(`active on $miravo/${simulationName}/* topics`);
277
376
  }
278
377
  }
279
378
 
280
- // Step 5c: Start health probe server
281
- let healthServer: { endpoint: string; stop: () => void } | null = null;
282
- let lastTickProgressAt = Date.now();
283
- const onTickProgress = (): void => {
284
- lastTickProgressAt = Date.now();
285
- };
379
+ // Step 5c: Start admin HTTP server
286
380
  engine.bus.on("tick:complete", onTickProgress);
381
+ tickProgressSubscribed = true;
287
382
  try {
288
- healthServer = startHealthServer({
383
+ adminServer = startAdminServer({
289
384
  engine,
290
- host: healthHost,
291
- port: healthPort,
385
+ host: adminHost,
386
+ port: adminPort,
387
+ tickIntervalMs: engine.getState().config.tickIntervalMs,
292
388
  mqttEnabled: adapter !== null,
293
389
  isMqttConnected: () => (adapter ? adapter.getMetrics().connected : false),
390
+ opcuaEnabled: opcuaResult !== null,
391
+ isOpcuaConnected: () => (opcuaResult ? opcuaResult.adapter.getMetrics().connected : false),
294
392
  getLastTickProgressAt: () => lastTickProgressAt,
295
393
  });
296
- controlLog.info(`Health server active on ${healthServer.endpoint}`);
394
+ controlLog.info(`admin server ready · endpoint: ${colors.cyan(adminServer.endpoint)}`);
297
395
  } catch (error) {
396
+ tickProgressSubscribed = false;
298
397
  engine.bus.off("tick:complete", onTickProgress);
299
398
  const message = error instanceof Error ? error.message : String(error);
300
399
  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.",
400
+ `Admin server failed to start on ${adminHost}:${adminPort}: ${message}`,
401
+ "Set --admin-port / MIRAVO_ADMIN_PORT to a free port, or change --admin-host.",
303
402
  );
304
403
  }
305
404
 
@@ -310,49 +409,38 @@ async function startAction(options: StartOptions): Promise<void> {
310
409
  seed,
311
410
  seedExplicit,
312
411
  mqttStartResult?.endpoint ?? null,
412
+ opcuaResult?.endpoint ?? null,
313
413
  simulationName,
314
414
  runId,
315
- healthServer.endpoint,
415
+ adminServer.endpoint,
316
416
  );
317
417
 
318
418
  // Step 7: Subscribe to events + periodic summary
319
419
  const errorContextCounts = new Map<string, number>();
320
- const unsubscribeEvents = subscribeToEvents(engine, errorContextCounts);
420
+ unsubscribeEvents = subscribeToEvents(engine, errorContextCounts);
421
+ shutdown.setErrorContextCounts(errorContextCounts);
321
422
  const summaryInterval = options.summaryInterval ?? 5;
322
- const summaryTimer = startPeriodicSummary(engine, summaryInterval);
423
+ summaryTimer = startPeriodicSummary(engine, summaryInterval);
323
424
 
324
- // Step 8: Install shutdown handler and start
325
- const teardownShutdown = installShutdownHandlers({
326
- engine,
327
- seed,
328
- seedExplicit,
329
- cleanup: () => {
330
- controlPlane?.stop();
331
- healthServer?.stop();
332
- engine.bus.off("tick:complete", onTickProgress);
333
- clearInterval(summaryTimer);
334
- unsubscribeEvents();
335
- clearRuntimeLogContext();
336
- runtimeLogContextSet = false;
337
- },
338
- errorContextCounts,
339
- });
340
-
341
- try {
342
- engine.start();
343
- } catch (error) {
344
- teardownShutdown();
345
- controlPlane?.stop();
346
- healthServer?.stop();
347
- engine.bus.off("tick:complete", onTickProgress);
348
- clearInterval(summaryTimer);
349
- unsubscribeEvents();
350
- clearRuntimeLogContext();
351
- runtimeLogContextSet = false;
352
- throw error;
353
- }
425
+ engine.start();
426
+ shutdown.markRunning();
354
427
  engineLog.success("Simulation started.");
428
+ await shutdownRequested;
429
+ await shutdown.runShutdown();
430
+ shutdown.teardown();
355
431
  } catch (error) {
432
+ if (shutdown?.isShuttingDown()) {
433
+ await shutdown.runShutdown();
434
+ shutdown.teardown();
435
+ return;
436
+ }
437
+ shutdown?.teardown();
438
+ stopCliResources();
439
+ try {
440
+ await engine?.stop("boot error");
441
+ } catch {
442
+ // engine may not exist yet or stop may fail — swallow
443
+ }
356
444
  if (runtimeLogContextSet) {
357
445
  clearRuntimeLogContext();
358
446
  runtimeLogContextSet = false;
@@ -404,9 +492,11 @@ async function resolveTemplatePath(
404
492
  const builtinTemplateDir = catalog.getBuiltinTemplatesDir();
405
493
  let builtinTemplates: readonly DiscoveredTemplate[] = [];
406
494
  try {
407
- builtinTemplates = await discoverTemplates(builtinTemplateDir);
495
+ const discovered = await discoverTemplates(builtinTemplateDir);
496
+ // Exclude stress/benchmark templates from built-in list (same as standalone binaries).
497
+ builtinTemplates = discovered.filter((t) => !isStressBenchmarkTemplate(t.id));
408
498
  } catch {
409
- // Builtin dir may not exist (e.g. MIRAVO_BUILTIN_DIR pointing to a missing path)
499
+ // Builtin dir may not exist in edge cases (e.g. standalone binary extraction failure)
410
500
  }
411
501
  if (builtinTemplates.length > 0) {
412
502
  if (process.stdout.isTTY && process.stdin.isTTY) {
@@ -495,7 +585,7 @@ async function loadAndValidateTemplate(
495
585
  catalog: ContentCatalog,
496
586
  ): Promise<{
497
587
  template: MiravoTemplate;
498
- models: Map<string, DigitalTwinModel>;
588
+ models: Map<string, TwinModel>;
499
589
  seed: number;
500
590
  seedExplicit: boolean;
501
591
  }> {
@@ -528,13 +618,10 @@ async function loadAndValidateTemplate(
528
618
  }
529
619
 
530
620
  // Build inline models map
531
- const models = new Map<string, DigitalTwinModel>();
621
+ const models = new Map<string, TwinModel>();
532
622
  if (template.models) {
533
623
  for (const inlineModel of template.models) {
534
- models.set(inlineModel.name, {
535
- ...inlineModel,
536
- version: inlineModel.version ?? "1",
537
- });
624
+ models.set(inlineModel.model.name, inlineModel);
538
625
  }
539
626
  }
540
627
 
@@ -605,7 +692,7 @@ export function resolveSimulationName(input: {
605
692
 
606
693
  function createAndPopulateEngine(
607
694
  template: MiravoTemplate,
608
- models: Map<string, DigitalTwinModel>,
695
+ models: Map<string, TwinModel>,
609
696
  seed: number,
610
697
  options: StartOptions,
611
698
  templatePath: string,
@@ -728,7 +815,9 @@ async function startMQTTAdapter(
728
815
  }
729
816
 
730
817
  const endpoint = embedded ? `mqtt://localhost:${port}` : (external as string);
731
- mqttLog.debug(`Starting MQTT adapter on ${endpoint}`);
818
+ mqttLog.debug(
819
+ `starting adapter · mode: ${embedded ? "embedded" : "external"} · endpoint: ${endpoint}`,
820
+ );
732
821
 
733
822
  const adapter = new MQTTAdapter(engine.bus);
734
823
  try {
@@ -745,24 +834,69 @@ async function startMQTTAdapter(
745
834
  ...(password !== undefined ? { password } : {}),
746
835
  ...(mqttConfig?.reconnect !== undefined ? { reconnect: mqttConfig.reconnect } : {}),
747
836
  ...(mqttConfig?.rbe !== undefined ? { rbe: mqttConfig.rbe } : {}),
837
+ ...(mqttConfig?.metadata !== undefined ? { metadata: mqttConfig.metadata } : {}),
748
838
  };
749
839
 
750
- await adapter.start({
751
- ...resolvedConfig,
752
- });
840
+ await adapter.start(resolvedConfig);
753
841
  engine.registerAdapter(adapter);
754
- mqttLog.ready(`MQTT connected: ${colors.cyan(endpoint)}`);
842
+ mqttLog.ready(`connected · broker: ${colors.cyan(endpoint)}`);
755
843
  return { adapter, endpoint };
756
844
  } catch (error) {
757
845
  const message = error instanceof Error ? error.message : String(error);
758
846
  const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
759
847
  const fix = isPortConflict
760
- ? `Port ${port} is in use. Use '--port ${port + 1}' or set protocols.mqtt.port in template.`
848
+ ? `Port ${port} is in use. Use '--mqtt-port ${port + 1}' or set protocols.mqtt.port in template.`
761
849
  : "Check the MQTT configuration in your template file.";
762
850
  throw new MiravoError(`MQTT adapter failed to start: ${message}`, fix);
763
851
  }
764
852
  }
765
853
 
854
+ // ---------------------------------------------------------------------------
855
+ // Step 5a: OPC UA adapter
856
+ // ---------------------------------------------------------------------------
857
+
858
+ async function startOPCUAAdapter(
859
+ engine: Engine,
860
+ template: MiravoTemplate,
861
+ portOverride: number | undefined,
862
+ hostOverride: string | undefined,
863
+ simulationName: string,
864
+ ): Promise<{ adapter: OPCUAAdapter; endpoint: string } | null> {
865
+ const opcuaConfig = template.protocols?.opcua;
866
+
867
+ const port = portOverride ?? opcuaConfig?.port ?? DEFAULT_OPCUA_PORT;
868
+ const host = hostOverride ?? opcuaConfig?.host ?? "0.0.0.0";
869
+
870
+ opcuaLog.debug(`starting adapter · endpoint: opc.tcp://${host}:${port}`);
871
+
872
+ const opcuaAdapter = new OPCUAAdapter(engine.bus, engine);
873
+ try {
874
+ const resolvedConfig = {
875
+ name: "opcua" as const,
876
+ enabled: true,
877
+ port,
878
+ host,
879
+ security: opcuaConfig?.security ?? "none",
880
+ simulationName,
881
+ ...(opcuaConfig?.buildInfo ? { buildInfo: opcuaConfig.buildInfo } : {}),
882
+ };
883
+
884
+ await opcuaAdapter.start(resolvedConfig);
885
+ engine.registerAdapter(opcuaAdapter);
886
+
887
+ const endpoint = `opc.tcp://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
888
+ opcuaLog.ready(`server ready · endpoint: ${colors.cyan(endpoint)}`);
889
+ return { adapter: opcuaAdapter, endpoint };
890
+ } catch (error) {
891
+ const message = error instanceof Error ? error.message : String(error);
892
+ const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
893
+ const fix = isPortConflict
894
+ ? `Port ${port} is in use. Use '--opcua-port ${port + 1}'.`
895
+ : "Check the OPC UA port/network settings and retry.";
896
+ throw new MiravoError(`OPC UA adapter failed to start: ${message}`, fix);
897
+ }
898
+ }
899
+
766
900
  // ---------------------------------------------------------------------------
767
901
  // Step 6: Startup banner
768
902
  // ---------------------------------------------------------------------------
@@ -773,32 +907,55 @@ function printStartupBanner(
773
907
  seed: number,
774
908
  seedExplicit: boolean,
775
909
  mqttEndpoint: string | null,
910
+ opcuaEndpoint: string | null,
776
911
  simulationName: string,
777
912
  runId: string,
778
- healthEndpoint: string,
913
+ adminEndpoint: string,
779
914
  ): void {
780
915
  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)");
916
+
917
+ // Build model summary: one model per line for readability
918
+ const modelLines = state.models.map(
919
+ (m) => `${colors.bold(m.name)} ${colors.dim(`(${m.memberCount} members)`)}`,
920
+ );
921
+ const modelCount = `${colors.bold(String(state.models.length))} ${colors.dim("models")}`;
785
922
 
786
923
  const seedValue = seedExplicit
787
924
  ? colors.bold(String(seed))
788
925
  : `${colors.bold(String(seed))} ${colors.gray("(random — add seed to config to reproduce)")}`;
789
926
 
790
- const content = formatKeyValue([
927
+ const mqttValue = mqttEndpoint ? colors.cyan(mqttEndpoint) : colors.dim("disabled");
928
+ const opcuaValue = opcuaEndpoint ? colors.cyan(opcuaEndpoint) : colors.dim("disabled");
929
+
930
+ const allEntries: ReadonlyArray<readonly [string, string]> = [
791
931
  [colors.dim("Simulation"), colors.bold(simulationName)],
792
932
  [colors.dim("Run ID"), colors.bold(runId)],
793
933
  [colors.dim("Template"), templatePath],
794
- [colors.dim("Models"), modelSummary],
934
+ [colors.dim("Models"), modelCount],
795
935
  [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`)],
936
+ [colors.dim("MQTT"), mqttValue],
937
+ [colors.dim("OPC UA"), opcuaValue],
938
+ [colors.dim("Admin"), colors.cyan(adminEndpoint)],
798
939
  [colors.dim("Tick interval"), colors.bold(`${state.config.tickIntervalMs}ms`)],
799
940
  [colors.dim("Time multiplier"), colors.bold(`${state.config.timeMultiplier}x`)],
800
941
  [colors.dim("Seed"), seedValue],
801
- ]);
942
+ ];
943
+
944
+ const kvBlock = formatKeyValue(allEntries);
945
+
946
+ // Compute the value column offset used by formatKeyValue so model detail
947
+ // lines align with the value of the "Models" row.
948
+ const maxKeyWidth = Math.max(...allEntries.map(([key]) => stripAnsi(key).length));
949
+ const valueIndent = 4 + maxKeyWidth + 3; // " " + key + ":" + padding(2) + " "
950
+
951
+ // Inject per-model detail lines right after the "Models" row
952
+ const kvLines = kvBlock.split("\n");
953
+ const modelsIdx = kvLines.findIndex((line) => stripAnsi(line).includes("Models:"));
954
+ if (modelsIdx !== -1 && modelLines.length > 0) {
955
+ const detailLines = modelLines.map((line) => `${"".padEnd(valueIndent)}${line}`);
956
+ kvLines.splice(modelsIdx + 1, 0, ...detailLines);
957
+ }
958
+ const content = kvLines.join("\n");
802
959
 
803
960
  const header = `${colors.cyan(colors.bold("Miravo"))} ${colors.dim(`v${MIRAVO_VERSION}`)}`;
804
961
 
@@ -856,7 +1013,11 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
856
1013
  };
857
1014
 
858
1015
  const onFaultCleared = (event: { instanceId: string; fault: string }): void => {
859
- faultLog.success(`${event.instanceId} ${event.fault} cleared`);
1016
+ faultLog.debug(`${event.instanceId} ${event.fault} cleared`);
1017
+ };
1018
+
1019
+ const onWarn = (event: { message: string; context: string }): void => {
1020
+ engineLog.debug(event.message, { context: event.context });
860
1021
  };
861
1022
 
862
1023
  const onError = (event: { context: string; error: Error }): void => {
@@ -875,12 +1036,14 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
875
1036
  engine.bus.on("lifecycle:changed", onLifecycle);
876
1037
  engine.bus.on("fault:triggered", onFaultTriggered);
877
1038
  engine.bus.on("fault:cleared", onFaultCleared);
1039
+ engine.bus.on("engine:warn", onWarn);
878
1040
  engine.bus.on("engine:error", onError);
879
1041
 
880
1042
  return () => {
881
1043
  engine.bus.off("lifecycle:changed", onLifecycle);
882
1044
  engine.bus.off("fault:triggered", onFaultTriggered);
883
1045
  engine.bus.off("fault:cleared", onFaultCleared);
1046
+ engine.bus.off("engine:warn", onWarn);
884
1047
  engine.bus.off("engine:error", onError);
885
1048
  };
886
1049
  }
@@ -913,29 +1076,7 @@ function startPeriodicSummary(
913
1076
  ): ReturnType<typeof setInterval> {
914
1077
  return setInterval(() => {
915
1078
  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
- );
1079
+ printSummaryLine(metrics);
939
1080
  }, intervalSeconds * 1_000);
940
1081
  }
941
1082
 
@@ -945,7 +1086,7 @@ function startPeriodicSummary(
945
1086
 
946
1087
  function printDryRunSummary(
947
1088
  template: MiravoTemplate,
948
- models: Map<string, DigitalTwinModel>,
1089
+ models: Map<string, TwinModel>,
949
1090
  seed: number,
950
1091
  seedExplicit: boolean,
951
1092
  templatePath: string,
@@ -967,8 +1108,8 @@ function printDryRunSummary(
967
1108
 
968
1109
  // Model table
969
1110
  const modelRows = [...models.values()].map((m) => ({
970
- name: colors.cyan(m.name),
971
- sensors: String(m.sensors.length),
1111
+ name: colors.cyan(m.model.name),
1112
+ members: String(Object.keys(m.members).length),
972
1113
  faults: String(m.faults?.length ?? 0),
973
1114
  lifecycle: m.lifecycle ? `${m.lifecycle.stages.length} stages` : colors.dim("none"),
974
1115
  }));
@@ -980,7 +1121,7 @@ function printDryRunSummary(
980
1121
  formatTable(
981
1122
  {
982
1123
  name: colors.dim("Name"),
983
- sensors: colors.dim("Sensors"),
1124
+ members: colors.dim("Members"),
984
1125
  faults: colors.dim("Faults"),
985
1126
  lifecycle: colors.dim("Lifecycle"),
986
1127
  },
@@ -991,14 +1132,14 @@ function printDryRunSummary(
991
1132
 
992
1133
  // Instance summary
993
1134
  let totalInstances = 0;
994
- let totalSensors = 0;
1135
+ let totalMembers = 0;
995
1136
  for (const area of template.areas) {
996
1137
  for (const line of area.lines) {
997
1138
  for (const twin of line.twins) {
998
1139
  totalInstances += twin.count;
999
1140
  const model = models.get(twin.model);
1000
1141
  if (model) {
1001
- totalSensors += twin.count * model.sensors.length;
1142
+ totalMembers += twin.count * Object.keys(model.members).length;
1002
1143
  }
1003
1144
  }
1004
1145
  }
@@ -1008,8 +1149,7 @@ function printDryRunSummary(
1008
1149
  lines.push(
1009
1150
  formatKeyValue([
1010
1151
  [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))],
1152
+ [colors.dim("Total members"), colors.bold(String(totalMembers))],
1013
1153
  ]),
1014
1154
  );
1015
1155