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.
- package/README.md +38 -32
- package/package.json +11 -5
- package/src/__tests__/integration/executable-methods-runner.ts +253 -0
- package/src/__tests__/integration/executable-methods.integration.ts +1688 -0
- package/src/commands/fork.ts +205 -0
- package/src/commands/inject.test.ts +6 -9
- package/src/commands/inject.ts +48 -158
- package/src/commands/models.test.ts +32 -8
- package/src/commands/models.ts +71 -45
- package/src/commands/start.test.ts +32 -63
- package/src/commands/start.ts +281 -149
- package/src/commands/status.test.ts +3 -22
- package/src/commands/status.ts +114 -279
- package/src/commands/templates.test.ts +4 -50
- package/src/commands/templates.ts +12 -191
- package/src/index.ts +16 -6
- package/src/lib/admin-client.ts +214 -0
- package/src/lib/admin-server.test.ts +276 -0
- package/src/lib/admin-server.ts +307 -0
- package/src/lib/shutdown.test.ts +2 -2
- package/src/lib/shutdown.ts +218 -35
- package/src/lib/template-category.test.ts +14 -7
- package/src/lib/template-category.ts +6 -1
- package/src/lib/template-locator.ts +5 -8
- package/src/lib/template-utils.ts +14 -0
- package/src/logger.test.ts +2 -2
- package/src/logger.ts +68 -15
- package/src/output.ts +41 -1
- package/src/lib/health-server.test.ts +0 -161
- package/src/lib/health-server.ts +0 -154
package/src/commands/start.ts
CHANGED
|
@@ -12,11 +12,13 @@ import {
|
|
|
12
12
|
validateFullTemplate,
|
|
13
13
|
} from "@miravo/core";
|
|
14
14
|
import { MQTTAdapter } from "@miravo/mqtt";
|
|
15
|
-
import
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
76
|
-
readonly
|
|
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
|
|
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
|
-
"--
|
|
116
|
-
"
|
|
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
|
-
|
|
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
|
-
"--
|
|
134
|
-
"
|
|
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
|
-
"--
|
|
138
|
-
"
|
|
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(
|
|
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
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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 (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
fix: "Set --
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
271
|
+
adminPort = parsePort(adminPortEnv);
|
|
206
272
|
} catch {
|
|
207
273
|
throw new ConfigValidationError({
|
|
208
|
-
message: `Invalid
|
|
209
|
-
fix: "Set
|
|
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
|
-
|
|
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 =
|
|
256
|
-
engine,
|
|
257
|
-
|
|
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(
|
|
345
|
+
controlLog.info(`active on $miravo/${simulationName}/* topics`);
|
|
277
346
|
}
|
|
278
347
|
}
|
|
279
348
|
|
|
280
|
-
// Step 5c: Start
|
|
281
|
-
let
|
|
282
|
-
let lastTickProgressAt =
|
|
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 =
|
|
353
|
+
lastTickProgressAt = performance.now();
|
|
285
354
|
};
|
|
286
355
|
engine.bus.on("tick:complete", onTickProgress);
|
|
287
356
|
try {
|
|
288
|
-
|
|
357
|
+
adminServer = startAdminServer({
|
|
289
358
|
engine,
|
|
290
|
-
host:
|
|
291
|
-
port:
|
|
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(`
|
|
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
|
-
`
|
|
302
|
-
"Set --
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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(`
|
|
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
|
-
|
|
905
|
+
adminEndpoint: string,
|
|
779
906
|
): void {
|
|
780
907
|
const state = engine.getState();
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
|
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"),
|
|
926
|
+
[colors.dim("Models"), modelCount],
|
|
795
927
|
[colors.dim("Instances"), colors.bold(String(state.instances.length))],
|
|
796
|
-
[colors.dim("MQTT"),
|
|
797
|
-
[colors.dim("
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|