Add multi-decoder split and pit timing support
This commit is contained in:
256
server.js
256
server.js
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user