Files
Live_RC/server.js
larssand 627179b95a feat(layouts): switch track layout mid-practice and view stats per layout
A club can run one Open Practice all season and re-lay the track now and then.
Previously a session's layout was frozen at start and every training lap inherited
it, so a season-long practice stayed on its first layout forever, and changing layout
was hidden under Settings -> Appearance.

- Each practice lap now counts toward the layout active when it was driven; a running
  practice session moves onto a new layout when one is added
- "New layout" strip on Training stats, Race Day and the Dashboard, offered only around
  practice; a running race session blocks layout changes everywhere
- "Stats for" selector (current / older layout / before layouts / all layouts) on
  Training stats and in the driver's mobile profile (?layout= on the public API)
- A layout that has practice laps on it can no longer be deleted
- Fix: the buildDriverTrainingStats wrapper in app.js dropped extra arguments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:24:44 +02:00

1536 lines
54 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const os = require("os");
const net = require("net");
const { spawn, execFile } = require("child_process");
const express = require("express");
const QRCode = require("qrcode");
const Database = require("better-sqlite3");
const publicTrainingProfile = require("./server/public_training_profile");
const { createBackupService } = require("./server/backup_service");
const { createAuth, COOKIE_NAME: AUTH_COOKIE_NAME, TOKEN_MAX_AGE_MS } = require("./server/auth");
const { createTts, resolvePiperBinary } = require("./server/tts");
const { installAll: installPiperAll } = require("./server/tts_install");
const PORT = Number(process.env.PORT || 8081);
const HOST = process.env.HOST || "0.0.0.0";
const DB_DIR = process.env.RC_TIMING_DATA_DIR ? path.resolve(process.env.RC_TIMING_DATA_DIR) : path.join(__dirname, "data");
const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite");
const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json");
const auth = createAuth({ dataDir: DB_DIR, allowedCidrs: process.env.RC_ADMIN_ALLOWED_CIDRS || "" });
const tts = createTts({
dataDir: DB_DIR,
projectDir: __dirname,
voiceDirs: [process.env.RC_PIPER_VOICES_DIR, path.join(DB_DIR, "piper-voices"), path.join(__dirname, "piper", "voices"), path.join(os.homedir(), ".local", "share", "piper-voices")].filter(Boolean),
});
const PID_FILE = process.env.RC_TIMING_PID_FILE || "";
const SRC_DIR = path.join(__dirname, "src");
const RACE_SOUND_DIR = path.join(__dirname, "rc-race-sound-pack");
const WATCHED_FILES = [
path.join(__dirname, "index.html"),
path.join(__dirname, "public-training.html"),
path.join(RACE_SOUND_DIR, "sound_manifest.json"),
...fs.readdirSync(SRC_DIR)
.filter((fileName) => /\.(?:js|css)$/i.test(fileName))
.map((fileName) => path.join(SRC_DIR, fileName)),
];
fs.mkdirSync(DB_DIR, { recursive: true });
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initSchema();
const backupService = createBackupService({ db, dataDir: DB_DIR });
const appVersion = {
version: require("./package.json").version,
revision: 1,
updatedAt: new Date().toISOString(),
};
watchAppFiles();
// Everything below supports the in-app "an update is available" check and the one-click
// updater. It always shells out to the git binary already required for this deployment model
// (clone + git pull) rather than any git-hosting-specific API, so it works the same way against
// any remote (GitHub, GitLab, a self-hosted server) with no code that assumes which one is used.
// Overridable so tests can point this at a throwaway local repo/remote instead of this
// installation's real one - never at the real remote, and never touching the working tree.
const UPDATE_GIT_DIR = process.env.RC_UPDATE_GIT_DIR ? path.resolve(process.env.RC_UPDATE_GIT_DIR) : __dirname;
function runGitCommand(args, timeoutMs = 8000) {
return new Promise((resolve) => {
execFile("git", args, { cwd: UPDATE_GIT_DIR, timeout: timeoutMs }, (error, stdout, stderr) => {
resolve({ ok: !error, stdout: String(stdout || "").trim(), stderr: String(stderr || "").trim() });
});
});
}
const UPDATE_CHECK_CACHE_MS = 10 * 60 * 1000;
let updateStatusCache = { checkedAt: 0, result: null };
async function checkForUpdate() {
const branchResult = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"]);
const branch = branchResult.ok && branchResult.stdout && branchResult.stdout !== "HEAD" ? branchResult.stdout : "main";
// A real network call (git fetch is one) can't be avoided for a genuine "is there something
// new" check, so this is cached and only ever triggered by a background poll or an explicit
// user click - never on every page load.
const fetchResult = await runGitCommand(["fetch", "--quiet", "origin", branch], 15000);
if (!fetchResult.ok) {
return { checked: false, available: false, currentVersion: appVersion.version, error: "Could not reach the update server" };
}
const localResult = await runGitCommand(["rev-parse", "HEAD"]);
const remoteResult = await runGitCommand(["rev-parse", `origin/${branch}`]);
if (!localResult.ok || !remoteResult.ok) {
return { checked: false, available: false, currentVersion: appVersion.version, error: "Not a git installation" };
}
const available = localResult.stdout !== remoteResult.stdout;
let remoteVersion = "";
if (available) {
const packageResult = await runGitCommand(["show", `origin/${branch}:package.json`]);
if (packageResult.ok) {
try {
remoteVersion = String(JSON.parse(packageResult.stdout)?.version || "");
} catch {
// leave remoteVersion empty if the remote package.json can't be parsed
}
}
}
return {
checked: true,
available,
currentVersion: appVersion.version,
remoteVersion,
localCommit: localResult.stdout.slice(0, 10),
remoteCommit: remoteResult.stdout.slice(0, 10),
};
}
function createAmmcChannelState() {
return {
process: null,
pid: null,
startedAt: null,
stoppedAt: null,
lastExitCode: null,
lastExitSignal: null,
lastError: "",
lastOutput: [],
};
}
const ammcState = {
config: loadAmmcConfig(),
channels: {
main: createAmmcChannelState(),
split: createAmmcChannelState(),
pitEntry: createAmmcChannelState(),
pitExit: createAmmcChannelState(),
},
};
const app = express();
const allowedCorsOrigins = new Set(
String(process.env.RC_ALLOWED_ORIGINS || "")
.split(",")
.map((origin) => origin.trim().replace(/\/$/, ""))
.filter(Boolean)
);
app.use(express.json({ limit: "20mb" }));
app.use((req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
const requestOrigin = `${req.protocol}://${req.get("host")}`;
const origin = String(req.get("origin") || "").replace(/\/$/, "");
const corsAllowed = !origin || origin === requestOrigin || allowedCorsOrigins.has(origin);
if (!corsAllowed) {
res.status(403).end();
return;
}
if (origin) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}
if (req.method === "OPTIONS") {
res.status(corsAllowed ? 204 : 403).end();
return;
}
next();
});
app.use(auth.middleware);
function setAuthCookie(res, token) {
const maxAge = token ? Math.floor(TOKEN_MAX_AGE_MS / 1000) : 0;
res.setHeader("Set-Cookie", `${AUTH_COOKIE_NAME}=${token || ""}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`);
}
app.get("/api/auth/status", (req, res) => {
res.json({ enabled: auth.isEnabled(), authenticated: auth.isEnabled() ? auth.isAuthenticated(req) : true, ipAllowed: auth.isIpAllowed(req.ip) });
});
app.post("/api/auth/login", (req, res) => {
if (auth.isLoginBlocked(req.ip)) return res.status(429).json({ error: "too_many_attempts" });
if (!auth.isEnabled()) return res.json({ ok: true, enabled: false });
if (!auth.verifyPin(req.body?.pin)) {
auth.registerLoginFailure(req.ip);
return res.status(401).json({ error: "wrong_pin" });
}
setAuthCookie(res, auth.issueToken());
return res.json({ ok: true, enabled: true });
});
app.post("/api/auth/logout", (_req, res) => {
setAuthCookie(res, "");
res.json({ ok: true });
});
// Protected by the middleware once a PIN exists; while auth is off, setting the first PIN
// is open, which matches how every other admin setting behaves in that state.
app.post("/api/auth/pin", (req, res) => {
try {
auth.setPin(req.body?.newPin);
} catch (error) {
return res.status(400).json({ error: error.message });
}
setAuthCookie(res, auth.issueToken());
return res.json({ ok: true, enabled: true });
});
app.post("/api/auth/disable", (_req, res) => {
auth.disable();
setAuthCookie(res, "");
res.json({ ok: true, enabled: false });
});
app.get("/api/tts/status", (_req, res) => {
res.json({ ...tts.status(), install: ttsInstall });
});
// POST (not GET) so generating audio falls under the admin PIN like every other write.
app.post("/api/tts/speech", async (req, res) => {
try {
const file = await tts.speech({ text: req.body?.text, language: req.body?.language, rate: req.body?.rate, voice: req.body?.voice });
res.type("audio/wav").sendFile(file);
} catch (error) {
res.status(error.code === "unavailable" ? 503 : error.code === "bad_request" ? 400 : 500).json({ error: error.message });
}
});
// One-time download of Piper + Swedish/English voices, run in the background; progress is
// polled through /api/tts/status. Needs internet only while it runs.
const ttsInstall = { running: false, log: [], error: "" };
app.post("/api/tts/install", (_req, res) => {
if (!ttsInstall.running) {
Object.assign(ttsInstall, { running: true, log: [], error: "" });
installPiperAll({ projectDir: __dirname, hasBinary: Boolean(resolvePiperBinary(process.env.RC_PIPER_BIN || "", __dirname)), log: (line) => ttsInstall.log.push(line) })
.catch((error) => { ttsInstall.error = error.message; })
.finally(() => { ttsInstall.running = false; });
}
res.status(202).json(ttsInstall);
});
app.post("/api/tts/pregenerate", (req, res) => {
tts.pregenerate(req.body?.items);
res.status(202).json(tts.status().pregenerate);
});
app.get("/api/health", (_req, res) => {
res.json({ ok: true, dbPath: DB_PATH });
});
app.get("/api/app-version", (_req, res) => {
res.json(appVersion);
});
app.get("/api/update-status", async (req, res) => {
const force = req.query.force === "1";
if (!force && updateStatusCache.result && Date.now() - updateStatusCache.checkedAt < UPDATE_CHECK_CACHE_MS) {
res.json(updateStatusCache.result);
return;
}
const result = await checkForUpdate();
updateStatusCache = { checkedAt: Date.now(), result };
res.json(result);
});
app.post("/api/update/run", (req, res) => {
res.json({ ok: true, message: "Update starting - the server will restart shortly." });
// A detached child outlives this response and this process: it stops the currently running
// server (this one), pulls and installs the update, then starts a fresh server - the exact
// same stop/start machinery "npm run restart" already uses, with git pull/npm install
// sandwiched in between. See scripts/serverctl.js's "update" command.
const child = spawn(process.execPath, [path.join(__dirname, "scripts", "serverctl.js"), "update"], {
cwd: __dirname,
detached: true,
stdio: "ignore",
});
child.unref();
});
app.get("/api/network/public-address", async (_req, res) => {
try {
const response = await fetch("https://api.ipify.org?format=json", { signal: AbortSignal.timeout(5000) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const address = String((await response.json())?.ip || "").trim();
if (!net.isIP(address)) throw new Error("Invalid public address response");
res.setHeader("Cache-Control", "no-store");
res.json({ address, suggestedBaseUrl: `https://${address}` });
} catch (error) {
res.status(502).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/api/backups", (_req, res) => {
try {
res.setHeader("Cache-Control", "no-store");
res.json({ backups: backupService.listBackups() });
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post("/api/backups", async (req, res) => {
try {
const backup = await backupService.createBackup({
reason: req.body?.reason || "manual",
sessionId: req.body?.sessionId || "",
eventId: req.body?.eventId || "",
});
res.status(201).json({ ok: true, backup });
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/api/ammc/config", (_req, res) => {
res.json({
config: ammcState.config,
status: buildAmmcStatus(),
});
});
app.post("/api/ammc/config", (req, res) => {
if (!req.body || typeof req.body !== "object") {
res.status(400).json({ error: "Expected JSON body" });
return;
}
try {
ammcState.config = normalizeAmmcConfig(req.body, ammcState.config);
saveAmmcConfig(ammcState.config);
res.json({
ok: true,
config: ammcState.config,
status: buildAmmcStatus(),
});
} catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : String(error),
status: buildAmmcStatus(),
});
}
});
app.get("/api/ammc/status", (_req, res) => {
res.json(buildAmmcStatus());
});
app.post("/api/ammc/start", (_req, res) => {
try {
startAmmcProcesses();
res.json({ ok: true, status: buildAmmcStatus() });
} catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : String(error),
status: buildAmmcStatus(),
});
}
});
app.post("/api/ammc/stop", async (_req, res) => {
try {
await stopAmmcProcesses();
res.json({ ok: true, status: buildAmmcStatus() });
} catch (error) {
res.status(500).json({
error: error instanceof Error ? error.message : String(error),
status: buildAmmcStatus(),
});
}
});
// Two admin browsers can be open at once, and each pushes its own full in-memory copy of
// state on every save - whichever save lands last silently overwrites everything else,
// including any other browser's edits since its own last page load. That risk is most
// severe for resultsBySession: a browser that's been idle for a while (so its copy is
// stale) can revert live race results computed moments ago by a different, active browser.
// /api/passings is the only route driven by live decoder data, so a recent write through it
// is a reliable signal that a session's results are currently "hot" and worth protecting.
const RESULT_PROTECTION_WINDOW_MS = Number(process.env.RC_RESULT_PROTECTION_WINDOW_MS || 15000);
const recentSessionResultWrites = new Map();
function protectRecentSessionResults(incomingState) {
const stored = readStoredAppState();
if (!stored?.state?.resultsBySession || typeof stored.state.resultsBySession !== "object") return incomingState;
const now = Date.now();
for (const [sessionId, writtenAt] of recentSessionResultWrites) {
if (now - writtenAt > RESULT_PROTECTION_WINDOW_MS) {
recentSessionResultWrites.delete(sessionId);
continue;
}
const storedResult = stored.state.resultsBySession[sessionId];
if (storedResult === undefined) continue;
const incomingResult = incomingState.resultsBySession?.[sessionId];
if (JSON.stringify(incomingResult) === JSON.stringify(storedResult)) continue;
if (!incomingState.resultsBySession || typeof incomingState.resultsBySession !== "object") incomingState.resultsBySession = {};
incomingState.resultsBySession[sessionId] = storedResult;
}
return incomingState;
}
app.get("/api/state", (_req, res) => {
const row = db.prepare("SELECT state_json, updated_at FROM app_state WHERE id = 1").get();
if (!row) {
res.json({ state: null, updatedAt: null });
return;
}
try {
res.json({ state: JSON.parse(row.state_json), updatedAt: row.updated_at });
} catch {
res.status(500).json({ error: "Stored app state is invalid JSON" });
}
});
app.post("/api/state", (req, res) => {
if (!req.body || typeof req.body !== "object") {
res.status(400).json({ error: "Expected JSON body" });
return;
}
const stateJson = JSON.stringify(protectRecentSessionResults(req.body));
const nowIso = new Date().toISOString();
db.prepare(
`
INSERT INTO app_state (id, state_json, updated_at)
VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET
state_json = excluded.state_json,
updated_at = excluded.updated_at
`
).run(stateJson, nowIso);
res.json({ ok: true, updatedAt: nowIso });
});
app.post("/api/passings", (req, res) => {
const { sessionId, passing, sessionResult } = req.body || {};
if (!sessionId || !passing || typeof passing !== "object") {
res.status(400).json({ error: "Expected { sessionId, passing }" });
return;
}
const stmt = db.prepare(
`
INSERT INTO passings (
session_id,
timestamp_ms,
transponder,
driver_id,
driver_name,
car_id,
car_name,
strength,
loop_id,
resend,
raw_json,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
);
stmt.run(
String(sessionId),
Number(passing.timestamp || Date.now()),
String(passing.transponder || ""),
passing.driverId ? String(passing.driverId) : null,
passing.driverName ? String(passing.driverName) : null,
passing.carId ? String(passing.carId) : null,
passing.carName ? String(passing.carName) : null,
typeof passing.strength === "number" ? passing.strength : null,
passing.loopId ? String(passing.loopId) : null,
passing.resend ? 1 : 0,
JSON.stringify(passing),
new Date().toISOString()
);
// Keep app_state hot for overlay clients that hydrate from backend instead of
// having their own direct decoder socket.
if (sessionResult && typeof sessionResult === "object") {
const nowIso = new Date().toISOString();
const row = db.prepare("SELECT state_json FROM app_state WHERE id = 1").get();
if (row?.state_json) {
try {
const parsed = JSON.parse(row.state_json);
if (!parsed.resultsBySession || typeof parsed.resultsBySession !== "object") {
parsed.resultsBySession = {};
}
parsed.resultsBySession[String(sessionId)] = sessionResult;
recentSessionResultWrites.set(String(sessionId), Date.now());
db.prepare(
`
INSERT INTO app_state (id, state_json, updated_at)
VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET
state_json = excluded.state_json,
updated_at = excluded.updated_at
`
).run(JSON.stringify(parsed), nowIso);
} catch {
// leave app_state untouched if the stored JSON is invalid
}
}
}
res.json({ ok: true });
});
app.get("/api/passings", (req, res) => {
const sessionId = req.query.sessionId ? String(req.query.sessionId) : null;
const limit = Math.min(1000, Math.max(1, Number(req.query.limit || 200)));
const rows = sessionId
? db
.prepare(
`
SELECT *
FROM passings
WHERE session_id = ?
ORDER BY timestamp_ms DESC
LIMIT ?
`
)
.all(sessionId, limit)
: db
.prepare(
`
SELECT *
FROM passings
ORDER BY timestamp_ms DESC
LIMIT ?
`
)
.all(limit);
res.json({ rows });
});
// Deleting an event/session already removes it from every part of the UI (the event list,
// resultsBySession, training aggregates all preserve their own independent copy of what they
// need). The raw passing rows behind it are the one thing that isn't reachable through
// /api/state at all, so a dedicated purge is how the client asks for them to be cleaned up too
// instead of lingering in SQLite indefinitely with no UI ever pointing at them again.
app.post("/api/passings/purge", (req, res) => {
const sessionIds = Array.isArray(req.body?.sessionIds)
? [...new Set(req.body.sessionIds.map((id) => String(id || "")).filter(Boolean))]
: [];
if (!sessionIds.length) {
res.json({ ok: true, deleted: 0 });
return;
}
const placeholders = sessionIds.map(() => "?").join(",");
const result = db.prepare(`DELETE FROM passings WHERE session_id IN (${placeholders})`).run(...sessionIds);
res.json({ ok: true, deleted: result.changes });
});
app.post("/api/export/pdf", (req, res) => {
try {
const payload = normalizePdfExportPayload(req.body || {});
const pdf = buildSimplePdf(payload);
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `attachment; filename="${payload.filename}"`);
res.send(pdf);
} catch (error) {
res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
});
const PUBLIC_OVERLAY_TOKEN = String(process.env.PUBLIC_OVERLAY_TOKEN || "").trim();
app.get(["/public-overlay", "/public-overlay/:mode"], (req, res) => {
if (PUBLIC_OVERLAY_TOKEN && String(req.query.token || "").trim() !== PUBLIC_OVERLAY_TOKEN) {
res.status(403).type("text/plain").send("Forbidden");
return;
}
res.sendFile(path.join(__dirname, "index.html"));
});
app.get("/public-training/api/:shareToken", (req, res) => {
try {
const stored = readStoredAppState();
const sinceTimestamp = publicTrainingProfile.normalizePublicTrainingSince(req.query.since, req.query.startedAgoMs);
const windowMs = publicTrainingProfile.normalizePublicTrainingWindow(req.query.windowMs);
// Separate "since" markers for the two guided exercises: a driver may run one, both, or
// neither independently of the general practice timer above, each started at its own time.
const evenLapsSince = publicTrainingProfile.normalizePublicTrainingSince(req.query.evenLapsSince, req.query.evenLapsStartedAgoMs);
const stepTrainingSince = publicTrainingProfile.normalizePublicTrainingSince(req.query.stepTrainingSince, req.query.stepTrainingStartedAgoMs);
const profile = stored ? publicTrainingProfile.buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt, sinceTimestamp, windowMs, evenLapsSince, stepTrainingSince, String(req.query.layout || "current")) : null;
if (!profile) {
res.status(404).json({ error: "Profile not found" });
return;
}
res.setHeader("Cache-Control", "no-store");
res.json(profile);
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post("/public-training/api/:shareToken/markers", (req, res) => {
try {
const stored = readStoredAppState();
const marker = stored ? publicTrainingProfile.addPublicTrainingMarker(stored.state, String(req.params.shareToken || ""), req.body || {}) : null;
if (!marker) {
res.status(404).json({ error: "Profile or class not found" });
return;
}
const nowIso = new Date().toISOString();
db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at").run(JSON.stringify(stored.state), nowIso);
res.status(201).json({ ok: true, marker: { type: marker.type, timestamp: marker.timestamp, note: marker.note } });
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post("/public-training/api/:shareToken/even-laps", (req, res) => {
try {
const stored = readStoredAppState();
const entry = stored ? publicTrainingProfile.savePublicEvenLapsConfig(stored.state, String(req.params.shareToken || ""), req.body || {}) : null;
if (!entry) {
res.status(404).json({ error: "Profile, class, or configuration not found" });
return;
}
const nowIso = new Date().toISOString();
db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at").run(JSON.stringify(stored.state), nowIso);
res.status(201).json({ ok: true });
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post("/public-training/api/:shareToken/step-training", (req, res) => {
try {
const stored = readStoredAppState();
const entry = stored ? publicTrainingProfile.savePublicStepTrainingConfig(stored.state, String(req.params.shareToken || ""), req.body || {}) : null;
if (!entry) {
res.status(404).json({ error: "Profile, class, or configuration not found" });
return;
}
const nowIso = new Date().toISOString();
db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at").run(JSON.stringify(stored.state), nowIso);
res.status(201).json({ ok: true });
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post("/public-training/api/:shareToken/exercise-sessions", (req, res) => {
try {
const stored = readStoredAppState();
const record = stored ? publicTrainingProfile.recordPublicExerciseSession(stored.state, String(req.params.shareToken || ""), req.body || {}) : null;
if (!record) {
res.status(404).json({ error: "Profile, class, or configuration not found, or the exercise was not actually completed" });
return;
}
const nowIso = new Date().toISOString();
db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at").run(JSON.stringify(stored.state), nowIso);
res.status(201).json({ ok: true, session: { kind: record.kind, completedAt: record.completedAt, summary: record.summary } });
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/public-training/qr/:shareToken", async (req, res) => {
try {
const stored = readStoredAppState();
const token = String(req.params.shareToken || "");
if (!stored || !publicTrainingProfile.hasPublicTrainingShare(stored.state, token)) {
res.status(404).type("text/plain").send("Profile not found");
return;
}
const requestedBase = String(req.query.baseUrl || "").trim();
const configuredBase = String(stored.state?.settings?.publicBaseUrl || "").trim();
const requestBase = `${req.protocol}://${req.get("host")}`;
const lanUrls = getLanUrls(PORT);
const localBase = lanUrls.find((url) => /^http:\/\/(?:192\.168\.|10\.)/.test(url)) || lanUrls[0] || requestBase;
let baseUrl;
try {
const parsed = new URL(requestedBase === "local" ? localBase : requestedBase || configuredBase || requestBase);
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Unsupported protocol");
baseUrl = parsed.origin;
} catch {
baseUrl = `${req.protocol}://${req.get("host")}`;
}
const language = req.query.lang === "en" ? "en" : "sv";
const profileUrl = new URL(`/public-training/${encodeURIComponent(token)}`, baseUrl);
profileUrl.searchParams.set("lang", language);
const svg = await QRCode.toString(profileUrl.toString(), { type: "svg", errorCorrectionLevel: "M", margin: 2, width: 320 });
res.setHeader("Cache-Control", "no-store");
res.type("image/svg+xml").send(svg);
} catch (error) {
res.status(500).type("text/plain").send(error instanceof Error ? error.message : String(error));
}
});
app.get("/public-training/:shareToken", (req, res) => {
try {
const stored = readStoredAppState();
if (!stored || !publicTrainingProfile.hasPublicTrainingShare(stored.state, String(req.params.shareToken || ""))) {
res.status(404).type("text/plain").send("Profile not found");
return;
}
res.setHeader("Cache-Control", "no-store");
res.sendFile(path.join(__dirname, "public-training.html"));
} catch {
res.status(404).type("text/plain").send("Profile not found");
}
});
app.use("/race-sounds", express.static(RACE_SOUND_DIR, {
setHeaders: (res, filePath) => {
res.setHeader("Cache-Control", filePath.endsWith(".wav") ? "public, max-age=86400" : "no-store");
},
}));
app.use(
"/src",
express.static(SRC_DIR, {
setHeaders: (res, filePath) => {
if (filePath.endsWith(".html") || filePath.endsWith(".js") || filePath.endsWith(".css")) {
res.setHeader("Cache-Control", "no-store");
}
},
})
);
app.use("/docs", express.static(path.join(__dirname, "docs")));
app.use((_req, res) => {
res.sendFile(path.join(__dirname, "index.html"));
});
app.listen(PORT, HOST, () => {
writePidFile();
console.log(`RC Timing server listening on http://${HOST}:${PORT}`);
const lanUrls = getLanUrls(PORT);
if (lanUrls.length) {
console.log(`LAN access: ${lanUrls.join(" , ")}`);
}
console.log(`SQLite database: ${DB_PATH}`);
console.log(`AMMC executable: ${buildAmmcStatus().resolvedExecutablePath}`);
maybeStartManagedAmmc();
});
function initSchema() {
db.exec(`
CREATE TABLE IF NOT EXISTS app_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
state_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS passings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
transponder TEXT NOT NULL,
driver_id TEXT,
driver_name TEXT,
car_id TEXT,
car_name TEXT,
strength REAL,
loop_id TEXT,
resend INTEGER NOT NULL DEFAULT 0,
raw_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_passings_session ON passings(session_id);
CREATE INDEX IF NOT EXISTS idx_passings_timestamp ON passings(timestamp_ms);
`);
}
function readStoredAppState() {
const row = db.prepare("SELECT state_json, updated_at FROM app_state WHERE id = 1").get();
if (!row?.state_json) return null;
return { state: JSON.parse(row.state_json), updatedAt: row.updated_at };
}
function normalizePdfExportPayload(input = {}) {
const title = String(input.title || "RC RaceCore").trim();
const subtitle = String(input.subtitle || "").trim();
const brandName = String(input.brandName || "JMK RB").trim();
const brandTagline = String(input.brandTagline || "Live Event").trim();
const footer = String(input.footer || "").trim();
const logoDataUrl = String(input.logoDataUrl || "").trim();
const theme = ["classic", "minimal", "motorsport"].includes(String(input.theme || "").toLowerCase())
? String(input.theme).toLowerCase()
: "classic";
const filenameBase = String(input.filename || "export.pdf").trim() || "export.pdf";
const filename = filenameBase.toLowerCase().endsWith(".pdf") ? filenameBase : `${filenameBase}.pdf`;
const sections = Array.isArray(input.sections)
? input.sections.map((section) => ({
title: String(section?.title || "").trim(),
headers: Array.isArray(section?.headers) ? section.headers.map((value) => String(value || "").trim()) : [],
rows: Array.isArray(section?.rows)
? section.rows.map((row) => (Array.isArray(row) ? row.map((value) => String(value ?? "").trim()) : [String(row ?? "").trim()]))
: [],
}))
: [];
return { title, subtitle, brandName, brandTagline, footer, theme, logoDataUrl, filename, sections };
}
function buildSimplePdf(payload) {
const pageWidth = 595;
const pageHeight = 842;
const marginLeft = 42;
const marginTop = 56;
const marginBottom = 56;
const lineHeight = 15;
const maxChars = 86;
const logo = parsePdfLogo(payload.logoDataUrl);
const headerOffset = logo ? logo.displayHeight + 18 : 0;
const lines = buildPdfLines(payload, maxChars);
const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop - marginBottom - headerOffset) / lineHeight));
const pages = [];
for (let index = 0; index < lines.length; index += linesPerPage) {
pages.push(lines.slice(index, index + linesPerPage));
}
if (!pages.length) {
pages.push(["No content"]);
}
const objects = [];
const pageObjectIds = [];
const contentObjectIds = [];
let nextObjectId = 3;
for (let index = 0; index < pages.length; index += 1) {
pageObjectIds.push(nextObjectId++);
contentObjectIds.push(nextObjectId++);
}
const fontObjectId = nextObjectId++;
const imageObjectId = logo ? nextObjectId++ : null;
objects.push(createPdfTextObject(1, "<< /Type /Catalog /Pages 2 0 R >>"));
objects.push(createPdfTextObject(2, `<< /Type /Pages /Count ${pages.length} /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] >>`));
objects.push(createPdfTextObject(fontObjectId, "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>"));
if (logo && imageObjectId) {
objects.push(
createPdfStreamObject(
imageObjectId,
`<< /Type /XObject /Subtype /Image /Width ${logo.width} /Height ${logo.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode`,
logo.data
)
);
}
for (let index = 0; index < pages.length; index += 1) {
const resources = imageObjectId
? `<< /Font << /F1 ${fontObjectId} 0 R >> /XObject << /Im1 ${imageObjectId} 0 R >> >>`
: `<< /Font << /F1 ${fontObjectId} 0 R >> >>`;
const contentStream = pdfWinAnsiBuffer(
buildPdfContentStream(
pages[index],
marginLeft,
pageHeight - marginTop - headerOffset,
lineHeight,
logo
? {
x: marginLeft,
y: pageHeight - marginTop - logo.displayHeight,
width: logo.displayWidth,
height: logo.displayHeight,
}
: null
)
);
objects.push(
createPdfTextObject(
pageObjectIds[index],
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources ${resources} /Contents ${contentObjectIds[index]} 0 R >>`
)
);
objects.push(createPdfStreamObject(contentObjectIds[index], "<<", contentStream));
}
return assemblePdf(objects);
}
function createPdfTextObject(id, body) {
return {
id,
buffer: Buffer.from(`${id} 0 obj\n${body}\nendobj\n`, "binary"),
};
}
function createPdfStreamObject(id, dictionaryPrefix, streamBuffer) {
return {
id,
buffer: Buffer.concat([
Buffer.from(`${id} 0 obj\n${dictionaryPrefix} /Length ${streamBuffer.length} >>\nstream\n`, "binary"),
streamBuffer,
Buffer.from("\nendstream\nendobj\n", "binary"),
]),
};
}
function assemblePdf(objects) {
const ordered = [...objects].sort((left, right) => left.id - right.id);
const chunks = [Buffer.from("%PDF-1.4\n", "binary")];
const offsets = [0];
let position = chunks[0].length;
ordered.forEach((object) => {
offsets[object.id] = position;
chunks.push(object.buffer);
position += object.buffer.length;
});
const xrefOffset = position;
const size = ordered.length + 1;
let xref = `xref\n0 ${size}\n0000000000 65535 f \n`;
for (let id = 1; id < size; id += 1) {
xref += `${String(offsets[id] || 0).padStart(10, "0")} 00000 n \n`;
}
chunks.push(Buffer.from(`${xref}trailer\n<< /Size ${size} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`, "binary"));
return Buffer.concat(chunks);
}
function parsePdfLogo(dataUrl) {
const match = /^data:image\/jpeg;base64,([A-Za-z0-9+/=\s]+)$/i.exec(String(dataUrl || "").trim());
if (!match) {
return null;
}
const data = Buffer.from(match[1].replace(/\s+/g, ""), "base64");
const dimensions = getJpegDimensions(data);
if (!dimensions) {
return null;
}
const scale = Math.min(150 / dimensions.width, 52 / dimensions.height, 1);
return {
data,
width: dimensions.width,
height: dimensions.height,
displayWidth: Math.max(1, Math.round(dimensions.width * scale)),
displayHeight: Math.max(1, Math.round(dimensions.height * scale)),
};
}
function getJpegDimensions(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
return null;
}
let offset = 2;
while (offset + 1 < buffer.length) {
while (offset < buffer.length && buffer[offset] !== 0xff) {
offset += 1;
}
while (offset < buffer.length && buffer[offset] === 0xff) {
offset += 1;
}
if (offset >= buffer.length) {
break;
}
const marker = buffer[offset];
offset += 1;
if (marker === 0xd9 || marker === 0xda) {
break;
}
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
continue;
}
if (offset + 1 >= buffer.length) {
break;
}
const length = buffer.readUInt16BE(offset);
if (length < 2 || offset + length > buffer.length) {
break;
}
if (
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf)
) {
if (length >= 7) {
return {
height: buffer.readUInt16BE(offset + 3),
width: buffer.readUInt16BE(offset + 5),
};
}
break;
}
offset += length;
}
return null;
}
function buildPdfLines(payload, maxChars) {
const lines = [];
const separator =
payload.theme === "minimal"
? ""
: payload.theme === "motorsport"
? "=".repeat(Math.min(maxChars, 70))
: "-".repeat(Math.min(maxChars, 70));
if (payload.brandName) {
lines.push(payload.brandName);
}
if (payload.brandTagline) {
lines.push(payload.brandTagline);
}
if (separator) {
lines.push(separator);
}
if (payload.brandName || payload.brandTagline) {
lines.push("");
}
lines.push(payload.title);
if (payload.subtitle) {
lines.push(payload.subtitle);
}
payload.sections.forEach((section) => {
lines.push("");
if (section.title) {
lines.push(payload.theme === "motorsport" ? `> ${section.title}` : section.title);
}
if (section.headers.length) {
const widths = getColumnWidths(section.headers, section.rows, maxChars);
lines.push(formatPdfRow(section.headers, widths));
lines.push(formatPdfRow(widths.map((width) => (payload.theme === "minimal" ? " ".repeat(Math.min(width, 1)) : "-".repeat(Math.min(width, 12)))), widths));
section.rows.forEach((row) => {
lines.push(formatPdfRow(row, widths));
});
} else {
section.rows.forEach((row) => {
row.forEach((value) => {
wrapPdfText(value, maxChars).forEach((line) => lines.push(line));
});
});
}
});
if (payload.footer) {
lines.push("");
lines.push(payload.footer);
}
return lines;
}
function getColumnWidths(headers, rows, maxChars) {
const widths = headers.map((header, index) => {
const columnValues = rows.map((row) => String(row[index] || ""));
const longest = Math.max(header.length, ...columnValues.map((value) => value.length), 4);
return Math.min(26, Math.max(4, longest));
});
let total = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, (widths.length - 1) * 3);
while (total > maxChars) {
const widestIndex = widths.findIndex((width) => width === Math.max(...widths));
if (widths[widestIndex] <= 4) {
break;
}
widths[widestIndex] -= 1;
total -= 1;
}
return widths;
}
function formatPdfRow(values, widths) {
return values
.map((value, index) => String(value || "").slice(0, widths[index]).padEnd(widths[index], " "))
.join(" | ");
}
function wrapPdfText(text, maxChars) {
const words = String(text || "").split(/\s+/).filter(Boolean);
if (!words.length) {
return [""];
}
const lines = [];
let current = "";
words.forEach((word) => {
const next = current ? `${current} ${word}` : word;
if (next.length > maxChars) {
if (current) {
lines.push(current);
current = word;
} else {
lines.push(word.slice(0, maxChars));
current = word.slice(maxChars);
}
} else {
current = next;
}
});
if (current) {
lines.push(current);
}
return lines;
}
function buildPdfContentStream(lines, x, y, lineHeight, logoPlacement = null) {
const escaped = lines.map((line) => escapePdfText(line));
const commands = [];
if (logoPlacement) {
commands.push("q");
commands.push(`${logoPlacement.width} 0 0 ${logoPlacement.height} ${logoPlacement.x} ${logoPlacement.y} cm`);
commands.push("/Im1 Do");
commands.push("Q");
}
commands.push(
"BT",
"/F1 11 Tf",
`${lineHeight} TL`,
`${x} ${y} Td`,
...escaped.map((line) => `(${line}) Tj\nT*`),
"ET"
);
return commands.join("\n");
}
function escapePdfText(text) {
return String(text || "")
.replaceAll("\\", "\\\\")
.replaceAll("(", "\\(")
.replaceAll(")", "\\)");
}
// The content stream is declared with /Encoding /WinAnsiEncoding (see buildSimplePdf), so every
// character must become exactly one WinAnsi (~CP1252) byte or a PDF viewer renders garbage/boxes
// for anything outside plain ASCII - accented letters most of all (å, ä, ö, ...). WinAnsi matches
// Latin-1 one-for-one above 0x9F (where å/ä/ö/Å/Ä/Ö all live), so a plain code-point clamp covers
// Swedish text; only the narrow 0x80-0x9F "smart punctuation" block needs its own mapping.
const PDF_WIN_ANSI_OVERRIDES = {
0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87,
0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a, 0x2039: 0x8b, 0x0152: 0x8c, 0x017d: 0x8e,
0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97,
0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b, 0x0153: 0x9c, 0x017e: 0x9e, 0x0178: 0x9f,
};
function pdfWinAnsiBuffer(text) {
const bytes = [];
for (const char of String(text || "")) {
const code = char.codePointAt(0);
if (code < 0x80 || (code > 0x9f && code <= 0xff)) bytes.push(code);
else if (PDF_WIN_ANSI_OVERRIDES[code] !== undefined) bytes.push(PDF_WIN_ANSI_OVERRIDES[code]);
else bytes.push(0x3f); // "?" - outside what a standard PDF font/WinAnsiEncoding can represent
}
return Buffer.from(bytes);
}
function getLanUrls(port) {
const interfaces = os.networkInterfaces();
const urls = [];
Object.values(interfaces).forEach((records) => {
(records || []).forEach((rec) => {
if (rec && rec.family === "IPv4" && !rec.internal) {
urls.push(`http://${rec.address}:${port}`);
}
});
});
return urls;
}
function watchAppFiles() {
WATCHED_FILES.forEach((filePath) => {
if (!fs.existsSync(filePath)) {
return;
}
fs.watchFile(filePath, { interval: 1000 }, (curr, prev) => {
if (curr.mtimeMs !== prev.mtimeMs) {
bumpAppVersion();
}
});
});
}
function bumpAppVersion() {
appVersion.revision += 1;
appVersion.updatedAt = new Date().toISOString();
}
function getDefaultAmmcExecutablePath(platform = process.platform) {
if (platform === "win32") {
return path.join(__dirname, "AMMC", "windows64", "ammc-amb.exe");
}
if (platform === "darwin") {
return path.join(__dirname, "AMMC", "apple_m", "ammc-amb");
}
return path.join(__dirname, "AMMC", "linux_x86-64", "ammc-amb");
}
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 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,
splitEnabled,
splitDecoderHost,
splitWsPort,
pitEnabled,
pitEntryDecoderHost,
pitEntryWsPort,
pitExitDecoderHost,
pitExitWsPort,
executablePath,
workingDirectory,
extraArgs,
updatedAt: new Date().toISOString(),
};
}
function loadAmmcConfig() {
if (!fs.existsSync(AMMC_CONFIG_PATH)) {
return normalizeAmmcConfig({
managedEnabled: false,
autoStart: false,
decoderHost: "",
wsPort: 9000,
splitEnabled: false,
splitDecoderHost: "",
splitWsPort: 9001,
pitEnabled: false,
pitEntryDecoderHost: "",
pitEntryWsPort: 9002,
pitExitDecoderHost: "",
pitExitWsPort: 9003,
executablePath: getDefaultAmmcExecutablePath(),
workingDirectory: "",
extraArgs: "",
});
}
try {
const raw = fs.readFileSync(AMMC_CONFIG_PATH, "utf8");
return normalizeAmmcConfig(JSON.parse(raw));
} catch (error) {
console.error(`Failed to load AMMC config: ${error instanceof Error ? error.message : String(error)}`);
return normalizeAmmcConfig({
managedEnabled: false,
autoStart: false,
decoderHost: "",
wsPort: 9000,
splitEnabled: false,
splitDecoderHost: "",
splitWsPort: 9001,
pitEnabled: false,
pitEntryDecoderHost: "",
pitEntryWsPort: 9002,
pitExitDecoderHost: "",
pitExitWsPort: 9003,
executablePath: getDefaultAmmcExecutablePath(),
workingDirectory: "",
extraArgs: "",
});
}
}
function saveAmmcConfig(config) {
fs.writeFileSync(AMMC_CONFIG_PATH, JSON.stringify(config, null, 2));
}
function resolveAmmcExecutable(executablePath) {
const value = String(executablePath || "").trim();
if (!value) {
return getDefaultAmmcExecutablePath();
}
if (path.isAbsolute(value)) {
return value;
}
return path.join(__dirname, value);
}
function resolveAmmcWorkingDirectory(config, executablePath) {
const explicitDir = String(config.workingDirectory || "").trim();
if (!explicitDir) {
return path.dirname(executablePath);
}
return path.isAbsolute(explicitDir) ? explicitDir : path.join(__dirname, explicitDir);
}
function parseExtraArgs(extraArgs) {
return String(extraArgs || "")
.split(/\s+/)
.map((part) => part.trim())
.filter(Boolean);
}
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 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 {
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,
workingDirectory,
};
}
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) => {
channel.lastOutput.push({
ts: new Date().toISOString(),
role,
stream,
line,
});
});
if (channel.lastOutput.length > 30) {
channel.lastOutput.splice(0, channel.lastOutput.length - 30);
}
}
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(wsPort), decoderHost, ...parseExtraArgs(config.extraArgs)];
const workingDirectory = resolveAmmcWorkingDirectory(config, executablePath);
channel.lastError = "";
appendAmmcOutput(role, "system", `Starting ${descriptor.label}: ${path.basename(executablePath)} ${args.join(" ")}`);
const child = spawn(executablePath, args, {
cwd: workingDirectory,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
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(role, "stdout", chunk));
child.stderr.on("data", (chunk) => appendAmmcOutput(role, "stderr", chunk));
child.on("error", (error) => {
channel.lastError = error instanceof Error ? error.message : String(error);
appendAmmcOutput(role, "error", channel.lastError);
});
child.on("exit", (code, signal) => {
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 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 = channel.process;
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (!settled) {
settled = true;
resolve(buildAmmcStatus());
}
};
child.once("exit", finish);
child.kill();
setTimeout(() => {
if (!settled && isAmmcRunning(role)) {
if (process.platform !== "win32") {
child.kill("SIGKILL");
}
finish();
}
}, 3000);
});
}
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 {
startAmmcProcesses();
console.log("Managed AMMC decoder channels started");
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
getAmmcChannel("main").lastError = msg;
console.error(`Managed AMMC failed to start: ${msg}`);
}
}
async function shutdown() {
await stopAmmcProcesses();
removePidFile();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
process.on("exit", removePidFile);
function writePidFile() {
if (!PID_FILE) {
return;
}
try {
fs.writeFileSync(PID_FILE, `${process.pid}\n`);
} catch (error) {
console.error(`Failed to write pid file: ${error instanceof Error ? error.message : String(error)}`);
}
}
function removePidFile() {
if (!PID_FILE || !fs.existsSync(PID_FILE)) {
return;
}
try {
const raw = fs.readFileSync(PID_FILE, "utf8").trim();
if (String(process.pid) === raw) {
fs.rmSync(PID_FILE, { force: true });
}
} catch {
// ignore pid cleanup errors on shutdown
}
}