Add multi-decoder split and pit timing support

This commit is contained in:
larssand
2026-08-25 19:45:40 +02:00
parent 6f02e955a5
commit 8ea8b3a84e
20 changed files with 876 additions and 147 deletions

256
server.js
View File

@@ -33,16 +33,27 @@ const appVersion = {
updatedAt: new Date().toISOString(),
};
watchAppFiles();
function createAmmcChannelState() {
return {
process: null,
pid: null,
startedAt: null,
stoppedAt: null,
lastExitCode: null,
lastExitSignal: null,
lastError: "",
lastOutput: [],
};
}
const ammcState = {
process: null,
pid: null,
startedAt: null,
stoppedAt: null,
lastExitCode: null,
lastExitSignal: null,
lastError: "",
lastOutput: [],
config: loadAmmcConfig(),
channels: {
main: createAmmcChannelState(),
split: createAmmcChannelState(),
pitEntry: createAmmcChannelState(),
pitExit: createAmmcChannelState(),
},
};
const app = express();
@@ -101,7 +112,7 @@ app.get("/api/ammc/status", (_req, res) => {
app.post("/api/ammc/start", (_req, res) => {
try {
startAmmcProcess();
startAmmcProcesses();
res.json({ ok: true, status: buildAmmcStatus() });
} catch (error) {
res.status(400).json({
@@ -113,7 +124,7 @@ app.post("/api/ammc/start", (_req, res) => {
app.post("/api/ammc/stop", async (_req, res) => {
try {
await stopAmmcProcess();
await stopAmmcProcesses();
res.json({ ok: true, status: buildAmmcStatus() });
} catch (error) {
res.status(500).json({
@@ -760,19 +771,43 @@ function getDefaultAmmcExecutablePath(platform = process.platform) {
function normalizeAmmcConfig(input = {}, previous = {}) {
const decoderHost = String(input.decoderHost ?? previous.decoderHost ?? "").trim();
const splitDecoderHost = String(input.splitDecoderHost ?? previous.splitDecoderHost ?? "").trim();
const pitEntryDecoderHost = String(input.pitEntryDecoderHost ?? previous.pitEntryDecoderHost ?? "").trim();
const pitExitDecoderHost = String(input.pitExitDecoderHost ?? previous.pitExitDecoderHost ?? "").trim();
const executablePath = String(input.executablePath ?? previous.executablePath ?? getDefaultAmmcExecutablePath()).trim();
const workingDirectory = String(input.workingDirectory ?? previous.workingDirectory ?? "").trim();
const extraArgs = String(input.extraArgs ?? previous.extraArgs ?? "").trim();
const wsPortValue = Number(input.wsPort ?? previous.wsPort ?? 9000);
if (!Number.isFinite(wsPortValue) || wsPortValue < 1 || wsPortValue > 65535) {
throw new Error("AMMC WebSocket port must be between 1 and 65535");
const normalizePort = (value, fallback, label) => {
const port = Number(value ?? fallback);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
throw new Error(`${label} WebSocket port must be between 1 and 65535`);
}
return Math.round(port);
};
const wsPort = normalizePort(input.wsPort ?? previous.wsPort, 9000, "Main decoder");
const splitWsPort = normalizePort(input.splitWsPort ?? previous.splitWsPort, 9001, "Split decoder");
const pitEntryWsPort = normalizePort(input.pitEntryWsPort ?? previous.pitEntryWsPort, 9002, "Pit entry decoder");
const pitExitWsPort = normalizePort(input.pitExitWsPort ?? previous.pitExitWsPort, 9003, "Pit exit decoder");
const splitEnabled = Boolean(input.splitEnabled ?? previous.splitEnabled ?? false);
const pitEnabled = Boolean(input.pitEnabled ?? previous.pitEnabled ?? false);
const enabledPorts = [wsPort, ...(splitEnabled ? [splitWsPort] : []), ...(pitEnabled ? [pitEntryWsPort, pitExitWsPort] : [])];
if (new Set(enabledPorts).size !== enabledPorts.length) {
throw new Error("Every enabled AMMC decoder channel must use a unique WebSocket port");
}
return {
managedEnabled: Boolean(input.managedEnabled ?? previous.managedEnabled ?? false),
autoStart: Boolean(input.autoStart ?? previous.autoStart ?? false),
decoderHost,
wsPort: Math.round(wsPortValue),
wsPort,
splitEnabled,
splitDecoderHost,
splitWsPort,
pitEnabled,
pitEntryDecoderHost,
pitEntryWsPort,
pitExitDecoderHost,
pitExitWsPort,
executablePath,
workingDirectory,
extraArgs,
@@ -787,6 +822,14 @@ function loadAmmcConfig() {
autoStart: false,
decoderHost: "",
wsPort: 9000,
splitEnabled: false,
splitDecoderHost: "",
splitWsPort: 9001,
pitEnabled: false,
pitEntryDecoderHost: "",
pitEntryWsPort: 9002,
pitExitDecoderHost: "",
pitExitWsPort: 9003,
executablePath: getDefaultAmmcExecutablePath(),
workingDirectory: "",
extraArgs: "",
@@ -803,6 +846,14 @@ function loadAmmcConfig() {
autoStart: false,
decoderHost: "",
wsPort: 9000,
splitEnabled: false,
splitDecoderHost: "",
splitWsPort: 9001,
pitEnabled: false,
pitEntryDecoderHost: "",
pitEntryWsPort: 9002,
pitExitDecoderHost: "",
pitExitWsPort: 9003,
executablePath: getDefaultAmmcExecutablePath(),
workingDirectory: "",
extraArgs: "",
@@ -840,22 +891,44 @@ function parseExtraArgs(extraArgs) {
.filter(Boolean);
}
function isAmmcRunning() {
return Boolean(ammcState.process && ammcState.process.exitCode == null && !ammcState.process.killed);
const AMMC_CHANNEL_CONFIG = {
main: { label: "Main", hostKey: "decoderHost", portKey: "wsPort" },
split: { label: "Split", hostKey: "splitDecoderHost", portKey: "splitWsPort", enabledKey: "splitEnabled" },
pitEntry: { label: "Pit entry", hostKey: "pitEntryDecoderHost", portKey: "pitEntryWsPort", enabledKey: "pitEnabled" },
pitExit: { label: "Pit exit", hostKey: "pitExitDecoderHost", portKey: "pitExitWsPort", enabledKey: "pitEnabled" },
};
function getAmmcChannel(role = "main") {
return ammcState.channels[role] || ammcState.channels.main;
}
function buildAmmcStatus() {
const resolvedExecutablePath = resolveAmmcExecutable(ammcState.config.executablePath);
const workingDirectory = resolveAmmcWorkingDirectory(ammcState.config, resolvedExecutablePath);
function isAmmcChannelEnabled(role, config = ammcState.config) {
const descriptor = AMMC_CHANNEL_CONFIG[role] || AMMC_CHANNEL_CONFIG.main;
return Boolean(config.managedEnabled && (!descriptor.enabledKey || config[descriptor.enabledKey]));
}
function isAmmcRunning(role = "main") {
const channel = getAmmcChannel(role);
return Boolean(channel.process && channel.process.exitCode == null && !channel.process.killed);
}
function buildAmmcChannelStatus(role, resolvedExecutablePath, workingDirectory) {
const channel = getAmmcChannel(role);
const descriptor = AMMC_CHANNEL_CONFIG[role];
return {
running: isAmmcRunning(),
pid: ammcState.pid,
startedAt: ammcState.startedAt,
stoppedAt: ammcState.stoppedAt,
lastExitCode: ammcState.lastExitCode,
lastExitSignal: ammcState.lastExitSignal,
lastError: ammcState.lastError,
lastOutput: ammcState.lastOutput,
role,
label: descriptor.label,
enabled: isAmmcChannelEnabled(role),
running: isAmmcRunning(role),
pid: channel.pid,
startedAt: channel.startedAt,
stoppedAt: channel.stoppedAt,
lastExitCode: channel.lastExitCode,
lastExitSignal: channel.lastExitSignal,
lastError: channel.lastError,
lastOutput: channel.lastOutput,
decoderHost: ammcState.config[descriptor.hostKey] || "",
wsPort: ammcState.config[descriptor.portKey],
serverPlatform: process.platform,
executableExists: fs.existsSync(resolvedExecutablePath),
resolvedExecutablePath,
@@ -863,51 +936,67 @@ function buildAmmcStatus() {
};
}
function appendAmmcOutput(stream, chunk) {
function buildAmmcStatus() {
const resolvedExecutablePath = resolveAmmcExecutable(ammcState.config.executablePath);
const workingDirectory = resolveAmmcWorkingDirectory(ammcState.config, resolvedExecutablePath);
const main = buildAmmcChannelStatus("main", resolvedExecutablePath, workingDirectory);
const split = buildAmmcChannelStatus("split", resolvedExecutablePath, workingDirectory);
const pitEntry = buildAmmcChannelStatus("pitEntry", resolvedExecutablePath, workingDirectory);
const pitExit = buildAmmcChannelStatus("pitExit", resolvedExecutablePath, workingDirectory);
const lastOutput = [main, split, pitEntry, pitExit]
.flatMap((channel) => channel.lastOutput)
.sort((a, b) => String(a.ts).localeCompare(String(b.ts)))
.slice(-60);
return {
...main,
lastOutput,
split,
pitEntry,
pitExit,
};
}
function appendAmmcOutput(role, stream, chunk) {
const channel = getAmmcChannel(role);
const lines = String(chunk || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
lines.forEach((line) => {
ammcState.lastOutput.push({
channel.lastOutput.push({
ts: new Date().toISOString(),
role,
stream,
line,
});
});
if (ammcState.lastOutput.length > 30) {
ammcState.lastOutput.splice(0, ammcState.lastOutput.length - 30);
if (channel.lastOutput.length > 30) {
channel.lastOutput.splice(0, channel.lastOutput.length - 30);
}
}
function startAmmcProcess() {
if (isAmmcRunning()) {
return buildAmmcStatus();
}
const config = normalizeAmmcConfig(ammcState.config, ammcState.config);
ammcState.config = config;
saveAmmcConfig(config);
if (!config.managedEnabled) {
throw new Error("Enable managed AMMC first");
}
if (!config.decoderHost) {
throw new Error("Set decoder IP/host before starting AMMC");
}
function startAmmcProcess(role = "main") {
if (isAmmcRunning(role)) return buildAmmcStatus();
const config = ammcState.config;
const descriptor = AMMC_CHANNEL_CONFIG[role];
const channel = getAmmcChannel(role);
if (!isAmmcChannelEnabled(role, config)) return buildAmmcStatus();
const decoderHost = config[descriptor.hostKey];
const wsPort = config[descriptor.portKey];
if (!decoderHost) throw new Error(`Set ${descriptor.label} decoder IP/host before starting AMMC`);
const executablePath = resolveAmmcExecutable(config.executablePath);
if (!fs.existsSync(executablePath)) {
throw new Error(`AMMC executable not found: ${executablePath}`);
}
const args = ["-w", String(config.wsPort), config.decoderHost, ...parseExtraArgs(config.extraArgs)];
const args = ["-w", String(wsPort), decoderHost, ...parseExtraArgs(config.extraArgs)];
const workingDirectory = resolveAmmcWorkingDirectory(config, executablePath);
ammcState.lastError = "";
appendAmmcOutput("system", `Starting ${path.basename(executablePath)} ${args.join(" ")}`);
channel.lastError = "";
appendAmmcOutput(role, "system", `Starting ${descriptor.label}: ${path.basename(executablePath)} ${args.join(" ")}`);
const child = spawn(executablePath, args, {
cwd: workingDirectory,
@@ -915,37 +1004,53 @@ function startAmmcProcess() {
stdio: ["ignore", "pipe", "pipe"],
});
ammcState.process = child;
ammcState.pid = child.pid || null;
ammcState.startedAt = new Date().toISOString();
ammcState.stoppedAt = null;
ammcState.lastExitCode = null;
ammcState.lastExitSignal = null;
channel.process = child;
channel.pid = child.pid || null;
channel.startedAt = new Date().toISOString();
channel.stoppedAt = null;
channel.lastExitCode = null;
channel.lastExitSignal = null;
child.stdout.on("data", (chunk) => appendAmmcOutput("stdout", chunk));
child.stderr.on("data", (chunk) => appendAmmcOutput("stderr", chunk));
child.stdout.on("data", (chunk) => appendAmmcOutput(role, "stdout", chunk));
child.stderr.on("data", (chunk) => appendAmmcOutput(role, "stderr", chunk));
child.on("error", (error) => {
ammcState.lastError = error instanceof Error ? error.message : String(error);
appendAmmcOutput("error", ammcState.lastError);
channel.lastError = error instanceof Error ? error.message : String(error);
appendAmmcOutput(role, "error", channel.lastError);
});
child.on("exit", (code, signal) => {
ammcState.lastExitCode = code;
ammcState.lastExitSignal = signal;
ammcState.stoppedAt = new Date().toISOString();
appendAmmcOutput("system", `AMMC stopped (code=${code}, signal=${signal || "none"})`);
ammcState.process = null;
ammcState.pid = null;
channel.lastExitCode = code;
channel.lastExitSignal = signal;
channel.stoppedAt = new Date().toISOString();
appendAmmcOutput(role, "system", `${descriptor.label} AMMC stopped (code=${code}, signal=${signal || "none"})`);
channel.process = null;
channel.pid = null;
});
return buildAmmcStatus();
}
function stopAmmcProcess() {
if (!isAmmcRunning() || !ammcState.process) {
function startAmmcProcesses() {
const config = normalizeAmmcConfig(ammcState.config, ammcState.config);
ammcState.config = config;
saveAmmcConfig(config);
if (!config.managedEnabled) throw new Error("Enable managed AMMC first");
Object.keys(AMMC_CHANNEL_CONFIG)
.filter((role) => isAmmcChannelEnabled(role, config))
.forEach((role) => {
const descriptor = AMMC_CHANNEL_CONFIG[role];
if (!config[descriptor.hostKey]) throw new Error(`Set ${descriptor.label} decoder IP/host before starting AMMC`);
});
Object.keys(AMMC_CHANNEL_CONFIG).forEach((role) => startAmmcProcess(role));
return buildAmmcStatus();
}
function stopAmmcProcess(role = "main") {
const channel = getAmmcChannel(role);
if (!isAmmcRunning(role) || !channel.process) {
return Promise.resolve(buildAmmcStatus());
}
const child = ammcState.process;
const child = channel.process;
return new Promise((resolve) => {
let settled = false;
const finish = () => {
@@ -959,7 +1064,7 @@ function stopAmmcProcess() {
child.kill();
setTimeout(() => {
if (!settled && isAmmcRunning()) {
if (!settled && isAmmcRunning(role)) {
if (process.platform !== "win32") {
child.kill("SIGKILL");
}
@@ -969,23 +1074,28 @@ function stopAmmcProcess() {
});
}
async function stopAmmcProcesses() {
await Promise.all(Object.keys(AMMC_CHANNEL_CONFIG).map((role) => stopAmmcProcess(role)));
return buildAmmcStatus();
}
function maybeStartManagedAmmc() {
if (!ammcState.config.managedEnabled || !ammcState.config.autoStart) {
return;
}
try {
startAmmcProcess();
console.log("Managed AMMC started");
startAmmcProcesses();
console.log("Managed AMMC decoder channels started");
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
ammcState.lastError = msg;
getAmmcChannel("main").lastError = msg;
console.error(`Managed AMMC failed to start: ${msg}`);
}
}
async function shutdown() {
await stopAmmcProcess();
await stopAmmcProcesses();
removePidFile();
process.exit(0);
}