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.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Miravo
2
2
 
3
- Digital Twin Engine & Unified Namespace Simulator for Industrial IoT.
3
+ Industrial environment platform. Canonical asset models, multi-protocol output, structured plant topology.
4
4
 
5
- Miravo generates realistic industrial sensor data — correlated signals, lifecycle degradation, cascading faults — and publishes it to a fully structured **Unified Namespace** following the ISA-95 hierarchy. One command gives you thousands of data points that behave like a real plant.
5
+ Miravo simulates industrial assets with realistic physics — correlated signals, lifecycle degradation, cascading faults — and projects them over MQTT and OPC UA from a single canonical asset model. One command gives you a fully structured namespace with typed members that behave like a real plant.
6
6
 
7
7
  ```
8
8
  MiravoWorks/smart-factory
@@ -56,7 +56,7 @@ Connect any MQTT client to `localhost:1883` and subscribe to `#` to see the data
56
56
 
57
57
  ## Built-In Models
58
58
 
59
- Miravo ships with 8 industrial digital twin models, each with realistic sensor correlations, lifecycle stages, and fault modes:
59
+ Miravo ships with 8 canonical asset models, each with typed members, lifecycle stages, and fault modes:
60
60
 
61
61
  | Model | Key Signals | Fault Examples |
62
62
  | ------------------ | ----------------------------------------- | ---------------------------------- |
@@ -87,24 +87,29 @@ miravo start --content-dir ./my-content
87
87
 
88
88
  Start the simulation engine with an embedded MQTT broker.
89
89
 
90
- | Option | Description |
91
- | ----------------------------- | ----------------------------------------------------------------------------- |
92
- | `--template <id\|name\|path>` | Template to run (env: `MIRAVO_TEMPLATE`) |
93
- | `--content-dir <path>` | User content root with `/templates` and `/models` (env: `MIRAVO_CONTENT_DIR`) |
94
- | `--models-dir <path>` | Additional `.twin.yaml` model directory |
95
- | `--speed <multiplier>` | Time multiplier (default: `1`) |
96
- | `--seed <number>` | Deterministic RNG seed |
97
- | `--tick <ms>` | Tick interval in ms (default: `1000`) |
98
- | `--port <number>` | MQTT port (default: `1883`, env: `MIRAVO_MQTT_PORT`) |
99
- | `--mqtt-host <host>` | MQTT bind host (default: `127.0.0.1`, env: `MIRAVO_MQTT_HOST`) |
100
- | `--mqtt-mode <mode>` | `embedded` or `external` (env: `MIRAVO_MQTT_MODE`) |
101
- | `--mqtt-url <url>` | External broker URL (env: `MIRAVO_MQTT_URL`) |
102
- | `--mqtt-username <user>` | External broker username (env: `MIRAVO_MQTT_USERNAME`) |
103
- | `--mqtt-password <pass>` | External broker password (env: `MIRAVO_MQTT_PASSWORD`) |
104
- | `--health-host <host>` | Health server host (default: `0.0.0.0`, env: `MIRAVO_HEALTH_HOST`) |
105
- | `--health-port <number>` | Health server port (default: `8080`, env: `MIRAVO_HEALTH_PORT`) |
106
- | `--dry-run` | Validate and print summary without starting |
107
- | `--summary-interval <s>` | Seconds between periodic summaries (default: `5`) |
90
+ | Option | Description |
91
+ | --------------------------------- | ----------------------------------------------------------------------------- |
92
+ | `-t, --template <id\|name\|path>` | Template to run (env: `MIRAVO_TEMPLATE`) |
93
+ | `-n, --name <name>` | Simulation name for control topics (env: `MIRAVO_NAME`) |
94
+ | `--content-dir <path>` | User content root with `/templates` and `/models` (env: `MIRAVO_CONTENT_DIR`) |
95
+ | `--models-dir <path>` | Additional `.twin.yaml` model directory |
96
+ | `-s, --speed <multiplier>` | Time multiplier (default: `1`) |
97
+ | `--seed <number>` | Deterministic RNG seed |
98
+ | `--tick <ms>` | Tick interval in ms (default: `1000`) |
99
+ | `-p, --mqtt-port <number>` | MQTT port (default: `1883`, env: `MIRAVO_MQTT_PORT`) |
100
+ | `--mqtt-host <host>` | MQTT bind host (default: `127.0.0.1`, env: `MIRAVO_MQTT_HOST`) |
101
+ | `--mqtt-mode <mode>` | `embedded` or `external` (env: `MIRAVO_MQTT_MODE`) |
102
+ | `--mqtt-url <url>` | External broker URL (env: `MIRAVO_MQTT_URL`) |
103
+ | `--mqtt-username <user>` | External broker username (env: `MIRAVO_MQTT_USERNAME`) |
104
+ | `--mqtt-password <pass>` | External broker password (env: `MIRAVO_MQTT_PASSWORD`) |
105
+ | `--no-mqtt` | Disable MQTT broker |
106
+ | `--opcua` | Enable OPC UA server |
107
+ | `--opcua-port <number>` | OPC UA port (default: `4840`, env: `MIRAVO_OPCUA_PORT`) |
108
+ | `--opcua-host <host>` | OPC UA bind host (default: `0.0.0.0`, env: `MIRAVO_OPCUA_HOST`) |
109
+ | `--admin-host <host>` | Admin server host (default: `127.0.0.1`, env: `MIRAVO_ADMIN_HOST`) |
110
+ | `--admin-port <number>` | Admin server port (default: `8080`, env: `MIRAVO_ADMIN_PORT`) |
111
+ | `-d, --dry-run` | Validate and print summary without starting |
112
+ | `--summary-interval <s>` | Seconds between periodic summaries (default: `5`) |
108
113
 
109
114
  ### `miravo templates list`
110
115
 
@@ -126,7 +131,7 @@ Show template details or copy it locally.
126
131
 
127
132
  ### `miravo models list`
128
133
 
129
- List all discovered digital twin models.
134
+ List all discovered canonical asset models.
130
135
 
131
136
  | Option | Description |
132
137
  | -------------- | ---------------------------- |
@@ -134,7 +139,7 @@ List all discovered digital twin models.
134
139
 
135
140
  ### `miravo models inspect <name>`
136
141
 
137
- Show model details — sensors, lifecycle stages, fault definitions.
142
+ Show model details — typed members, lifecycle stages, fault definitions.
138
143
 
139
144
  | Option | Description |
140
145
  | ------- | -------------- |
@@ -142,15 +147,16 @@ Show model details — sensors, lifecycle stages, fault definitions.
142
147
 
143
148
  ### `miravo status`
144
149
 
145
- Read live state from a running simulation via MQTT control plane.
150
+ Read live state from a running simulation via the admin HTTP server. `--discover` still uses MQTT when enabled.
146
151
 
147
152
  | Option | Description |
148
153
  | ------------------- | ----------------------------------------------------------- |
149
- | `-w, --watch` | Stay subscribed and re-render on updates |
154
+ | `--admin-url <url>` | Admin server URL (env: `MIRAVO_ADMIN_URL`) |
155
+ | `-w, --watch` | Poll and re-render on updates |
150
156
  | `--instance <id>` | Show one instance instead of fleet overview |
151
157
  | `--json` | Machine-readable JSON output |
152
- | `--template <path>` | Template for broker URL resolution (env: `MIRAVO_TEMPLATE`) |
153
- | `--timeout <ms>` | Connection timeout (default: `5000`) |
158
+ | `--template <path>` | Template for MQTT discovery only (env: `MIRAVO_TEMPLATE`) |
159
+ | `--timeout <ms>` | Request timeout (default: `5000`) |
154
160
 
155
161
  ### `miravo inject [instanceId] [fault]`
156
162
 
@@ -159,8 +165,8 @@ Trigger or clear faults on running instances.
159
165
  | Option | Description |
160
166
  | ------------------- | ----------------------------------------------------------- |
161
167
  | `--clear` | Clear fault instead of triggering |
162
- | `--template <path>` | Template for broker URL resolution (env: `MIRAVO_TEMPLATE`) |
163
- | `--timeout <ms>` | Command timeout (default: `5000`) |
168
+ | `--admin-url <url>` | Admin server URL (env: `MIRAVO_ADMIN_URL`) |
169
+ | `--timeout <ms>` | Request timeout (default: `5000`) |
164
170
 
165
171
  If `instanceId` and `fault` are omitted in a TTY, an interactive picker is shown.
166
172
 
@@ -193,13 +199,13 @@ docker run --rm -p 1883:1883 -p 8080:8080 \
193
199
  amineamaach/miravo:latest start --template my-template
194
200
  ```
195
201
 
196
- Health probes are available at `/healthz` (liveness) and `/readyz` (readiness) on port 8080.
202
+ The admin server exposes `/healthz`, `/readyz`, `/state`, `/metrics`, and `/commands` on port 8080.
197
203
 
198
204
  ## Links
199
205
 
200
206
  - [GitHub](https://github.com/amine-amaach/miravo)
201
- - [Built-in Models & Templates Reference](https://github.com/amine-amaach/miravo/blob/main/docs/builtin-twins-and-templates.md)
202
- - [Writing Custom Twin Models](https://github.com/amine-amaach/miravo/blob/main/docs/builtin-twins-and-templates.md)
207
+ - [Built-in Models & Templates Reference](https://github.com/amine-amaach/miravo/blob/main/docs/ai/05-builtin-twins-and-templates.md)
208
+ - [Writing Custom Twin Models](https://github.com/amine-amaach/miravo/blob/main/docs/ai/05-builtin-twins-and-templates.md)
203
209
 
204
210
  ## License
205
211
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "miravo",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
- "description": "Digital twin engine for Industrial IoT simulation",
5
+ "description": "Industrial environment platform with canonical asset models and MQTT + OPC UA protocol adapters",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",
@@ -31,11 +31,17 @@
31
31
  ],
32
32
  "dependencies": {
33
33
  "@clack/prompts": "^1.1.0",
34
- "@miravo/core": "0.1.4",
35
- "@miravo/mqtt": "0.1.4",
36
- "@miravo/shared": "0.1.4",
34
+ "@miravo/core": "0.2.1",
35
+ "@miravo/mqtt": "0.2.1",
36
+ "@miravo/opcua": "0.2.1",
37
+ "@miravo/shared": "0.2.1",
37
38
  "commander": "^13.0.0",
38
39
  "consola": "^3.4.0",
39
40
  "mqtt": "^5.15.0"
41
+ },
42
+ "devDependencies": {
43
+ "node-opcua-client": "^2.163.1",
44
+ "node-opcua-variant": "^2.163.0",
45
+ "yaml": "^2.8.1"
40
46
  }
41
47
  }
@@ -0,0 +1,253 @@
1
+ import {
2
+ createControlPlane,
3
+ createEngine,
4
+ loadTemplate,
5
+ loadTwinModel,
6
+ resolveModelPath,
7
+ } from "@miravo/core";
8
+ import { MQTTAdapter } from "@miravo/mqtt";
9
+ import { OPCUAAdapter } from "@miravo/opcua";
10
+ import type { MiravoTemplate, TwinModel } from "@miravo/shared";
11
+ import { startAdminServer } from "../../lib/admin-server.ts";
12
+
13
+ interface RunnerArgs {
14
+ readonly template: string;
15
+ readonly modelsDir: string;
16
+ readonly readyFile: string;
17
+ readonly rbeEnabled: boolean;
18
+ readonly rbeDeadband: number;
19
+ }
20
+
21
+ function parseArgs(argv: readonly string[]): RunnerArgs {
22
+ const values = new Map<string, string>();
23
+ for (let index = 0; index < argv.length; index += 2) {
24
+ const key = argv[index];
25
+ const value = argv[index + 1];
26
+ if (!key?.startsWith("--") || value === undefined) {
27
+ throw new Error(
28
+ "Invalid smoke runner arguments. Expected --template <path> --models-dir <path> --ready-file <path>.",
29
+ );
30
+ }
31
+ values.set(key.slice(2), value);
32
+ }
33
+
34
+ const template = values.get("template");
35
+ const modelsDir = values.get("models-dir");
36
+ const readyFile = values.get("ready-file");
37
+ if (!template || !modelsDir || !readyFile) {
38
+ throw new Error(
39
+ "Missing smoke runner arguments. Provide --template, --models-dir, and --ready-file.",
40
+ );
41
+ }
42
+
43
+ const rbeEnabled = values.get("rbe-enabled") === "true";
44
+ const rbeDeadband = Number.parseFloat(values.get("rbe-deadband") ?? "0");
45
+
46
+ return { template, modelsDir, readyFile, rbeEnabled, rbeDeadband };
47
+ }
48
+
49
+ function resolveSimulationName(template: MiravoTemplate): string {
50
+ const namespace = template.namespace;
51
+ if (!namespace) {
52
+ throw new Error("Smoke template is missing namespace metadata.");
53
+ }
54
+ if ("enterprise" in namespace) {
55
+ return `${namespace.enterprise}-${namespace.site}`.toLowerCase();
56
+ }
57
+ return namespace.prefix.toLowerCase();
58
+ }
59
+
60
+ function buildUNSBasePath(template: MiravoTemplate, areaName: string, lineName: string): string {
61
+ const segments: string[] = [];
62
+ const namespace = template.namespace;
63
+ if (namespace !== undefined) {
64
+ if ("enterprise" in namespace) {
65
+ segments.push(namespace.enterprise, namespace.site);
66
+ } else {
67
+ segments.push(namespace.prefix);
68
+ }
69
+ }
70
+ segments.push(areaName, lineName);
71
+ return segments.join("/");
72
+ }
73
+
74
+ async function loadModels(
75
+ template: MiravoTemplate,
76
+ modelsDir: string,
77
+ ): Promise<Map<string, TwinModel>> {
78
+ const models = new Map<string, TwinModel>();
79
+ if (template.models) {
80
+ for (const inlineModel of template.models) {
81
+ models.set(inlineModel.model.name, inlineModel);
82
+ }
83
+ }
84
+
85
+ const referencedModelNames = new Set<string>();
86
+ for (const area of template.areas) {
87
+ for (const line of area.lines) {
88
+ for (const twin of line.twins) {
89
+ referencedModelNames.add(twin.model);
90
+ }
91
+ }
92
+ }
93
+
94
+ for (const modelName of referencedModelNames) {
95
+ if (models.has(modelName)) {
96
+ continue;
97
+ }
98
+ const modelPath = await resolveModelPath(modelName, [modelsDir]);
99
+ const model = await loadTwinModel(modelPath);
100
+ models.set(modelName, model);
101
+ }
102
+
103
+ return models;
104
+ }
105
+
106
+ async function main(): Promise<void> {
107
+ const args = parseArgs(process.argv.slice(2));
108
+ const template = await loadTemplate(args.template);
109
+ const models = await loadModels(template, args.modelsDir);
110
+ const simulationName = resolveSimulationName(template);
111
+ const tickIntervalMs = template.settings?.tick_interval_ms ?? 100;
112
+ const timeMultiplier = template.settings?.time_multiplier ?? 1;
113
+ const seed = template.settings?.seed ?? 42;
114
+
115
+ const engine = createEngine({
116
+ simulationName,
117
+ seed,
118
+ tickIntervalMs,
119
+ timeMultiplier,
120
+ templateMetadata: {
121
+ name: template.name,
122
+ path: args.template,
123
+ },
124
+ });
125
+
126
+ for (const [, model] of models) {
127
+ engine.runtime.registerModel(model);
128
+ }
129
+
130
+ for (const area of template.areas) {
131
+ for (const line of area.lines) {
132
+ for (const twin of line.twins) {
133
+ engine.runtime.spawn(twin.model, twin.count, {
134
+ variation: twin.variation,
135
+ unsBasePath: buildUNSBasePath(template, area.name, line.name),
136
+ ...(twin.parameters !== undefined ? { parameterOverrides: twin.parameters } : {}),
137
+ });
138
+ }
139
+ }
140
+ }
141
+
142
+ const mqttAdapter = new MQTTAdapter(engine.bus);
143
+ await mqttAdapter.start({
144
+ name: "mqtt",
145
+ enabled: true,
146
+ embedded: true,
147
+ host: "127.0.0.1",
148
+ port: 0,
149
+ retain: false,
150
+ simulationName,
151
+ ...(args.rbeEnabled ? { rbe: { enabled: true, deadband: args.rbeDeadband } } : {}),
152
+ });
153
+ engine.registerAdapter(mqttAdapter);
154
+
155
+ const opcuaAdapter = new OPCUAAdapter(engine.bus, engine);
156
+ await opcuaAdapter.start({
157
+ name: "opcua",
158
+ enabled: true,
159
+ host: "127.0.0.1",
160
+ port: 0,
161
+ security: "none",
162
+ simulationName,
163
+ });
164
+ engine.registerAdapter(opcuaAdapter);
165
+
166
+ const transport = mqttAdapter.getControlTransport();
167
+ if (!transport) {
168
+ throw new Error("Smoke runtime failed to resolve MQTT control transport.");
169
+ }
170
+
171
+ const controlPlane = createControlPlane({
172
+ simulationName,
173
+ engine,
174
+ transport,
175
+ bus: engine.bus,
176
+ });
177
+ controlPlane.start();
178
+
179
+ let lastTickProgressAt = performance.now();
180
+ const onTickProgress = () => {
181
+ lastTickProgressAt = performance.now();
182
+ };
183
+ engine.bus.on("tick:complete", onTickProgress);
184
+
185
+ const adminServer = startAdminServer({
186
+ engine,
187
+ host: "127.0.0.1",
188
+ port: 0,
189
+ tickIntervalMs,
190
+ mqttEnabled: true,
191
+ isMqttConnected: () => mqttAdapter.getMetrics().connected,
192
+ opcuaEnabled: true,
193
+ isOpcuaConnected: () => opcuaAdapter.getMetrics().connected,
194
+ getLastTickProgressAt: () => lastTickProgressAt,
195
+ });
196
+
197
+ engine.start();
198
+
199
+ await Bun.sleep(Math.max(250, tickIntervalMs * 3));
200
+
201
+ const [instanceId, instance] = [...engine.runtime.getInstances()][0] ?? [];
202
+ if (!instanceId || !instance) {
203
+ throw new Error("Smoke runtime did not spawn any instances from the template.");
204
+ }
205
+ if (!mqttAdapter.brokerUrl) {
206
+ throw new Error("Smoke runtime did not expose an MQTT broker URL.");
207
+ }
208
+ if (!opcuaAdapter.endpointUrl) {
209
+ throw new Error("Smoke runtime did not expose an OPC UA endpoint URL.");
210
+ }
211
+ if (opcuaAdapter.port <= 0) {
212
+ throw new Error("Smoke runtime did not expose an OPC UA port.");
213
+ }
214
+
215
+ await Bun.write(
216
+ args.readyFile,
217
+ JSON.stringify({
218
+ simulationName,
219
+ adminUrl: adminServer.endpoint,
220
+ mqttBrokerUrl: mqttAdapter.brokerUrl,
221
+ opcuaEndpointUrl: opcuaAdapter.endpointUrl,
222
+ instanceId,
223
+ instancePath: instance.browsePath.join("/"),
224
+ }),
225
+ );
226
+
227
+ let shuttingDown = false;
228
+ const shutdown = async () => {
229
+ if (shuttingDown) {
230
+ return;
231
+ }
232
+ shuttingDown = true;
233
+ adminServer.stop();
234
+ engine.bus.off("tick:complete", onTickProgress);
235
+ controlPlane.stop();
236
+ await engine.stop("smoke runner shutdown");
237
+ process.exit(0);
238
+ };
239
+
240
+ process.on("SIGINT", () => {
241
+ void shutdown();
242
+ });
243
+ process.on("SIGTERM", () => {
244
+ void shutdown();
245
+ });
246
+
247
+ await new Promise(() => {});
248
+ }
249
+
250
+ void main().catch((error) => {
251
+ console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
252
+ process.exit(1);
253
+ });