Skip to content

Commit 1daa6e0

Browse files
committed
feat(api): wire mqtt client, timescaledb pool, historian recorder, participant enrollment
1 parent 9d30f91 commit 1daa6e0

4 files changed

Lines changed: 398 additions & 110 deletions

File tree

packages/api/src/app.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22

3+
import type { CosimMqttClient } from "@modelscript/cosim";
34
import express from "express";
5+
import type { Pool } from "pg";
46

57
import { LibraryDatabase } from "./database.js";
68
import { JobQueue } from "./jobs.js";
@@ -16,11 +18,28 @@ import { simulateRouter } from "./routes/simulate.js";
1618
import { sparqlRouter } from "./routes/sparql.js";
1719
import { LibraryStorage } from "./storage.js";
1820

19-
export function createApp(storage?: LibraryStorage): express.Express {
21+
/** Options for creating the Express application. */
22+
export interface AppOptions {
23+
/** Optional library storage override. */
24+
storage?: LibraryStorage | undefined;
25+
/** MQTT client for co-simulation (null = no MQTT). */
26+
mqttClient?: CosimMqttClient | null | undefined;
27+
/** PostgreSQL pool for historian queries (null = stubs). */
28+
dbPool?: Pool | null | undefined;
29+
}
30+
31+
export function createApp(options?: AppOptions | LibraryStorage): express.Express {
2032
const app = express();
21-
const libraryStorage = storage ?? new LibraryStorage();
33+
34+
// Support legacy signature: createApp(storage?)
35+
const opts: AppOptions =
36+
options && "storage" in options ? (options as AppOptions) : { storage: options as LibraryStorage | undefined };
37+
38+
const libraryStorage = opts.storage ?? new LibraryStorage();
2239
const jobQueue = new JobQueue();
2340
const database = new LibraryDatabase();
41+
const mqttClient = opts.mqttClient ?? null;
42+
const dbPool = opts.dbPool ?? null;
2443

2544
app.use(express.json());
2645

@@ -35,15 +54,19 @@ export function createApp(storage?: LibraryStorage): express.Express {
3554
app.use("/api/v1/libraries", sparqlRouter(database));
3655
app.use("/api/v1", simulateRouter(libraryStorage, jobQueue));
3756

38-
// Co-simulation routes (MQTT client injected as null until runtime wiring)
39-
app.use("/api/v1/cosim", cosimRouter(null));
40-
app.use("/api/v1/mqtt/participants", mqttParticipantsRouter(null));
41-
app.use("/api/v1/historian", historianRouter());
57+
// Co-simulation routes (with MQTT client injection)
58+
app.use("/api/v1/cosim", cosimRouter(mqttClient));
59+
app.use("/api/v1/mqtt/participants", mqttParticipantsRouter(mqttClient));
60+
app.use("/api/v1/historian", historianRouter(dbPool));
4261
app.use("/api/v1/fmus", fmuRouter());
4362

4463
// Health check
4564
app.get("/health", (_req, res) => {
46-
res.json({ status: "ok" });
65+
res.json({
66+
status: "ok",
67+
mqtt: mqttClient ? "connected" : "unavailable",
68+
historian: dbPool ? "connected" : "unavailable",
69+
});
4770
});
4871

4972
return app;

packages/api/src/main.ts

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,37 @@
66
* Wires together:
77
* - Express app with all route modules
88
* - MQTT client for co-simulation participant discovery
9+
* - TimescaleDB pool for historian queries
10+
* - Historian recorder for live telemetry ingestion
911
* - WebSocket server for live variable streaming
1012
* - Graceful shutdown handling
1113
*/
1214

13-
import { CosimMqttClient, attachCosimWebSocket } from "@modelscript/cosim";
15+
import { CosimMqttClient, HistorianRecorder, attachCosimWebSocket } from "@modelscript/cosim";
1416
import { createApp } from "./app.js";
1517

1618
const port = parseInt(process.env["PORT"] ?? "3000", 10);
1719
const mqttUrl = process.env["MQTT_BROKER_URL"];
18-
19-
const app = createApp();
20-
21-
// ── Start HTTP server ──
22-
23-
const server = app.listen(port, () => {
24-
console.log(`ModelScript API server listening on port ${port}`);
25-
});
20+
const timescaleUrl = process.env["TIMESCALE_URL"];
21+
22+
// ── TimescaleDB pool (optional — only if TIMESCALE_URL is set) ──
23+
24+
let dbPool: import("pg").Pool | null = null;
25+
26+
if (timescaleUrl) {
27+
// Lazy-load pg to avoid hard dependency when TimescaleDB is unavailable
28+
try {
29+
const { Pool } = await import("pg");
30+
dbPool = new Pool({ connectionString: timescaleUrl, max: 10 });
31+
// Test the connection
32+
await dbPool.query("SELECT 1");
33+
console.log("TimescaleDB connected.");
34+
} catch (err: unknown) {
35+
console.error("TimescaleDB connection failed:", err instanceof Error ? err.message : err);
36+
console.warn("Historian features will be unavailable.");
37+
dbPool = null;
38+
}
39+
}
2640

2741
// ── MQTT client (optional — only if MQTT_BROKER_URL is set) ──
2842

@@ -38,23 +52,41 @@ if (mqttUrl) {
3852
},
3953
});
4054

41-
mqttClient
42-
.connect()
43-
.then(async () => {
44-
console.log(`MQTT connected to ${mqttUrl}`);
45-
if (mqttClient) {
46-
await mqttClient.subscribeParticipants();
47-
console.log(`MQTT participant discovery active (${mqttClient.participants.size} online)`);
48-
}
49-
})
50-
.catch((err: unknown) => {
51-
console.error("MQTT connection failed:", err instanceof Error ? err.message : err);
52-
console.warn("Co-simulation features will be unavailable.");
53-
mqttClient = null;
54-
});
55+
try {
56+
await mqttClient.connect();
57+
console.log(`MQTT connected to ${mqttUrl}`);
58+
await mqttClient.subscribeParticipants();
59+
console.log(`MQTT participant discovery active (${mqttClient.participants.size} online)`);
60+
} catch (err: unknown) {
61+
console.error("MQTT connection failed:", err instanceof Error ? err.message : err);
62+
console.warn("Co-simulation features will be unavailable.");
63+
mqttClient = null;
64+
}
5565
}
5666

57-
// ── WebSocket streaming (attaches to the HTTP server upgrade event) ──
67+
// ── Historian recorder (only when both MQTT and TimescaleDB are available) ──
68+
69+
let recorder: HistorianRecorder | null = null;
70+
71+
if (mqttClient && dbPool) {
72+
recorder = new HistorianRecorder(dbPool, mqttClient, {
73+
batchSize: 500,
74+
flushIntervalMs: 200,
75+
});
76+
console.log("Historian recorder ready (starts per-session).");
77+
}
78+
79+
// ── Create Express app ──
80+
81+
const app = createApp({ mqttClient, dbPool });
82+
83+
// ── Start HTTP server ──
84+
85+
const server = app.listen(port, () => {
86+
console.log(`ModelScript API server listening on port ${port}`);
87+
});
88+
89+
// ── WebSocket streaming ──
5890

5991
attachCosimWebSocket(server, mqttClient);
6092
console.log("WebSocket co-simulation stream available at /api/v1/cosim/stream");
@@ -64,10 +96,16 @@ console.log("WebSocket co-simulation stream available at /api/v1/cosim/stream");
6496
async function shutdown(signal: string): Promise<void> {
6597
console.log(`\n${signal} received. Shutting down gracefully...`);
6698

67-
// Stop accepting new connections
6899
server.close();
69100

70-
// Disconnect MQTT
101+
if (recorder) {
102+
try {
103+
await recorder.stopRecording();
104+
} catch {
105+
// Best-effort
106+
}
107+
}
108+
71109
if (mqttClient) {
72110
try {
73111
await mqttClient.disconnect();
@@ -77,6 +115,15 @@ async function shutdown(signal: string): Promise<void> {
77115
}
78116
}
79117

118+
if (dbPool) {
119+
try {
120+
await dbPool.end();
121+
console.log("TimescaleDB pool closed.");
122+
} catch {
123+
// Best-effort
124+
}
125+
}
126+
80127
process.exit(0);
81128
}
82129

packages/api/src/routes/cosim.ts

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@
33
/**
44
* Co-simulation REST routes.
55
*
6-
* Provides endpoints for session management, MQTT participant discovery,
7-
* and historian queries.
6+
* Provides endpoints for session management, participant enrollment,
7+
* MQTT participant discovery, variable couplings, and orchestrator control.
88
*/
99

1010
import type { CosimMqttClient } from "@modelscript/cosim";
11-
import { Orchestrator, SessionManager } from "@modelscript/cosim";
11+
import { FmuJsParticipant, FmuStorage, Orchestrator, SessionManager } from "@modelscript/cosim";
1212
import express from "express";
1313

1414
const sessionManager = new SessionManager();
15+
const fmuStorage = new FmuStorage();
16+
17+
/** Track running orchestrators by session ID for pause/stop. */
18+
const orchestrators = new Map<string, Orchestrator>();
1519

1620
/**
1721
* Create the co-simulation router.
@@ -46,6 +50,73 @@ export function cosimRouter(mqttClient: CosimMqttClient | null): express.Router
4650
res.json(session.toJSON());
4751
});
4852

53+
// ── Participant Enrollment ──
54+
55+
// POST /api/v1/cosim/sessions/:id/participants/fmu — Add an FMU participant
56+
router.post("/sessions/:id/participants/fmu", (req, res) => {
57+
const session = sessionManager.getSession(req.params["id"] ?? "");
58+
if (!session) {
59+
return res.status(404).json({ error: "Session not found" });
60+
}
61+
62+
const { fmuId, participantId } = req.body as { fmuId?: string; participantId?: string };
63+
if (!fmuId) {
64+
return res.status(400).json({ error: "Missing required field: fmuId" });
65+
}
66+
67+
const pid = participantId ?? `fmu-${fmuId}-${Math.random().toString(36).slice(2, 6)}`;
68+
69+
try {
70+
const participant = new FmuJsParticipant({
71+
id: pid,
72+
fmuId,
73+
storage: fmuStorage,
74+
});
75+
session.addParticipant(participant);
76+
res.status(201).json({
77+
ok: true,
78+
participantId: pid,
79+
modelName: participant.modelName,
80+
variables: participant.metadata.variables.length,
81+
});
82+
} catch (err: unknown) {
83+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
84+
}
85+
});
86+
87+
// GET /api/v1/cosim/sessions/:id/participants — List session participants
88+
router.get("/sessions/:id/participants", (req, res) => {
89+
const session = sessionManager.getSession(req.params["id"] ?? "");
90+
if (!session) {
91+
return res.status(404).json({ error: "Session not found" });
92+
}
93+
94+
const participants = Array.from(session.participants.values()).map((p) => ({
95+
id: p.id,
96+
modelName: p.modelName,
97+
type: p.metadata.type,
98+
variables: p.metadata.variables.length,
99+
}));
100+
res.json({ participants });
101+
});
102+
103+
// DELETE /api/v1/cosim/sessions/:id/participants/:pid — Remove a participant
104+
router.delete("/sessions/:id/participants/:pid", (req, res) => {
105+
const session = sessionManager.getSession(req.params["id"] ?? "");
106+
if (!session) {
107+
return res.status(404).json({ error: "Session not found" });
108+
}
109+
110+
try {
111+
session.removeParticipant(req.params["pid"] ?? "");
112+
res.json({ ok: true });
113+
} catch (err: unknown) {
114+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
115+
}
116+
});
117+
118+
// ── Couplings ──
119+
49120
// POST /api/v1/cosim/sessions/:id/couplings — Define variable couplings
50121
router.post("/sessions/:id/couplings", (req, res) => {
51122
const session = sessionManager.getSession(req.params["id"] ?? "");
@@ -73,6 +144,8 @@ export function cosimRouter(mqttClient: CosimMqttClient | null): express.Router
73144
}
74145
});
75146

147+
// ── Orchestrator Control ──
148+
76149
// POST /api/v1/cosim/sessions/:id/start — Start co-simulation
77150
router.post("/sessions/:id/start", (req, res) => {
78151
const session = sessionManager.getSession(req.params["id"] ?? "");
@@ -87,12 +160,16 @@ export function cosimRouter(mqttClient: CosimMqttClient | null): express.Router
87160
const orchestrator = new Orchestrator(session, mqttClient, {
88161
onComplete: () => {
89162
console.log(`[cosim] Session ${session.sessionId} completed`);
163+
orchestrators.delete(session.sessionId);
90164
},
91165
onError: (err: Error) => {
92166
console.error(`[cosim] Session ${session.sessionId} error:`, err.message);
167+
orchestrators.delete(session.sessionId);
93168
},
94169
});
95170

171+
orchestrators.set(session.sessionId, orchestrator);
172+
96173
// Run asynchronously — don't await
97174
void orchestrator.run();
98175

@@ -101,25 +178,43 @@ export function cosimRouter(mqttClient: CosimMqttClient | null): express.Router
101178

102179
// POST /api/v1/cosim/sessions/:id/pause — Pause simulation
103180
router.post("/sessions/:id/pause", (req, res) => {
104-
const session = sessionManager.getSession(req.params["id"] ?? "");
105-
if (!session) {
106-
return res.status(404).json({ error: "Session not found" });
181+
const sessionId = req.params["id"] ?? "";
182+
const orchestrator = orchestrators.get(sessionId);
183+
if (!orchestrator) {
184+
return res.status(404).json({ error: "No running orchestrator for this session" });
107185
}
108-
res.json({ ok: true, state: session.state });
186+
orchestrator.pause();
187+
res.json({ ok: true, state: "paused" });
188+
});
189+
190+
// POST /api/v1/cosim/sessions/:id/resume — Resume simulation
191+
router.post("/sessions/:id/resume", (req, res) => {
192+
const sessionId = req.params["id"] ?? "";
193+
const orchestrator = orchestrators.get(sessionId);
194+
if (!orchestrator) {
195+
return res.status(404).json({ error: "No running orchestrator for this session" });
196+
}
197+
orchestrator.resume();
198+
res.json({ ok: true, state: "running" });
109199
});
110200

111201
// POST /api/v1/cosim/sessions/:id/stop — Stop and terminate
112202
router.post("/sessions/:id/stop", (req, res) => {
113-
const session = sessionManager.getSession(req.params["id"] ?? "");
114-
if (!session) {
115-
return res.status(404).json({ error: "Session not found" });
203+
const sessionId = req.params["id"] ?? "";
204+
const orchestrator = orchestrators.get(sessionId);
205+
if (!orchestrator) {
206+
return res.status(404).json({ error: "No running orchestrator for this session" });
116207
}
117-
res.json({ ok: true, state: session.state });
208+
orchestrator.abort();
209+
orchestrators.delete(sessionId);
210+
res.json({ ok: true, state: "stopping" });
118211
});
119212

120213
// DELETE /api/v1/cosim/sessions/:id — Remove completed/failed session
121214
router.delete("/sessions/:id", (req, res) => {
122-
sessionManager.removeSession(req.params["id"] ?? "");
215+
const sessionId = req.params["id"] ?? "";
216+
orchestrators.delete(sessionId);
217+
sessionManager.removeSession(sessionId);
123218
res.json({ ok: true });
124219
});
125220

@@ -185,7 +280,6 @@ export function mqttParticipantsRouter(mqttClient: CosimMqttClient | null): expr
185280
return res.status(404).json({ error: "Participant not found" });
186281
}
187282

188-
// Return in the same format as the library tree nodes
189283
const treeNode = {
190284
id: `mqtt://${meta.participantId}`,
191285
name: meta.modelName,

0 commit comments

Comments
 (0)