miravo 0.4.0 → 0.6.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.
Files changed (161) hide show
  1. package/README.md +1 -1
  2. package/console-dist/assets/activity-DsWeRq4h.js +1 -0
  3. package/console-dist/assets/api-4AqKvsm9.js +1 -0
  4. package/console-dist/assets/arrow-right-Ea_UhTNi.js +1 -0
  5. package/console-dist/assets/arrow-up-down-DFSjzzl2.js +1 -0
  6. package/console-dist/assets/blueprint-card-classes-DBFCDy0M.js +1 -0
  7. package/console-dist/assets/box-C1WaoOMF.js +1 -0
  8. package/console-dist/assets/card-BjeP32PN.js +1 -0
  9. package/console-dist/assets/catalog-BJzyzbLR.js +1 -0
  10. package/console-dist/assets/catalog-DKjEm-c4.js +1 -0
  11. package/console-dist/assets/catalog-template-icons-BrUeHaCY.js +1 -0
  12. package/console-dist/assets/checkbox-DZlJXTE4.js +1 -0
  13. package/console-dist/assets/chunk-OE4NN4TA-CEjpfhS6.js +3 -0
  14. package/console-dist/assets/combobox-BGnuCYxO.js +1 -0
  15. package/console-dist/assets/control-CPhvwx9o.js +1 -0
  16. package/console-dist/assets/control-Cg2up9NA.js +1 -0
  17. package/console-dist/assets/copy-button-WkbNkMcd.js +1 -0
  18. package/console-dist/assets/createLucideIcon-Bwxw859K.js +1 -0
  19. package/console-dist/assets/deploy-tab-JeWDwPoZ.js +1 -0
  20. package/console-dist/assets/dist-BhbDML0a.js +1 -0
  21. package/console-dist/assets/dist-Cy8XTNFT.js +2 -0
  22. package/console-dist/assets/download-DQ6NH16U.js +1 -0
  23. package/console-dist/assets/engine-store-CrLnz692.js +1 -0
  24. package/console-dist/assets/events-CXm29Ok4.js +1 -0
  25. package/console-dist/assets/events-D01IxhnP.js +1 -0
  26. package/console-dist/assets/fleet-CfxbKXt9.js +1 -0
  27. package/console-dist/assets/fleet-lotMGVqe.js +1 -0
  28. package/console-dist/assets/format-kXTonpDJ.js +1 -0
  29. package/console-dist/assets/gauge-Ci8zNZlk.js +1 -0
  30. package/console-dist/assets/get-error-message-3Q5Q-2G5.js +1 -0
  31. package/console-dist/assets/index-CVPKSDvJ.js +12 -0
  32. package/console-dist/assets/index-DJkgDeun.css +2 -0
  33. package/console-dist/assets/input-BWEQFptx.js +1 -0
  34. package/console-dist/assets/instance-detail-C5IVwuh3.js +1 -0
  35. package/console-dist/assets/label-uephX5ZG.js +1 -0
  36. package/console-dist/assets/layers-CUsLQ1fq.js +1 -0
  37. package/console-dist/assets/login-C3RJli0m.js +126 -0
  38. package/console-dist/assets/models-B_UioWQ5.js +1 -0
  39. package/console-dist/assets/models-_IPlPiZ7.js +1 -0
  40. package/console-dist/assets/not-found-B8gAm08S.js +1 -0
  41. package/console-dist/assets/platform-targets-R3T-4eJ7.js +1 -0
  42. package/console-dist/assets/platforms-BWh0BQ3Y.js +1 -0
  43. package/console-dist/assets/platforms-DStNbtVc.js +1 -0
  44. package/console-dist/assets/proxy-CsnYqnPp.js +9 -0
  45. package/console-dist/assets/query-keys-BvtE7BMs.js +1 -0
  46. package/console-dist/assets/radio-CoHegOv5.js +1 -0
  47. package/console-dist/assets/rolldown-runtime-COnpUsM8.js +1 -0
  48. package/console-dist/assets/select-CeO4vP0G.js +1 -0
  49. package/console-dist/assets/select-display-items-CIzY8953.js +1 -0
  50. package/console-dist/assets/server-D9Kfn8fQ.js +1 -0
  51. package/console-dist/assets/shallow-6lGjQhtJ.js +1 -0
  52. package/console-dist/assets/tags-CiXOIHtY.js +4 -0
  53. package/console-dist/assets/tags-FQCXIxrP.js +1 -0
  54. package/console-dist/assets/tooltip-HGLF366Q.js +1 -0
  55. package/console-dist/assets/transition-nav-link-Df4Rk6vV.js +1 -0
  56. package/console-dist/assets/transition-types-CPlGtM8w.js +1 -0
  57. package/console-dist/assets/triangle-alert-adNk_sea.js +1 -0
  58. package/console-dist/assets/use-platform-stream-DGbK1k2L.js +2 -0
  59. package/console-dist/assets/use-transition-navigate-CCpcWXB0.js +2 -0
  60. package/console-dist/assets/validation-Blvud9_O.js +1 -0
  61. package/console-dist/assets/vendor-charts-CAZdaPlq.js +60 -0
  62. package/console-dist/assets/vendor-table-wd8JuMmT.js +4 -0
  63. package/console-dist/assets/vendor-ui-ByahnaY0.js +1 -0
  64. package/console-dist/index.html +70 -2
  65. package/package.json +9 -8
  66. package/src/__tests__/integration/executable-methods-runner.ts +7 -6
  67. package/src/__tests__/integration/executable-methods.integration.ts +214 -122
  68. package/src/__tests__/integration/opcua-concurrent-clients.integration.ts +314 -0
  69. package/src/commands/adapter.ts +180 -7
  70. package/src/commands/apply.test.ts +62 -0
  71. package/src/commands/apply.ts +216 -0
  72. package/src/commands/cleanup.test.ts +34 -0
  73. package/src/commands/cleanup.ts +201 -0
  74. package/src/commands/export.test.ts +51 -0
  75. package/src/commands/export.ts +404 -0
  76. package/src/commands/fork.ts +2 -2
  77. package/src/commands/inject.ts +2 -2
  78. package/src/commands/load.ts +1 -1
  79. package/src/commands/logs.ts +13 -15
  80. package/src/commands/models.test.ts +1 -1
  81. package/src/commands/models.ts +1 -1
  82. package/src/commands/pause.ts +3 -3
  83. package/src/commands/platforms.test.ts +186 -0
  84. package/src/commands/platforms.ts +371 -0
  85. package/src/commands/remove.ts +1 -1
  86. package/src/commands/reset.ts +2 -2
  87. package/src/commands/spawn.ts +2 -2
  88. package/src/commands/speed.ts +2 -2
  89. package/src/commands/start/adapter-config.ts +157 -0
  90. package/src/commands/start/catalog-le-dt.test.ts +50 -0
  91. package/src/commands/start/catalog-le-dt.ts +187 -0
  92. package/src/commands/start/catalog-model-schema.ts +89 -0
  93. package/src/commands/start/catalog-template-detail.ts +54 -0
  94. package/src/commands/start/daemon.ts +255 -0
  95. package/src/commands/start/display.ts +150 -0
  96. package/src/commands/start/engine-setup.ts +247 -0
  97. package/src/commands/start/events.ts +126 -0
  98. package/src/commands/start/index.ts +897 -0
  99. package/src/commands/start/protocol-plans.ts +239 -0
  100. package/src/commands/start/template-loader.ts +197 -0
  101. package/src/commands/start/template-resolver.ts +185 -0
  102. package/src/commands/start/types.ts +73 -0
  103. package/src/commands/start.test.ts +137 -16
  104. package/src/commands/status.ts +1 -1
  105. package/src/commands/stop.ts +19 -8
  106. package/src/commands/templates.test.ts +1 -1
  107. package/src/commands/templates.ts +1 -3
  108. package/src/index.ts +11 -3
  109. package/src/lib/admin-auth.ts +157 -0
  110. package/src/lib/admin-client.test.ts +31 -15
  111. package/src/lib/admin-client.ts +106 -6
  112. package/src/lib/admin-platform-routes.ts +1017 -0
  113. package/src/lib/admin-platform-stream.test.ts +434 -0
  114. package/src/lib/admin-platform-stream.ts +788 -0
  115. package/src/lib/admin-responses.ts +211 -0
  116. package/src/lib/admin-server.test.ts +274 -55
  117. package/src/lib/admin-server.ts +387 -248
  118. package/src/lib/auth-store.ts +87 -0
  119. package/src/lib/daemon-registry.ts +12 -14
  120. package/src/lib/le/le-apply.test.ts +591 -0
  121. package/src/lib/le/le-apply.ts +832 -0
  122. package/src/lib/le/le-auth.ts +89 -0
  123. package/src/lib/le/le-cleanup.test.ts +168 -0
  124. package/src/lib/le/le-cleanup.ts +399 -0
  125. package/src/lib/le/le-cli-helpers.ts +77 -0
  126. package/src/lib/le/le-client.ts +184 -0
  127. package/src/lib/le/le-concurrency.test.ts +157 -0
  128. package/src/lib/le/le-concurrency.ts +78 -0
  129. package/src/lib/le/le-connection-test.ts +42 -0
  130. package/src/lib/le/le-devicehub-properties.test.ts +53 -0
  131. package/src/lib/le/le-devicehub-properties.ts +33 -0
  132. package/src/lib/le/le-devicehub.ts +213 -0
  133. package/src/lib/le/le-digital-twins.ts +285 -0
  134. package/src/lib/le/le-drivers.ts +23 -0
  135. package/src/lib/le/le-endpoint.ts +40 -0
  136. package/src/lib/le/le-environments.test.ts +73 -0
  137. package/src/lib/le/le-environments.ts +156 -0
  138. package/src/lib/le/le-markers.ts +87 -0
  139. package/src/lib/le/le-plan.test.ts +26 -0
  140. package/src/lib/le/le-plan.ts +109 -0
  141. package/src/lib/le/le-platform-operations.test.ts +198 -0
  142. package/src/lib/le/le-platform-operations.ts +340 -0
  143. package/src/lib/le/le-static-model-schema.ts +78 -0
  144. package/src/lib/le/le-static-planner.test.ts +255 -0
  145. package/src/lib/le/le-static-planner.ts +442 -0
  146. package/src/lib/le/le-template-exporter.test.ts +527 -0
  147. package/src/lib/le/le-template-exporter.ts +459 -0
  148. package/src/lib/le/le-value-types.ts +23 -0
  149. package/src/lib/mqtt-client.ts +1 -2
  150. package/src/lib/resolve-simulation.ts +1 -6
  151. package/src/lib/session-store.ts +110 -0
  152. package/src/lib/shutdown.test.ts +184 -2
  153. package/src/lib/shutdown.ts +104 -34
  154. package/src/lib/template-category.ts +3 -3
  155. package/src/lib/template-locator.ts +8 -2
  156. package/src/lib/ws-hub.ts +72 -46
  157. package/src/logger.ts +39 -14
  158. package/src/output.ts +3 -0
  159. package/console-dist/assets/index-C3XMYq26.css +0 -2
  160. package/console-dist/assets/index-hUJSpKtB.js +0 -119
  161. package/src/commands/start.ts +0 -2023
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Litmus Edge template export (JSON + tags CSV) from desired state.
3
+ *
4
+ * The JSON template includes hardcoded `driverId` per protocol to support direct upload to LE.
5
+ * The CSV template uses `driverName` since that is expected by the LE CSV import format.
6
+ */
7
+
8
+ import type { ProtocolName } from "@miravo/shared";
9
+ import type { ApplyDesiredState, DesiredDevice, DesiredTag } from "./le-apply.ts";
10
+ import { buildLeDeviceHubDeviceProperties } from "./le-devicehub-properties.ts";
11
+ import { LE_DRIVER_IDS, LE_DRIVER_NAMES } from "./le-drivers.ts";
12
+ import { encodeDeviceMarkers } from "./le-markers.ts";
13
+
14
+ const ZERO = "00000000-0000-0000-0000-000000000000";
15
+
16
+ interface LeExportResult {
17
+ readonly templateJson: string;
18
+ readonly tagsCSV: string;
19
+ }
20
+
21
+ /**
22
+ * Convert offline desired state into a full LE template JSON + tags CSV.
23
+ *
24
+ * The JSON includes devicehub devices with registers, DT models and instances,
25
+ * and stats config. The CSV covers all tags across all devices in one file,
26
+ * with deviceId/registerId cross-referenced from the generated template.
27
+ */
28
+ export function buildLeExport(desired: ApplyDesiredState, templateId: string): LeExportResult {
29
+ const generatedDevices = desired.devices.map((d) => generateDevice(d, templateId));
30
+ const modelObjects = extractModelObjects(desired, templateId);
31
+ const modelByName = new Map(
32
+ modelObjects.map((m) => [(m as Record<string, unknown>)["name"] as string, m]),
33
+ );
34
+ const instanceObjects = desired.instances.map((inst) => {
35
+ const model = modelByName.get(inst.dtModelName);
36
+ if (!model) throw new Error(`Model "${inst.dtModelName}" not found in desired state`);
37
+ return buildInstance(
38
+ crypto.randomUUID(), // LE requires a UUID, not the human-readable instanceId
39
+ inst.dtInstanceName,
40
+ inst.modelName,
41
+ inst.boundDeviceName,
42
+ templateId,
43
+ model as Record<string, unknown>,
44
+ inst.browsePath,
45
+ );
46
+ });
47
+
48
+ const template = {
49
+ devicehub: { Devs: generatedDevices.map((g) => g.devObj) },
50
+ dtwin: { models: modelObjects, instances: instanceObjects },
51
+ stats: buildStats(generatedDevices),
52
+ };
53
+
54
+ return {
55
+ templateJson: JSON.stringify(template, null, 2),
56
+ tagsCSV: buildTagsCSV(generatedDevices),
57
+ };
58
+ }
59
+
60
+ interface GeneratedDevice {
61
+ readonly id: string;
62
+ readonly name: string;
63
+ readonly instanceId: string;
64
+ readonly modelName: string;
65
+ readonly protocol: ProtocolName;
66
+ readonly registers: GeneratedRegister[];
67
+ readonly devObj: unknown;
68
+ }
69
+
70
+ interface GeneratedRegister {
71
+ readonly id: string;
72
+ readonly tag: DesiredTag;
73
+ readonly protocol: ProtocolName;
74
+ }
75
+
76
+ function generateDevice(device: DesiredDevice, templateId: string): GeneratedDevice {
77
+ const deviceId = crypto.randomUUID();
78
+ const registers: GeneratedRegister[] = device.tags.map((tag) => ({
79
+ id: crypto.randomUUID(),
80
+ tag,
81
+ protocol: device.protocol,
82
+ }));
83
+
84
+ const markers = encodeDeviceMarkers({
85
+ templateId,
86
+ instanceId: device.instanceId,
87
+ modelName: device.modelName,
88
+ protocol: device.protocol,
89
+ unsPath: device.browsePath,
90
+ });
91
+
92
+ const devObj = {
93
+ Dev: {
94
+ id: deviceId,
95
+ name: device.deviceName,
96
+ driverId: LE_DRIVER_IDS[device.protocol],
97
+ settings: buildDeviceSettings(device),
98
+ aliasTopics: true,
99
+ meta_tags: Object.fromEntries(markers.map(({ Key, Value }) => [Key, Value])),
100
+ debug: false,
101
+ stopped: true,
102
+ version: "0.0.1",
103
+ },
104
+ Registers: registers.map((r) => buildRegisterObj(r, deviceId)),
105
+ };
106
+
107
+ return {
108
+ id: deviceId,
109
+ name: device.deviceName,
110
+ instanceId: device.instanceId,
111
+ modelName: device.modelName,
112
+ protocol: device.protocol,
113
+ registers,
114
+ devObj,
115
+ };
116
+ }
117
+
118
+ function buildDeviceSettings(device: DesiredDevice): Record<string, string> {
119
+ const { host, port } = device.endpoint;
120
+ return Object.fromEntries(
121
+ buildLeDeviceHubDeviceProperties(device.protocol, host, port).map((item) => [
122
+ item.Key,
123
+ item.Value,
124
+ ]),
125
+ );
126
+ }
127
+
128
+ function buildRegisterObj(reg: GeneratedRegister, deviceId: string): Record<string, unknown> {
129
+ return {
130
+ id: reg.id,
131
+ deviceId,
132
+ value_type: reg.tag.valueType,
133
+ name: reg.tag.name,
134
+ tag_name: reg.tag.tagName,
135
+ settings: buildRegisterSettings(reg.tag, reg.protocol),
136
+ };
137
+ }
138
+
139
+ function buildRegisterSettings(tag: DesiredTag, protocol: ProtocolName): Record<string, string> {
140
+ if (protocol === "modbus") {
141
+ const propMap = new Map(tag.properties.map((p) => [p.Key, p.Value]));
142
+ return {
143
+ address: propMap.get("address") ?? "0",
144
+ count: propMap.get("count") ?? "1",
145
+ pollingInterval: "1000",
146
+ };
147
+ }
148
+ return {};
149
+ }
150
+
151
+ /**
152
+ * Adapt leDtJson models for /dm/template/v2 import. The ImportModel mutation
153
+ * and the template import use different subsets of the model schema:
154
+ *
155
+ * - version: must be absent (0 causes "restore failed")
156
+ * - dynamicAttributes.topicExpression: must be absent (template rejects it; instances carry it)
157
+ * - parameters: must include id/ownerId/ownerType (template rejects simple {name,value} form)
158
+ * - staticAttributes: $param.* references and __miravo_placeholder__ are resolved/sanitized
159
+ */
160
+ function extractModelObjects(desired: ApplyDesiredState, templateId: string): unknown[] {
161
+ return desired.models.flatMap((m) => {
162
+ const arr = m.leDtJson as unknown[];
163
+ const models = Array.isArray(arr) ? arr : [arr];
164
+ return models.map((model) => {
165
+ if (!model || typeof model !== "object") return model;
166
+ const { version: _v, ...rest } = model as Record<string, unknown>;
167
+ const modelId = rest["id"] as string;
168
+ const modelNameLower = String(rest["name"] ?? "").toLowerCase();
169
+
170
+ const dynAttrs = rest["dynamicAttributes"] as Array<Record<string, unknown>> | undefined;
171
+ if (dynAttrs) {
172
+ rest["dynamicAttributes"] = dynAttrs.map(
173
+ ({ topicExpression: _te, topic: _t, ...attr }) => ({
174
+ ...attr,
175
+ topic: "",
176
+ unit: sanitizeUnit(String(attr["unit"] ?? "")),
177
+ }),
178
+ );
179
+ }
180
+
181
+ const params = rest["parameters"] as Array<Record<string, unknown>> | undefined;
182
+ if (params?.[0] && !params[0]["id"]) {
183
+ rest["parameters"] = params.map((p) => ({
184
+ id: crypto.randomUUID(),
185
+ ownerId: modelId,
186
+ ownerType: 2,
187
+ name: p["name"],
188
+ value: p["value"],
189
+ }));
190
+ }
191
+
192
+ const staticAttrs = rest["staticAttributes"] as Array<Record<string, unknown>> | undefined;
193
+ if (staticAttrs) {
194
+ rest["staticAttributes"] = staticAttrs.map((a) => {
195
+ const key = String(a["key"] ?? "");
196
+ let value = String(a["value"] ?? "");
197
+ // Resolve miravo marker placeholders at model level where possible
198
+ if (value === "__miravo_placeholder__") {
199
+ if (key === "miravo_template") value = templateId;
200
+ else if (key === "miravo_model") value = modelNameLower;
201
+ // miravo_instance cannot be resolved at model level — keep placeholder
202
+ }
203
+ // Template-exported models are Miravo-owned — mark as managed so cleanup can target them
204
+ if (key === "miravo_managed") value = "true";
205
+ // LE rejects $param.* template references and empty values
206
+ if (value.startsWith("$")) value = "N/A";
207
+ return { ...a, value };
208
+ });
209
+ }
210
+
211
+ return rest;
212
+ });
213
+ });
214
+ }
215
+
216
+ function buildInstance(
217
+ instanceId: string,
218
+ instanceName: string,
219
+ modelName: string,
220
+ deviceName: string,
221
+ templateId: string,
222
+ model: Record<string, unknown>,
223
+ browsePath: string,
224
+ ): Record<string, unknown> {
225
+ const modelId = model["id"] as string;
226
+ const modelNameLower = (model["name"] as string).toLowerCase();
227
+
228
+ const modelDynAttrs = (model["dynamicAttributes"] as Array<Record<string, unknown>>) ?? [];
229
+ const modelStaticAttrs = (model["staticAttributes"] as Array<Record<string, unknown>>) ?? [];
230
+ const modelParams = (model["parameters"] as Array<Record<string, unknown>>) ?? [];
231
+
232
+ const dynamicAttributes = modelDynAttrs.map((a) => {
233
+ const name = String(a["name"] ?? "");
234
+ // The LE template import format rejects `topicExpression` on model objects
235
+ // (extractModelObjects strips it), but instance objects require it.
236
+ // Reconstruct it here using the standard devicehub alias formula; the {device}
237
+ // parameter is resolved at runtime by LE using the instance's bound device name.
238
+ const topicExpression =
239
+ (a["topicExpression"] as string | undefined) || `devicehub.alias.{device}.${name}`;
240
+ return {
241
+ id: crypto.randomUUID(),
242
+ modelId: ZERO,
243
+ parentId: a["id"] as string,
244
+ instanceId,
245
+ topic: "",
246
+ name,
247
+ unit: sanitizeUnit(String(a["unit"] ?? "")),
248
+ dataType: a["dataType"] ?? "",
249
+ schemaId: ZERO,
250
+ skipParentKey: a["skipParentKey"] ?? false,
251
+ topicExpression,
252
+ };
253
+ });
254
+
255
+ const staticAttributes = modelStaticAttrs.map((a) => {
256
+ const key = String(a["key"] ?? "");
257
+ let value: string;
258
+ switch (key) {
259
+ case "miravo_managed":
260
+ value = "true";
261
+ break;
262
+ case "miravo_template":
263
+ value = templateId;
264
+ break;
265
+ case "miravo_model":
266
+ value = modelName;
267
+ break;
268
+ case "miravo_instance":
269
+ value = instanceName;
270
+ break;
271
+ case "uns_path":
272
+ value = browsePath;
273
+ break;
274
+ default:
275
+ value = sanitizeStaticValue(String(a["value"] ?? ""));
276
+ }
277
+ return {
278
+ id: crypto.randomUUID(),
279
+ parentId: a["id"] as string,
280
+ modelId: ZERO,
281
+ instanceId,
282
+ key,
283
+ value,
284
+ type: a["type"] ?? 0,
285
+ };
286
+ });
287
+
288
+ // Override model's {device} parameter with the bound device name.
289
+ const parameters = modelParams.map((p) => ({
290
+ id: crypto.randomUUID(),
291
+ ownerId: instanceId,
292
+ ownerType: 1,
293
+ name: p["name"] as string,
294
+ value: p["name"] === "device" ? deviceName : (p["value"] as string),
295
+ }));
296
+
297
+ return {
298
+ id: instanceId,
299
+ modelId,
300
+ name: instanceName,
301
+ topic: `digitaltwins.${modelNameLower}.${instanceName}`,
302
+ interval: 1,
303
+ image: "",
304
+ dynamicAttributes,
305
+ staticAttributes,
306
+ transformationBackup: null,
307
+ parameters,
308
+ flatten: false,
309
+ isEnabled: true,
310
+ publishAttributes: false,
311
+ };
312
+ }
313
+
314
+ function buildStats(devices: GeneratedDevice[]): Record<string, unknown> {
315
+ const statsDevices = devices.map((d) => ({
316
+ name: d.name,
317
+ id: d.id,
318
+ retention_hours: 720,
319
+ save_all: true,
320
+ topic: `devicehub.alias.${d.name}.>`,
321
+ }));
322
+
323
+ const timeseries = devices.map((d) => ({
324
+ id: d.id,
325
+ name: d.name,
326
+ topics: [`devicehub.alias.${d.name}.>`],
327
+ }));
328
+
329
+ return {
330
+ devices: statsDevices,
331
+ timeseries,
332
+ timeseries_db: { source: "internal", enabled: true },
333
+ };
334
+ }
335
+
336
+ /** LE DeviceHub CSV header — matches the real LE export format. */
337
+ const CSV_HEADER =
338
+ "deviceId,deviceName,driverName,name,tagName,description,valueType,registerId,publishCoV,tagFormula,metaData,Retain,Qos,address,count,pollingInterval,endianness";
339
+
340
+ function buildTagsCSV(devices: GeneratedDevice[]): string {
341
+ const rows = [CSV_HEADER];
342
+ for (const device of devices) {
343
+ for (const reg of device.registers) {
344
+ rows.push(buildCSVRow(device, reg));
345
+ }
346
+ }
347
+ return `${rows.join("\n")}\n`;
348
+ }
349
+
350
+ function buildCSVRow(device: GeneratedDevice, reg: GeneratedRegister): string {
351
+ const { id: deviceId, name: deviceName, instanceId, modelName, protocol } = device;
352
+ const { tag, id: registerId } = reg;
353
+ const driverName = LE_DRIVER_NAMES[protocol];
354
+ const propMap = new Map(tag.properties.map((p) => [p.Key, p.Value]));
355
+ const metaData = buildTagMetaDataJson(tag, instanceId, modelName);
356
+
357
+ switch (protocol) {
358
+ case "opcua":
359
+ return [
360
+ deviceId,
361
+ esc(deviceName),
362
+ esc(driverName),
363
+ esc(tag.name), // OPC-UA NodeId
364
+ esc(tag.tagName),
365
+ "",
366
+ tag.valueType,
367
+ registerId,
368
+ "false",
369
+ "",
370
+ metaData,
371
+ "",
372
+ "",
373
+ "",
374
+ "",
375
+ "",
376
+ "",
377
+ ].join(",");
378
+
379
+ case "modbus":
380
+ return [
381
+ deviceId,
382
+ esc(deviceName),
383
+ esc(driverName),
384
+ "H", // Holding register
385
+ esc(tag.tagName),
386
+ "",
387
+ tag.valueType,
388
+ registerId,
389
+ "false",
390
+ "",
391
+ metaData,
392
+ "",
393
+ "",
394
+ propMap.get("address") ?? "0",
395
+ propMap.get("count") ?? "1",
396
+ "1.0000000000",
397
+ "AB CD",
398
+ ].join(",");
399
+
400
+ case "mqtt":
401
+ return [
402
+ deviceId,
403
+ esc(deviceName),
404
+ esc(driverName),
405
+ esc(tag.name), // MQTT topic
406
+ esc(tag.tagName),
407
+ "",
408
+ tag.valueType,
409
+ registerId,
410
+ "false",
411
+ "",
412
+ metaData,
413
+ "Disable",
414
+ "0",
415
+ "",
416
+ "",
417
+ "",
418
+ "",
419
+ ].join(",");
420
+
421
+ default: {
422
+ const _never: never = protocol;
423
+ throw new Error(`Unknown protocol: ${_never}`);
424
+ }
425
+ }
426
+ }
427
+
428
+ function buildTagMetaDataJson(tag: DesiredTag, instanceId: string, modelName: string): string {
429
+ const meta: Record<string, string> = {
430
+ uns_path: tag.unsPath,
431
+ model: modelName,
432
+ instance: instanceId,
433
+ access: tag.access,
434
+ };
435
+ if (tag.unit) meta["unit"] = tag.unit;
436
+ if (tag.euRange) {
437
+ meta["eu_range_min"] = String(tag.euRange[0]);
438
+ meta["eu_range_max"] = String(tag.euRange[1]);
439
+ }
440
+ return esc(JSON.stringify(meta));
441
+ }
442
+
443
+ function esc(value: string): string {
444
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
445
+ return `"${value.replace(/"/g, '""')}"`;
446
+ }
447
+ return value;
448
+ }
449
+
450
+ /** LE rejects staticAttribute values that are empty or have leading/trailing spaces. */
451
+ function sanitizeStaticValue(value: string): string {
452
+ const trimmed = value.trim();
453
+ return trimmed.length > 0 ? trimmed : "N/A";
454
+ }
455
+
456
+ /** LE rejects dynamicAttribute units containing /, ^, or %. */
457
+ function sanitizeUnit(unit: string): string {
458
+ return unit.replace(/[/^%]/g, "");
459
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Miravo → LE value type mappings (shared between export and apply).
3
+ */
4
+
5
+ /** OPC UA: map Miravo data types to LE OPC UA Client Advanced value types. */
6
+ export const OPCUA_VALUE_TYPE: Readonly<Record<string, string>> = {
7
+ Boolean: "bool",
8
+ Int32: "int32",
9
+ UInt16: "uint16",
10
+ UInt32: "uint32",
11
+ Float: "float32",
12
+ Double: "float64",
13
+ String: "string",
14
+ };
15
+
16
+ /** Modbus: map Miravo register encodings to LE Modbus TCP value types. */
17
+ export const MODBUS_VALUE_TYPE: Readonly<Record<string, string>> = {
18
+ float32: "float32",
19
+ int32: "int32",
20
+ uint32: "uint32",
21
+ uint16: "word",
22
+ boolean: "word",
23
+ };
@@ -112,5 +112,4 @@ function disconnectClient(client: MqttClient): void {
112
112
  }
113
113
  }
114
114
 
115
- export type { BrokerConnectionInfo };
116
- export { connectToBroker, connectWithTimeout, disconnectClient, resolveBrokerInfo };
115
+ export { connectToBroker, disconnectClient };
@@ -177,10 +177,5 @@ async function resolveTargetSimulation(
177
177
  };
178
178
  }
179
179
 
180
- export type {
181
- ResolvedTargetSimulation,
182
- ResolveTargetSimulationOptions,
183
- SimulationResolverClient,
184
- SimulationTopics,
185
- };
180
+ export type { SimulationResolverClient };
186
181
  export { discoverRunningSimulations, resolveTargetSimulation };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Server-side session store — in-memory sessions with 24h TTL.
3
+ *
4
+ * Sessions are created on successful login and validated on every request.
5
+ * Cookie helpers handle Set-Cookie header construction and parsing.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Session types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface Session {
13
+ readonly id: string;
14
+ readonly username: string;
15
+ readonly createdAt: number;
16
+ }
17
+
18
+ export interface SessionStore {
19
+ /** Create a new session for the given username. */
20
+ create(username: string): Session;
21
+ /** Validate a session ID. Returns the session or null if expired/missing. */
22
+ validate(sessionId: string): Session | null;
23
+ /** Destroy a specific session. */
24
+ destroy(sessionId: string): void;
25
+ /** Clear all sessions (used on server stop). */
26
+ clear(): void;
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Constants
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
34
+ const SESSION_TTL_SECONDS = 24 * 60 * 60;
35
+ const COOKIE_NAME = "miravo_session";
36
+ const SWEEP_THRESHOLD = 50;
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Session store
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export function createSessionStore(): SessionStore {
43
+ const sessions = new Map<string, Session>();
44
+
45
+ function sweepExpired(): void {
46
+ const now = Date.now();
47
+ for (const [id, s] of sessions) {
48
+ if (now - s.createdAt > SESSION_TTL_MS) sessions.delete(id);
49
+ }
50
+ }
51
+
52
+ return {
53
+ create(username: string): Session {
54
+ if (sessions.size >= SWEEP_THRESHOLD) sweepExpired();
55
+ const session: Session = {
56
+ id: crypto.randomUUID(),
57
+ username,
58
+ createdAt: Date.now(),
59
+ };
60
+ sessions.set(session.id, session);
61
+ return session;
62
+ },
63
+
64
+ validate(sessionId: string): Session | null {
65
+ const session = sessions.get(sessionId);
66
+ if (!session) return null;
67
+ if (Date.now() - session.createdAt > SESSION_TTL_MS) {
68
+ sessions.delete(sessionId);
69
+ return null;
70
+ }
71
+ return session;
72
+ },
73
+
74
+ destroy(sessionId: string): void {
75
+ sessions.delete(sessionId);
76
+ },
77
+
78
+ clear(): void {
79
+ sessions.clear();
80
+ },
81
+ };
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Cookie helpers
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /** Build a Set-Cookie header value for a new session. */
89
+ export function buildSessionCookie(sessionId: string): string {
90
+ return `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_TTL_SECONDS}`;
91
+ }
92
+
93
+ /** Build a Set-Cookie header value that clears the session cookie. */
94
+ export function buildClearCookie(): string {
95
+ return `${COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
96
+ }
97
+
98
+ /** Extract the session ID from the Cookie header of a request. Returns null if absent. */
99
+ export function extractSessionId(request: Request): string | null {
100
+ const header = request.headers.get("cookie");
101
+ if (!header) return null;
102
+ for (const part of header.split(";")) {
103
+ const [key, ...rest] = part.split("=");
104
+ if (key?.trim() === COOKIE_NAME) {
105
+ const value = rest.join("=").trim();
106
+ if (value.length > 0) return value;
107
+ }
108
+ }
109
+ return null;
110
+ }