From 8ea8b3a84ec67243ae3eaf29c2b7b64e097da9a6 Mon Sep 17 00:00:00 2001 From: larssand Date: Tue, 25 Aug 2026 19:45:40 +0200 Subject: [PATCH] Add multi-decoder split and pit timing support --- README.md | 26 +- README.sv.md | 26 +- docs/javascript-architecture.md | 98 ++++++- server.js | 256 +++++++++++++------ src/app.js | 119 ++++++++- src/core_views.js | 2 + src/decoder_runtime.js | 157 +++++++++--- src/misc_views.js | 3 +- src/overlays.js | 1 + src/runtime_services.js | 19 +- src/settings.js | 66 ++++- src/styles.css | 11 + src/timing_logic.js | 14 +- src/timing_views.js | 28 +- tests/decoder_runtime.test.js | 137 ++++++++++ tests/run.js | 1 + tests/runtime_persistence.test.js | 9 + tests/server_persistence.integration.test.js | 34 +++ windows/README.md | 14 +- windows/setup.ps1 | 2 +- 20 files changed, 876 insertions(+), 147 deletions(-) create mode 100644 tests/decoder_runtime.test.js diff --git a/README.md b/README.md index 7466169..8f0e133 100644 --- a/README.md +++ b/README.md @@ -433,10 +433,23 @@ Important: - if you only browse from another laptop, no process starts there - the `AMMC executable` field in `Settings` is a path on the backend host, not the client browser machine +#### Decoder topology + +| Role | Physical placement | Example decoder IP | AMMC WS port | Effect | +|---|---|---:|---:|---| +| Main start/finish | Start/finish loop | `192.168.1.10` | `9000` | Counts laps and controls lap time | +| Split | Intermediate loop | `192.168.1.11` | `9001` | Records split from the latest main crossing; never counts a lap | +| Pit entry | Entry to pit lane | `192.168.1.12` | `9002` | Opens a pit stop | +| Pit exit | Exit from pit lane | `192.168.1.13` | `9003` | Closes and times a pit stop | + +The decoder IP and WebSocket address are different things. The decoder IP points from AMMC to physical hardware. The browser connects to AMMC on the backend host, for example `ws://192.168.1.100:9001`. Each decoder must have a unique network address and each AMMC process must have a unique WebSocket port. + +Use only the channels physically installed at the track. One decoder on `9000` remains the default and needs no optional settings. Split timing requires one additional decoder. Pit timing requires two additional decoders because entry and exit are separate events. A complete setup therefore uses four decoders. + 1. Place AMMC binaries in the `AMMC/` project folder. 2. Open `Settings`. 3. Enable `Managed AMMC`. -4. Set `Decoder IP / host`, verify port `9000`, save. +4. Set the Main `Decoder IP / host`, verify port `9000`, and optionally enable/configure Split and Pit channels from the table above. 5. Click `Start AMMC`. 6. Click `Use server WS URL` so the client uses for example `ws://:9000`. @@ -448,7 +461,15 @@ Default binaries: ### B. Manual start Example: ```powershell -ammc-amb.exe -w 9000 192.168.1.11 +ammc-amb.exe -w 9000 192.168.1.10 +``` + +Complete four-decoder example, using four separate terminals/processes: +```powershell +ammc-amb.exe -w 9000 192.168.1.10 +ammc-amb.exe -w 9001 192.168.1.11 +ammc-amb.exe -w 9002 192.168.1.12 +ammc-amb.exe -w 9003 192.168.1.13 ``` In the app: @@ -461,6 +482,7 @@ In the app: If Linux firewall/UFW is enabled: ```bash sudo ufw allow 8081/tcp +sudo ufw allow 9000:9003/tcp ``` ## Auto reload during development diff --git a/README.sv.md b/README.sv.md index 7f55797..303ad9d 100644 --- a/README.sv.md +++ b/README.sv.md @@ -422,10 +422,23 @@ Viktigt: - Om du öppnar sidan från en annan laptop startas ingen AMMC där. - Fältet `AMMC binär` i `Settings` är en sökväg på backend-hosten, inte på klienten som surfar in. +#### Koppling av decoders + +| Roll | Fysisk placering | Exempel decoder-IP | AMMC WS-port | Funktion | +|---|---|---:|---:|---| +| Main start/mål | Start-/målloop | `192.168.1.10` | `9000` | Räknar varv och bestämmer varvtid | +| Mellantid | Mellantidsloop | `192.168.1.11` | `9001` | Tid från senaste main-passering; räknar aldrig varv | +| Depå in | Infart till depå | `192.168.1.12` | `9002` | Öppnar ett depåstopp | +| Depå ut | Utfart från depå | `192.168.1.13` | `9003` | Stänger och tidtar depåstoppet | + +Decoder-IP och WebSocket-adress är två olika saker. Decoder-IP är den fysiska hårdvarans adress som AMMC ansluter till. Webbläsaren ansluter till AMMC på backend-hosten, till exempel `ws://192.168.1.100:9001`. Varje decoder måste ha unik nätverksadress och varje AMMC-process måste ha unik WebSocket-port. + +Aktivera bara de kanaler som finns fysiskt på banan. En decoder på `9000` är fortsatt standard och kräver inga extrainställningar. Mellantid kräver en extra decoder. Depåtid kräver två extra decoders eftersom in- och utpassering är separata händelser. Komplett installation använder därför fyra decoders. + 1. Lägg AMMC-binärerna i projektmappen `AMMC/` (redan gjort i denna repo). 2. Öppna `Settings`. 3. Aktivera `Hanterad AMMC / Managed AMMC`. -4. Sätt `Decoder IP / host`, kontrollera port `9000`, spara. +4. Sätt Main `Decoder IP / host`, kontrollera port `9000` och aktivera vid behov Mellantid och Depå enligt tabellen ovan. 5. Klicka `Starta AMMC / Start AMMC`. 6. Klicka `Använd serverns WS-url / Use server WS URL` så sätts klienten till t.ex. `ws://:9000`. @@ -437,7 +450,15 @@ Standardbinärer: ### B. Manuell start Starta AMMC med WebSocket (exempel): ```powershell -ammc-amb.exe -w 9000 192.168.1.11 +ammc-amb.exe -w 9000 192.168.1.10 +``` + +Komplett exempel med fyra decoders, i fyra separata terminaler/processer: +```powershell +ammc-amb.exe -w 9000 192.168.1.10 +ammc-amb.exe -w 9001 192.168.1.11 +ammc-amb.exe -w 9002 192.168.1.12 +ammc-amb.exe -w 9003 192.168.1.13 ``` I appen: @@ -450,6 +471,7 @@ I appen: Om du kör Linux-brandvägg (UFW), öppna porten: ```bash sudo ufw allow 8081/tcp +sudo ufw allow 9000:9003/tcp ``` ## Auto reload vid uppdatering diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index d32182b..d739335 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -893,6 +893,96 @@ sequenceDiagram A->>A: rerender timing/overlay ``` +### 8.3.1 Multi-decoder topology and ownership + +The decoder runtime supports four explicit roles. Roles are semantic, not inferred from `loop_id`; the WebSocket connection used to receive a message determines how it is processed. + +| Runtime role | Frontend URL setting | Managed AMMC config | Default port | Canonical mutation | +|---|---|---|---:|---| +| `main` | `settings.wsUrl` | `decoderHost` / `wsPort` | `9000` | validates and appends `result.passings`; increments laps | +| `split` | `settings.splitWsUrl` | `splitDecoderHost` / `splitWsPort` | `9001` | appends `result.splits`; updates split fields only | +| `pit_entry` | `settings.pitEntryWsUrl` | `pitEntryDecoderHost` / `pitEntryWsPort` | `9002` | opens pit state and appends a pit entry event | +| `pit_exit` | `settings.pitExitWsUrl` | `pitExitDecoderHost` / `pitExitWsPort` | `9003` | closes pit state and appends the measured stop | + +Physical and network chain: + +```text +physical loop -> physical decoder IP -> one AMMC child process on backend host + -> unique AMMC WebSocket port -> browser WebSocket client with fixed role + -> processDecoderMessageHelper(message, deps, role) +``` + +Example complete installation: + +```text +Main decoder 192.168.1.10 -> backend AMMC :9000 -> role main +Split decoder 192.168.1.11 -> backend AMMC :9001 -> role split +Pit entry decoder 192.168.1.12 -> backend AMMC :9002 -> role pit_entry +Pit exit decoder 192.168.1.13 -> backend AMMC :9003 -> role pit_exit +Browser host 192.168.1.100 uses ws://192.168.1.100:9000 through :9003 +``` + +Invariants: + +- only `main` may change `entry.laps`, `entry.lastTimestamp`, or append to `result.passings` +- split timing is measured from the latest main timestamp, or mass-start timestamp before the first completed lap +- a duplicate pit-entry signal while `entry.pitActive` is true is ignored +- pit exit without a prior pit entry is ignored +- an optional channel can disconnect without stopping main lap counting +- main, split, pit entry, and pit exit must use distinct AMMC WebSocket ports +- every physical decoder normally has a distinct IP; reusing one feed for different roles would process the same crossing with conflicting semantics + +### 8.3.2 Multi-decoder frontend lifecycle + +`src/app.js` owns role-indexed `wsClients` and `reconnectTimers` maps. `connectDecoderHelper()` first disconnects all clients, always connects `main`, then connects enabled optional roles. `connectDecoderRole()` binds the role into every message callback. Auto-reconnect checks the current enable flags before reconnecting optional channels. + +Decoder runtime state is persisted with separate status fields: + +```js +decoder: { + connected, lastMessageAt, lastError, + splitConnected, splitLastMessageAt, splitLastError, + pitEntryConnected, pitEntryLastMessageAt, pitEntryLastError, + pitExitConnected, pitExitLastMessageAt, pitExitLastError +} +``` + +Race data is stored under `resultsBySession[sessionId]`: + +```js +{ + passings: MainPassing[], + splits: SplitPassing[], + pitEvents: PitEvent[], + pitStops: PitStop[], + competitors: { + [competitorKey]: { + lastSplitMs, previousSplitMs, lastSplitDeltaMs, bestSplitMs, + lastSplitTimestamp, lastSplitLap, + pitActive, pitEnteredAt, pitStops, + lastPitStopMs, totalPitMs, bestPitStopMs + } + } +} +``` + +The full result object is included in `/api/state` persistence. Only main passings are posted to `/api/passings` and the SQLite passings table. This preserves the existing lap ledger and prevents split/pit records from being interpreted as laps. If split or pit history later needs SQL reporting, add dedicated tables and endpoints rather than inserting those records into `passings`. + +### 8.3.3 Managed AMMC process lifecycle + +`server.js` owns four process states in `ammcState.channels`: `main`, `split`, `pitEntry`, and `pitExit`. `startAmmcProcesses()` normalizes and validates the complete configuration before spawning one child process per enabled role. All roles use the same resolved AMMC executable and working directory but receive different `-w ` arguments. + +The AMMC status API remains backward compatible: top-level status fields represent `main`; nested `split`, `pitEntry`, and `pitExit` objects expose optional process status. Combined `lastOutput` records include a `role` field. `stopAmmcProcesses()` stops all roles during an API stop request or server shutdown. + +Configuration validation rejects: + +- ports outside `1..65535` +- duplicate ports among enabled channels +- an enabled role without its decoder host when AMMC is started +- missing AMMC executable + +Changing backend AMMC process code requires `npm restart` or `npm.cmd restart`. Editing browser modules alone is handled by app-version polling, but a server process cannot hot-reload its own implementation. + ## 8.4 Public overlay flow ```mermaid @@ -1309,9 +1399,11 @@ Start and finish lifecycle announcements are initiated from `timing_views.js`, w - `normalizeAmmcConfig(...)` - validates/stabilizes AMMC config - `buildAmmcStatus()` - - current AMMC process status object -- `startAmmcProcess()` / `stopAmmcProcess()` - - process lifecycle + - backward-compatible main status plus nested split/pit channel statuses +- `startAmmcProcesses()` / `stopAmmcProcesses()` + - lifecycle for every enabled decoder role +- `startAmmcProcess(role)` / `stopAmmcProcess(role)` + - lifecycle for one AMMC child process - PDF export helpers below `/api/export/pdf` #### Critical backend routes diff --git a/server.js b/server.js index be00cf5..499f42a 100644 --- a/server.js +++ b/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); } diff --git a/src/app.js b/src/app.js index 0adbfdb..3cb5de9 100644 --- a/src/app.js +++ b/src/app.js @@ -119,7 +119,7 @@ const getEventName = (eventId) => getEventNameHelper(state, eventId, { t }); const renderAssignmentList = (eventId) => renderAssignmentListView(eventId, { state, t, escapeHtml, getSessionsForEvent, getSessionTypeLabel, saveState, renderAssignmentList }); const renderSessionsTable = (sessions) => renderSessionsTableView(sessions, { t, renderTable, escapeHtml, getEventName, getSessionTypeLabel, getStatusLabel, getModeLabel }); const createDefaultAmmcConfig = () => createDefaultAmmcConfigHelper(); -const getManagedWsUrl = () => getManagedWsUrlHelper({ ammc, getBackendUrl }); +const getManagedWsUrl = (role = "main") => getManagedWsUrlHelper({ ammc, getBackendUrl }, role); const loadAmmcConfigFromBackend = () => loadAmmcConfigFromBackendHelper({ ammc, t, getBackendUrl, createDefaultAmmcConfig }); const saveAmmcConfigToBackend = (config) => saveAmmcConfigToBackendHelper(config, { ammc, t, getBackendUrl, createDefaultAmmcConfig }); const refreshAmmcStatus = () => refreshAmmcStatusHelper({ ammc, t, getBackendUrl }); @@ -154,10 +154,10 @@ const connectDecoder = () => connectDecoderHelper({ state, t, saveState, - getWsClient: () => wsClient, - setWsClient: (value) => { wsClient = value; }, - getReconnectTimer: () => reconnectTimer, - setReconnectTimer: (value) => { reconnectTimer = value; }, + getWsClient: (role) => wsClients[role] || null, + setWsClient: (role, value) => { wsClients[role] = value; }, + getReconnectTimer: (role) => reconnectTimers[role] || null, + setReconnectTimer: (role, value) => { reconnectTimers[role] = value; }, getCurrentView: () => currentView, renderView, updateConnectionBadge, @@ -168,13 +168,13 @@ const connectDecoder = () => connectDecoderHelper({ const disconnectDecoder = (options = {}) => disconnectDecoderHelper(options, { state, saveState, - getWsClient: () => wsClient, - setWsClient: (value) => { wsClient = value; }, - getReconnectTimer: () => reconnectTimer, - setReconnectTimer: (value) => { reconnectTimer = value; }, + getWsClient: (role) => wsClients[role] || null, + setWsClient: (role, value) => { wsClients[role] = value; }, + getReconnectTimer: (role) => reconnectTimers[role] || null, + setReconnectTimer: (role, value) => { reconnectTimers[role] = value; }, updateConnectionBadge, }); -const processDecoderMessage = (msg) => processDecoderMessageHelper(msg, { +const processDecoderMessage = (msg, decoderRole = "main") => processDecoderMessageHelper(msg, { state, t, getActiveSession, @@ -191,7 +191,7 @@ const processDecoderMessage = (msg) => processDecoderMessageHelper(msg, { lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, lastOverlayBestLapByKey, -}); +}, decoderRole); const renderDashboard = () => renderDashboardView({ state, dom, t, backend, getActiveSession, getStatusLabel, getSessionTypeLabel, getEventName, getModeLabel, getBackendUrl, formatLap, renderSessionsTable, setCurrentView: (view) => { currentView = view; }, updateViewUrl, renderNav, renderView, connectDecoder, disconnectDecoder, openOverlayWindow, ensureAudioContext, playPassingBeep, playFinishSiren, escapeHtml }); const renderClasses = () => renderClassesView({ state, dom, t, selectedClassEditId: () => selectedClassEditId, setSelectedClassEditId: (value) => { selectedClassEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell }); const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter: () => driverBrandFilter, setDriverBrandFilter: (value) => { driverBrandFilter = value; }, selectedDriverEditId: () => selectedDriverEditId, setSelectedDriverEditId: (value) => { selectedDriverEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeDriver, getClassName }); @@ -788,6 +788,11 @@ const TRANSLATIONS = { "timing.connected": "Ansluten", "timing.disconnected": "Frånkopplad", "timing.last_message": "Senaste meddelande", + "timing.main_decoder": "Main start/mål", + "timing.split_decoder": "Mellantid", + "timing.pit_entry_decoder": "Depå in", + "timing.pit_exit_decoder": "Depå ut", + "timing.pit_active": "I DEPÅ", "timing.control": "Sessionkontroll", "timing.speaker_panel": "Speaker-panel", "timing.speaker_panel_hint": "Dessa växlar slår av/på cues direkt för pågående session och overlay utan att lämna Tidtagning.", @@ -874,6 +879,20 @@ const TRANSLATIONS = { "timing.seeding_mode": "Seedning", "timing.position_grid_hint": "Griden visar startordningen för positionsstart i aktiv session.", "settings.decoder": "Decoder", + "settings.main_ws_url": "Main WebSocket-url", + "settings.enable_split_decoder": "Aktivera mellantidsdecoder", + "settings.split_ws_url": "Mellantid WebSocket-url", + "settings.enable_pit_decoders": "Aktivera depådecoders", + "settings.decoder_roles_hint": "Main räknar varv. Mellantid mäter från senaste main-passering. Depå in/ut mäter depåstopp. Varje fysisk decoder måste ha egen IP och varje AMMC-kanal egen WebSocket-port.", + "settings.managed_channels_hint": "Backend startar en separat AMMC-process per aktiverad kanal. Standardportar: main 9000, mellantid 9001, depå in 9002 och depå ut 9003.", + "settings.pit_entry_ws_url": "Depå in WebSocket-url", + "settings.pit_exit_ws_url": "Depå ut WebSocket-url", + "settings.split_decoder_host": "Mellantid decoder IP / host", + "settings.split_ws_port": "Mellantid WebSocket-port", + "settings.pit_entry_decoder_host": "Depå in decoder IP / host", + "settings.pit_entry_ws_port": "Depå in WebSocket-port", + "settings.pit_exit_decoder_host": "Depå ut decoder IP / host", + "settings.pit_exit_ws_port": "Depå ut WebSocket-port", "settings.auto_reconnect": "Auto-återanslut", "settings.save": "Spara", "settings.connect_now": "Anslut nu", @@ -990,6 +1009,8 @@ const TRANSLATIONS = { "table.laps": "Varv", "table.last_lap": "Senaste varv", "table.best_lap": "Bästa varv", + "table.split": "Mellantid", + "table.pit": "Depå", "table.gap": "Gap", "table.event": "Event", "table.result": "Resultat", @@ -1188,6 +1209,15 @@ const TRANSLATIONS = { "overlay.active_member": "Aktiv förare/bil", "overlay.top_three": "Topp 3", "guide.host_title": "Hur Managed AMMC körs", + "guide.decoder_roles_title": "Decoderroller, IP och portar", + "guide.decoder_roles_1": "1. Main start/mål är obligatorisk och är den enda kanalen som får räkna varv. Exempel: decoder `192.168.1.10` -> AMMC-port `9000` -> `ws://server-ip:9000`.", + "guide.decoder_roles_2": "2. Mellantid är valfri och registrerar tiden från senaste main-passering utan att öka varvantalet. Exempel: `192.168.1.11` -> `9001`.", + "guide.decoder_roles_3": "3. Depåtid kräver två loopar: Depå in öppnar stoppet och Depå ut stänger det. Exempel: `192.168.1.12` -> `9002`, samt `192.168.1.13` -> `9003`.", + "guide.decoder_roles_4": "4. Decoder-IP är den fysiska decoderns adress. WebSocket-porten tillhör AMMC-processen på backend-hosten. Blanda inte ihop dessa två adresser.", + "guide.decoder_roles_5": "5. Varje fysisk decoder ska ha unik IP och varje aktiverad AMMC-process ska ha unik port. Main/split/depå får aldrig dela samma WebSocket-port.", + "guide.decoder_roles_6": "6. I Inställningar aktiverar du endast de extrakanaler som finns fysiskt, sparar Managed AMMC och klickar Starta AMMC. Använd sedan serverns WS-url och Anslut decoder.", + "guide.decoder_roles_7": "7. Öppna TCP 9000 och de valfria portarna 9001-9003 i backend-hostens brandvägg om webbläsaren kör på en annan maskin. Port 8081 används för webb/API.", + "guide.decoder_roles_8": "8. Kontrollera i Tidtagning att varje aktiverad roll står som Ansluten. Om main är offline räknas inga varv; om en extrakanal är offline fortsätter vanlig varvräkning.", "guide.host_1": "1. AMMC körs alltid på samma maskin som backend körs på (`npm.cmd start` i Windows eller `npm start` i Linux).", "guide.host_2": "2. Om du bara surfar in från en laptop/webbläsare startas ingen process där. Webbläsaren styr bara backend via HTTP.", "guide.host_3": "3. Kör backend på Linux-servern -> Linux-binären används: `AMMC/linux_x86-64/ammc-amb`.", @@ -1767,6 +1797,11 @@ const TRANSLATIONS = { "timing.connected": "Connected", "timing.disconnected": "Disconnected", "timing.last_message": "Last message", + "timing.main_decoder": "Main start/finish", + "timing.split_decoder": "Split timing", + "timing.pit_entry_decoder": "Pit entry", + "timing.pit_exit_decoder": "Pit exit", + "timing.pit_active": "IN PIT", "timing.control": "Session Control", "timing.speaker_panel": "Speaker panel", "timing.speaker_panel_hint": "These toggles enable or disable cues live for the current session and overlay without leaving Timing.", @@ -1853,6 +1888,20 @@ const TRANSLATIONS = { "timing.seeding_mode": "Seeding", "timing.position_grid_hint": "The grid shows the start order for position start in the active session.", "settings.decoder": "Decoder", + "settings.main_ws_url": "Main WebSocket URL", + "settings.enable_split_decoder": "Enable split decoder", + "settings.split_ws_url": "Split WebSocket URL", + "settings.enable_pit_decoders": "Enable pit decoders", + "settings.decoder_roles_hint": "Main counts laps. Split measures from the latest main crossing. Pit entry/exit measures pit stops. Every physical decoder needs its own IP and every AMMC channel needs a unique WebSocket port.", + "settings.managed_channels_hint": "The backend starts one AMMC process per enabled channel. Default ports: main 9000, split 9001, pit entry 9002, and pit exit 9003.", + "settings.pit_entry_ws_url": "Pit entry WebSocket URL", + "settings.pit_exit_ws_url": "Pit exit WebSocket URL", + "settings.split_decoder_host": "Split decoder IP / host", + "settings.split_ws_port": "Split WebSocket port", + "settings.pit_entry_decoder_host": "Pit entry decoder IP / host", + "settings.pit_entry_ws_port": "Pit entry WebSocket port", + "settings.pit_exit_decoder_host": "Pit exit decoder IP / host", + "settings.pit_exit_ws_port": "Pit exit WebSocket port", "settings.auto_reconnect": "Auto reconnect", "settings.save": "Save", "settings.connect_now": "Connect Now", @@ -1969,6 +2018,8 @@ const TRANSLATIONS = { "table.laps": "Laps", "table.last_lap": "Last Lap", "table.best_lap": "Best Lap", + "table.split": "Split", + "table.pit": "Pit", "table.gap": "Gap", "table.event": "Event", "table.result": "Result", @@ -2167,6 +2218,15 @@ const TRANSLATIONS = { "overlay.active_member": "Active driver/car", "overlay.top_three": "Top 3", "guide.host_title": "How Managed AMMC Runs", + "guide.decoder_roles_title": "Decoder roles, IP addresses, and ports", + "guide.decoder_roles_1": "1. Main start/finish is required and is the only channel allowed to count laps. Example: decoder `192.168.1.10` -> AMMC port `9000` -> `ws://server-ip:9000`.", + "guide.decoder_roles_2": "2. Split timing is optional and records time from the latest main crossing without incrementing laps. Example: `192.168.1.11` -> `9001`.", + "guide.decoder_roles_3": "3. Pit timing needs two loops: Pit entry opens the stop and Pit exit closes it. Example: `192.168.1.12` -> `9002`, and `192.168.1.13` -> `9003`.", + "guide.decoder_roles_4": "4. Decoder IP is the physical decoder address. The WebSocket port belongs to the AMMC process on the backend host. Do not confuse these two addresses.", + "guide.decoder_roles_5": "5. Every physical decoder needs a unique IP and every enabled AMMC process needs a unique port. Main/split/pit must never share a WebSocket port.", + "guide.decoder_roles_6": "6. In Settings, enable only physically installed extra channels, save Managed AMMC, and click Start AMMC. Then use the server WS URLs and Connect decoder.", + "guide.decoder_roles_7": "7. Allow TCP 9000 and optional ports 9001-9003 through the backend host firewall when the browser runs on another machine. Port 8081 serves the web UI/API.", + "guide.decoder_roles_8": "8. Verify in Timing that every enabled role is Connected. If main is offline no laps are counted; an offline optional channel does not stop normal lap counting.", "guide.host_1": "1. AMMC always runs on the backend host (`npm.cmd start` on Windows or `npm start` on Linux).", "guide.host_2": "2. If you only browse from a laptop/browser, no process is started there. The browser only controls the backend over HTTP.", "guide.host_3": "3. Run the backend on Linux -> the Linux binary is used: `AMMC/linux_x86-64/ammc-amb`.", @@ -2222,8 +2282,8 @@ const publicOverlayMode = Boolean(publicOverlayMatch); const state = loadState(); const requestedView = String(urlParams.get("view") || "").toLowerCase(); let currentView = overlayMode ? "overlay" : NAV_ITEMS.some((item) => item.id === requestedView) ? requestedView : "dashboard"; -let wsClient = null; -let reconnectTimer = null; +let wsClients = { main: null, split: null, pit_entry: null, pit_exit: null }; +let reconnectTimers = { main: null, split: null, pit_entry: null, pit_exit: null }; let backendSyncTimer = null; let appVersionPollTimer = null; let baselineAppVersion = ""; @@ -2611,6 +2671,11 @@ function loadState() { activeSessionId: parsed.activeSessionId || null, settings: { wsUrl: parsed.settings?.wsUrl || "ws://127.0.0.1:9000", + splitDecoderEnabled: parsed.settings?.splitDecoderEnabled === true, + splitWsUrl: String(parsed.settings?.splitWsUrl || "ws://127.0.0.1:9001"), + pitDecoderEnabled: parsed.settings?.pitDecoderEnabled === true, + pitEntryWsUrl: String(parsed.settings?.pitEntryWsUrl || "ws://127.0.0.1:9002"), + pitExitWsUrl: String(parsed.settings?.pitExitWsUrl || "ws://127.0.0.1:9003"), backendUrl: parsed.settings?.backendUrl || getDefaultBackendUrl(), language: parsed.settings?.language || DEFAULT_LANGUAGE, theme: AVAILABLE_THEMES.includes(String(parsed.settings?.theme || "").toLowerCase()) ? String(parsed.settings.theme).toLowerCase() : DEFAULT_THEME, @@ -2641,8 +2706,17 @@ function loadState() { }, decoder: { connected: false, + splitConnected: false, + pitEntryConnected: false, + pitExitConnected: false, lastMessageAt: null, + splitLastMessageAt: null, + pitEntryLastMessageAt: null, + pitExitLastMessageAt: null, lastError: "", + splitLastError: "", + pitEntryLastError: "", + pitExitLastError: "", }, }; } catch { @@ -2661,6 +2735,11 @@ function loadState() { activeSessionId: null, settings: { wsUrl: "ws://127.0.0.1:9000", + splitDecoderEnabled: false, + splitWsUrl: "ws://127.0.0.1:9001", + pitDecoderEnabled: false, + pitEntryWsUrl: "ws://127.0.0.1:9002", + pitExitWsUrl: "ws://127.0.0.1:9003", backendUrl: getDefaultBackendUrl(), language: DEFAULT_LANGUAGE, theme: DEFAULT_THEME, @@ -2689,8 +2768,17 @@ function loadState() { }, decoder: { connected: false, + splitConnected: false, + pitEntryConnected: false, + pitExitConnected: false, lastMessageAt: null, + splitLastMessageAt: null, + pitEntryLastMessageAt: null, + pitExitLastMessageAt: null, lastError: "", + splitLastError: "", + pitEntryLastError: "", + pitExitLastError: "", }, }; } @@ -3234,7 +3322,10 @@ function updateHeaderState() { function updateConnectionBadge() { const isOnline = state.decoder.connected; - dom.connectionBadge.textContent = isOnline ? t("ui.decoder_online") : t("ui.decoder_offline"); + const enabledExtraCount = Number(state.settings.splitDecoderEnabled) + (state.settings.pitDecoderEnabled ? 2 : 0); + const connectedExtraCount = Number(state.decoder.splitConnected) + Number(state.decoder.pitEntryConnected) + Number(state.decoder.pitExitConnected); + const extraStatus = enabledExtraCount ? ` • ${connectedExtraCount}/${enabledExtraCount}` : ""; + dom.connectionBadge.textContent = `${isOnline ? t("ui.decoder_online") : t("ui.decoder_offline")}${extraStatus}`; dom.connectionBadge.className = `badge ${isOnline ? "badge-online" : "badge-offline"}`; } diff --git a/src/core_views.js b/src/core_views.js index cd01d52..8f53349 100644 --- a/src/core_views.js +++ b/src/core_views.js @@ -147,6 +147,8 @@ export function renderDashboardView(deps) { ${t("dashboard.decoder_feed")} ${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")} ${escapeHtml(decoderUrl)} + ${state.settings.splitDecoderEnabled ? `${t("timing.split_decoder")}: ${state.decoder.splitConnected ? t("timing.connected") : t("timing.disconnected")}` : ""} + ${state.settings.pitDecoderEnabled ? `${t("table.pit")}: ${Number(state.decoder.pitEntryConnected) + Number(state.decoder.pitExitConnected)}/2` : ""}
${t("dashboard.backend_link")} diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index ffeffce..76ef54a 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -6,6 +6,13 @@ function parseRtcTime(value) { return Number.isFinite(ts) ? ts : null; } +const DECODER_ROLES = { + main: { urlKey: "wsUrl", connectedKey: "connected", messageKey: "lastMessageAt", errorKey: "lastError" }, + split: { urlKey: "splitWsUrl", connectedKey: "splitConnected", messageKey: "splitLastMessageAt", errorKey: "splitLastError" }, + pit_entry: { urlKey: "pitEntryWsUrl", connectedKey: "pitEntryConnected", messageKey: "pitEntryLastMessageAt", errorKey: "pitEntryLastError" }, + pit_exit: { urlKey: "pitExitWsUrl", connectedKey: "pitExitConnected", messageKey: "pitExitLastMessageAt", errorKey: "pitExitLastError" }, +}; + function resolveCompetitor(session, transponder, deps) { const { state, t, findEventTeamForPassing } = deps; const sessionType = String(session?.type || "").toLowerCase(); @@ -110,29 +117,49 @@ function resolveCompetitor(session, transponder, deps) { } export function connectDecoderHelper(deps) { + deps.disconnectDecoder({ silent: true }); + deps.state.decoder.lastError = ""; + deps.state.decoder.splitLastError = ""; + deps.state.decoder.pitEntryLastError = ""; + deps.state.decoder.pitExitLastError = ""; + deps.saveState(); + connectDecoderRole("main", deps); + if (deps.state.settings.splitDecoderEnabled && deps.state.settings.splitWsUrl) { + connectDecoderRole("split", deps); + } + if (deps.state.settings.pitDecoderEnabled && deps.state.settings.pitEntryWsUrl) { + connectDecoderRole("pit_entry", deps); + } + if (deps.state.settings.pitDecoderEnabled && deps.state.settings.pitExitWsUrl) { + connectDecoderRole("pit_exit", deps); + } +} + +function connectDecoderRole(role, deps) { const { state, t, saveState, getWsClient, setWsClient, getReconnectTimer, setReconnectTimer, - getCurrentView, renderView, updateConnectionBadge, processDecoderMessage, disconnectDecoder, + getCurrentView, renderView, updateConnectionBadge, processDecoderMessage, } = deps; + const roleConfig = DECODER_ROLES[role] || DECODER_ROLES.main; + const { connectedKey, messageKey: lastMessageKey, errorKey: lastErrorKey } = roleConfig; + const url = state.settings[roleConfig.urlKey]; + if (!url) return; - disconnectDecoder({ silent: true }); - state.decoder.lastError = ""; + state.decoder[lastErrorKey] = ""; saveState(); - - const url = state.settings.wsUrl; try { - setWsClient(new WebSocket(url)); + setWsClient(role, new WebSocket(url)); } catch (error) { - state.decoder.lastError = t("error.ws_invalid", { msg: error instanceof Error ? error.message : String(error) }); + state.decoder[lastErrorKey] = t("error.ws_invalid", { msg: error instanceof Error ? error.message : String(error) }); saveState(); renderView(); return; } - const wsClient = getWsClient(); + const wsClient = getWsClient(role); wsClient.onopen = () => { - state.decoder.connected = true; - state.decoder.lastError = ""; + state.decoder[connectedKey] = true; + state.decoder[lastErrorKey] = ""; saveState(); updateConnectionBadge(); if (["timing", "settings", "overlay"].includes(getCurrentView())) { @@ -141,7 +168,7 @@ export function connectDecoderHelper(deps) { }; wsClient.onmessage = (event) => { - state.decoder.lastMessageAt = Date.now(); + state.decoder[lastMessageKey] = Date.now(); let parsed; try { @@ -151,9 +178,9 @@ export function connectDecoderHelper(deps) { } if (Array.isArray(parsed)) { - parsed.forEach(processDecoderMessage); + parsed.forEach((message) => processDecoderMessage(message, role)); } else { - processDecoderMessage(parsed); + processDecoderMessage(parsed, role); } saveState(); @@ -164,15 +191,18 @@ export function connectDecoderHelper(deps) { }; wsClient.onclose = () => { - state.decoder.connected = false; + state.decoder[connectedKey] = false; saveState(); updateConnectionBadge(); if (state.settings.autoReconnect) { - clearTimeout(getReconnectTimer()); - setReconnectTimer(setTimeout(() => { - if (!state.decoder.connected) { - connectDecoderHelper(deps); + clearTimeout(getReconnectTimer(role)); + setReconnectTimer(role, setTimeout(() => { + const roleEnabled = role === "main" + || (role === "split" && state.settings.splitDecoderEnabled) + || (["pit_entry", "pit_exit"].includes(role) && state.settings.pitDecoderEnabled); + if (!state.decoder[connectedKey] && roleEnabled) { + connectDecoderRole(role, deps); } }, 2000)); } @@ -183,7 +213,7 @@ export function connectDecoderHelper(deps) { }; wsClient.onerror = () => { - state.decoder.lastError = t("error.decoder_connection"); + state.decoder[lastErrorKey] = t("error.decoder_connection"); saveState(); updateConnectionBadge(); if (["timing", "settings", "overlay"].includes(getCurrentView())) { @@ -194,24 +224,29 @@ export function connectDecoderHelper(deps) { export function disconnectDecoderHelper(options = {}, deps) { const { state, saveState, getWsClient, setWsClient, getReconnectTimer, updateConnectionBadge } = deps; - clearTimeout(getReconnectTimer()); - const wsClient = getWsClient(); - if (wsClient) { - wsClient.onopen = null; - wsClient.onmessage = null; - wsClient.onclose = null; - wsClient.onerror = null; - wsClient.close(); - setWsClient(null); - } + Object.keys(DECODER_ROLES).forEach((role) => { + clearTimeout(getReconnectTimer(role)); + const wsClient = getWsClient(role); + if (wsClient) { + wsClient.onopen = null; + wsClient.onmessage = null; + wsClient.onclose = null; + wsClient.onerror = null; + wsClient.close(); + setWsClient(role, null); + } + }); state.decoder.connected = false; + state.decoder.splitConnected = false; + state.decoder.pitEntryConnected = false; + state.decoder.pitExitConnected = false; if (!options.silent) { saveState(); } updateConnectionBadge(); } -export function processDecoderMessageHelper(msg, deps) { +export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { const { state, t, getActiveSession, ensureSessionResult, normalizeStartMode, getSessionLapWindow, findEventTeamForPassing, persistPassingToBackend, pushOverlayEvent, buildLeaderboard, @@ -280,6 +315,68 @@ export function processDecoderMessageHelper(msg, deps) { entry.transponder = transponder; const startMode = normalizeStartMode(session.startMode); + if (decoderRole === "pit_entry") { + if (entry.pitActive) return; + entry.pitEnteredAt = timestamp; + entry.pitActive = true; + if (!Array.isArray(result.pitEvents)) result.pitEvents = []; + result.pitEvents.push({ timestamp, type: "entry", transponder, competitorKey: key, driverId: entry.driverId, carId: entry.carId }); + pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT IN`); + saveState(); + return; + } + + if (decoderRole === "pit_exit") { + const enteredAt = Number(entry.pitEnteredAt || 0); + if (!enteredAt || timestamp <= enteredAt) return; + const pitStopMs = timestamp - enteredAt; + entry.pitActive = false; + entry.pitEnteredAt = null; + entry.pitStops = Math.max(0, Number(entry.pitStops || 0)) + 1; + entry.lastPitStopMs = pitStopMs; + entry.totalPitMs = Math.max(0, Number(entry.totalPitMs || 0)) + pitStopMs; + if (!entry.bestPitStopMs || pitStopMs < entry.bestPitStopMs) entry.bestPitStopMs = pitStopMs; + if (!Array.isArray(result.pitEvents)) result.pitEvents = []; + if (!Array.isArray(result.pitStops)) result.pitStops = []; + result.pitEvents.push({ timestamp, type: "exit", transponder, competitorKey: key, driverId: entry.driverId, carId: entry.carId, pitStopMs }); + result.pitStops.push({ enteredAt, exitedAt: timestamp, pitStopMs, transponder, competitorKey: key, driverId: entry.driverId, carId: entry.carId }); + pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT ${formatLap(pitStopMs)}`); + saveState(); + return; + } + + if (decoderRole === "split") { + const baseTimestamp = Number(entry.lastTimestamp || (startMode === "mass" ? session.startedAt : 0) || 0); + if (!baseTimestamp || timestamp <= baseTimestamp) return; + if (entry.lastSplitTimestamp && timestamp - Number(entry.lastSplitTimestamp) < 1000) return; + const splitMs = timestamp - baseTimestamp; + const previousSplitMs = Number(entry.lastSplitMs || 0) || 0; + entry.previousSplitMs = previousSplitMs || null; + entry.lastSplitMs = splitMs; + entry.lastSplitDeltaMs = previousSplitMs ? splitMs - previousSplitMs : null; + entry.lastSplitTimestamp = timestamp; + entry.lastSplitLap = Math.max(1, Number(entry.laps || 0) + 1); + if (!entry.bestSplitMs || splitMs < entry.bestSplitMs) entry.bestSplitMs = splitMs; + if (!Array.isArray(result.splits)) result.splits = []; + result.splits.push({ + timestamp, + transponder, + driverId: entry.driverId, + driverName: entry.driverName, + carId: entry.carId, + carName: entry.carName, + competitorKey: key, + lap: entry.lastSplitLap, + splitMs, + splitDeltaMs: entry.lastSplitDeltaMs, + loopId: String(msg.loop_id || ""), + decoderRole: "split", + }); + pushOverlayEvent("split", `${entry.displayName || entry.driverName} • ${formatLap(splitMs)}`); + saveState(); + return; + } + if (startMode === "staggered" && !entry.startTimestamp) { entry.startTimestamp = timestamp; entry.lastTimestamp = timestamp; diff --git a/src/misc_views.js b/src/misc_views.js index 1c6d072..5b306c6 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -72,11 +72,12 @@ export function renderGuideView({ dom, t }) {
+ ${renderGuidePanel("guide.decoder_roles_title", ["guide.decoder_roles_1", "guide.decoder_roles_2", "guide.decoder_roles_3", "guide.decoder_roles_4", "guide.decoder_roles_5", "guide.decoder_roles_6", "guide.decoder_roles_7", "guide.decoder_roles_8"], "", { t })} ${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"], "", { t })} - ${renderGuidePanel("guide.windows_title", ["guide.windows_1", "guide.windows_2", "guide.windows_3", "guide.windows_4", "guide.windows_5"], "", { t })}
+ ${renderGuidePanel("guide.windows_title", ["guide.windows_1", "guide.windows_2", "guide.windows_3", "guide.windows_4", "guide.windows_5"], "", { t })} ${renderGuidePanel("guide.linux_title", ["guide.linux_1", "guide.linux_2", "guide.linux_3"], "", { t })}
diff --git a/src/overlays.js b/src/overlays.js index ba6fd53..da63e9c 100644 --- a/src/overlays.js +++ b/src/overlays.js @@ -72,6 +72,7 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive
${escapeHtml(row.displayName || row.driverName)} ${escapeHtml(row.teamId ? `${t("overlay.active_member")}: ${formatTeamActiveMemberLabel(row)}` : row.subLabel || row.transponder || "-")} + ${row.lastSplitMs || row.pitActive || row.pitStops ? `${row.lastSplitMs ? `${t("table.split")}: ${formatLap(row.lastSplitMs)}` : ""}${row.lastSplitMs && (row.pitActive || row.pitStops) ? " • " : ""}${row.pitActive ? t("timing.pit_active") : row.pitStops ? `${t("table.pit")}: ${row.pitStops} / ${formatLap(row.totalPitMs)}` : ""}` : ""} ${row.invalidPending ? `${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? ` • ${formatLap(row.invalidLapMs)}` : ""}` : ""}
diff --git a/src/runtime_services.js b/src/runtime_services.js index cbb75f1..02c3a66 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -4,14 +4,24 @@ export function createDefaultAmmcConfigHelper() { autoStart: false, decoderHost: "", wsPort: 9000, + splitEnabled: false, + splitDecoderHost: "", + splitWsPort: 9001, + pitEnabled: false, + pitEntryDecoderHost: "", + pitEntryWsPort: 9002, + pitExitDecoderHost: "", + pitExitWsPort: 9003, executablePath: "", workingDirectory: "", extraArgs: "", }; } -export function getManagedWsUrlHelper({ ammc, getBackendUrl }) { - const port = Number(ammc.config?.wsPort || 9000); +export function getManagedWsUrlHelper({ ammc, getBackendUrl }, role = "main") { + const portKey = role === "split" ? "splitWsPort" : role === "pit_entry" ? "pitEntryWsPort" : role === "pit_exit" ? "pitExitWsPort" : "wsPort"; + const defaultPort = role === "split" ? 9001 : role === "pit_entry" ? 9002 : role === "pit_exit" ? 9003 : 9000; + const port = Number(ammc.config?.[portKey] || defaultPort); try { const backendUrl = new URL(getBackendUrl()); return `ws://${backendUrl.hostname}:${port}`; @@ -97,6 +107,11 @@ export function applyPersistedStateHelper(persisted, deps) { state.activeSessionId = persisted.activeSessionId || null; state.settings = { wsUrl: persisted.settings?.wsUrl || state.settings.wsUrl || "ws://127.0.0.1:9000", + splitDecoderEnabled: persisted.settings?.splitDecoderEnabled === true, + splitWsUrl: String(persisted.settings?.splitWsUrl || state.settings.splitWsUrl || "ws://127.0.0.1:9001"), + pitDecoderEnabled: persisted.settings?.pitDecoderEnabled === true, + pitEntryWsUrl: String(persisted.settings?.pitEntryWsUrl || state.settings.pitEntryWsUrl || "ws://127.0.0.1:9002"), + pitExitWsUrl: String(persisted.settings?.pitExitWsUrl || state.settings.pitExitWsUrl || "ws://127.0.0.1:9003"), backendUrl: persisted.settings?.backendUrl || state.settings.backendUrl || getDefaultBackendUrl(), language: persisted.settings?.language || state.settings.language || DEFAULT_LANGUAGE, theme: AVAILABLE_THEMES.includes(String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase()) ? String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase() : DEFAULT_THEME, diff --git a/src/settings.js b/src/settings.js index 1d295c8..e7a3469 100644 --- a/src/settings.js +++ b/src/settings.js @@ -43,7 +43,10 @@ export function renderSettings(deps) { } = deps; const ammcStatus = ammc.status || {}; const ammcOutput = Array.isArray(ammcStatus.lastOutput) ? ammcStatus.lastOutput : []; - const suggestedWsUrl = getManagedWsUrl(); + const suggestedWsUrl = getManagedWsUrl("main"); + const suggestedSplitWsUrl = getManagedWsUrl("split"); + const suggestedPitEntryWsUrl = getManagedWsUrl("pit_entry"); + const suggestedPitExitWsUrl = getManagedWsUrl("pit_exit"); const running = Boolean(ammcStatus.running); const speechVoices = getSpeechVoices(); const speechVoiceOptions = speechVoices @@ -54,7 +57,13 @@ export function renderSettings(deps) {

${t("settings.decoder")}

- +

${t("settings.decoder_roles_hint")}

+ + + + + + @@ -217,6 +226,7 @@ export function renderSettings(deps) {

${t("settings.managed_ammc")}

+

${t("settings.managed_channels_hint")}

- - - - - + + + + + + + + + + + + + + +
@@ -250,12 +270,14 @@ export function renderSettings(deps) {

${ammcStatus.executableExists ? t("settings.executable_found") : t("settings.executable_missing")}

${t("settings.decoder_host")}: ${escapeHtml(String(ammc.config.decoderHost || "-"))}

${t("settings.ws_port")}: ${escapeHtml(String(ammc.config.wsPort || 9000))}

+ ${ammc.config.splitEnabled ? `

${t("timing.split_decoder")}: ${ammcStatus.split?.running ? t("settings.running") : t("settings.stopped")} • ${escapeHtml(String(ammc.config.splitDecoderHost || "-"))}:${escapeHtml(String(ammc.config.splitWsPort || 9001))}${ammcStatus.split?.lastError ? ` • ${escapeHtml(ammcStatus.split.lastError)}` : ""}

` : ""} + ${ammc.config.pitEnabled ? `

${t("timing.pit_entry_decoder")}: ${ammcStatus.pitEntry?.running ? t("settings.running") : t("settings.stopped")} • ${escapeHtml(String(ammc.config.pitEntryDecoderHost || "-"))}:${escapeHtml(String(ammc.config.pitEntryWsPort || 9002))}${ammcStatus.pitEntry?.lastError ? ` • ${escapeHtml(ammcStatus.pitEntry.lastError)}` : ""}

${t("timing.pit_exit_decoder")}: ${ammcStatus.pitExit?.running ? t("settings.running") : t("settings.stopped")} • ${escapeHtml(String(ammc.config.pitExitDecoderHost || "-"))}:${escapeHtml(String(ammc.config.pitExitWsPort || 9003))}${ammcStatus.pitExit?.lastError ? ` • ${escapeHtml(ammcStatus.pitExit.lastError)}` : ""}

` : ""}

${t("settings.last_error")}: ${escapeHtml(ammc.lastError || ammcStatus.lastError || "-")}

${t("settings.backend_url")}: ${escapeHtml(getBackendUrl())}

WebSocket URL: ${escapeHtml(suggestedWsUrl)}

${escapeHtml(
           ammcOutput.length
-            ? ammcOutput.map((entry) => `[${new Date(entry.ts).toLocaleTimeString()}] ${entry.stream}: ${entry.line}`).join("\n")
+            ? ammcOutput.map((entry) => `[${new Date(entry.ts).toLocaleTimeString()}] ${entry.role || "main"}/${entry.stream}: ${entry.line}`).join("\n")
             : "-"
         )}
@@ -328,6 +350,11 @@ export function renderSettings(deps) { e.preventDefault(); const form = new FormData(e.currentTarget); state.settings.wsUrl = String(form.get("wsUrl") || "").trim(); + state.settings.splitDecoderEnabled = form.get("splitDecoderEnabled") === "on"; + state.settings.splitWsUrl = String(form.get("splitWsUrl") || "").trim(); + state.settings.pitDecoderEnabled = form.get("pitDecoderEnabled") === "on"; + state.settings.pitEntryWsUrl = String(form.get("pitEntryWsUrl") || "").trim(); + state.settings.pitExitWsUrl = String(form.get("pitExitWsUrl") || "").trim(); state.settings.backendUrl = String(form.get("backendUrl") || "").trim(); state.settings.autoReconnect = form.get("autoReconnect") === "on"; applyAudioFormValues(); @@ -543,6 +570,14 @@ export function renderSettings(deps) { autoStart: form.get("autoStart") === "on", decoderHost: String(form.get("decoderHost") || "").trim(), wsPort: Number(form.get("wsPort") || 9000), + splitEnabled: form.get("splitEnabled") === "on", + splitDecoderHost: String(form.get("splitDecoderHost") || "").trim(), + splitWsPort: Number(form.get("splitWsPort") || 9001), + pitEnabled: form.get("pitEnabled") === "on", + pitEntryDecoderHost: String(form.get("pitEntryDecoderHost") || "").trim(), + pitEntryWsPort: Number(form.get("pitEntryWsPort") || 9002), + pitExitDecoderHost: String(form.get("pitExitDecoderHost") || "").trim(), + pitExitWsPort: Number(form.get("pitExitWsPort") || 9003), executablePath: String(form.get("executablePath") || "").trim(), workingDirectory: String(form.get("workingDirectory") || "").trim(), extraArgs: String(form.get("extraArgs") || "").trim(), @@ -565,6 +600,14 @@ export function renderSettings(deps) { autoStart: form.get("autoStart") === "on", decoderHost: String(form.get("decoderHost") || "").trim(), wsPort: Number(form.get("wsPort") || 9000), + splitEnabled: form.get("splitEnabled") === "on", + splitDecoderHost: String(form.get("splitDecoderHost") || "").trim(), + splitWsPort: Number(form.get("splitWsPort") || 9001), + pitEnabled: form.get("pitEnabled") === "on", + pitEntryDecoderHost: String(form.get("pitEntryDecoderHost") || "").trim(), + pitEntryWsPort: Number(form.get("pitEntryWsPort") || 9002), + pitExitDecoderHost: String(form.get("pitExitDecoderHost") || "").trim(), + pitExitWsPort: Number(form.get("pitExitWsPort") || 9003), executablePath: String(form.get("executablePath") || "").trim(), workingDirectory: String(form.get("workingDirectory") || "").trim(), extraArgs: String(form.get("extraArgs") || "").trim(), @@ -580,7 +623,12 @@ export function renderSettings(deps) { }); document.getElementById("ammcUseServerWs")?.addEventListener("click", () => { - state.settings.wsUrl = getManagedWsUrl(); + state.settings.wsUrl = suggestedWsUrl; + state.settings.splitDecoderEnabled = Boolean(ammc.config.splitEnabled); + state.settings.splitWsUrl = suggestedSplitWsUrl; + state.settings.pitDecoderEnabled = Boolean(ammc.config.pitEnabled); + state.settings.pitEntryWsUrl = suggestedPitEntryWsUrl; + state.settings.pitExitWsUrl = suggestedPitExitWsUrl; saveState(); renderView(); }); diff --git a/src/styles.css b/src/styles.css index 5274404..62581e9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2525,6 +2525,17 @@ select:focus { letter-spacing: 0.06em; } +.overlay-race-driver .overlay-decoder-meta { + color: color-mix(in srgb, var(--accent-2) 55%, var(--text) 45%); + display: block; + margin-top: 1px; + font-size: 0.44rem; +} + +.form-grid-full { + grid-column: 1 / -1; +} + .overlay-prediction { margin-top: 1px; max-width: 122px; diff --git a/src/timing_logic.js b/src/timing_logic.js index 90f3dae..d7d512e 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -99,6 +99,9 @@ export function ensureSessionResult(sessionId, { state }) { if (!state.resultsBySession[sessionId]) { state.resultsBySession[sessionId] = { passings: [], + splits: [], + pitEvents: [], + pitStops: [], competitors: {}, adjustments: [], }; @@ -106,6 +109,15 @@ export function ensureSessionResult(sessionId, { state }) { if (!Array.isArray(state.resultsBySession[sessionId].adjustments)) { state.resultsBySession[sessionId].adjustments = []; } + if (!Array.isArray(state.resultsBySession[sessionId].splits)) { + state.resultsBySession[sessionId].splits = []; + } + if (!Array.isArray(state.resultsBySession[sessionId].pitEvents)) { + state.resultsBySession[sessionId].pitEvents = []; + } + if (!Array.isArray(state.resultsBySession[sessionId].pitStops)) { + state.resultsBySession[sessionId].pitStops = []; + } return state.resultsBySession[sessionId]; } @@ -831,5 +843,3 @@ export function buildFinalStandings(event, { getSessionsForEvent, buildLeaderboa return a.orderingIndex - b.orderingIndex; }); } - - diff --git a/src/timing_views.js b/src/timing_views.js index 5457265..11755ee 100644 --- a/src/timing_views.js +++ b/src/timing_views.js @@ -132,6 +132,8 @@ function renderLeaderboardModal(session, row, deps) {

${t("timing.total_time")}: ${escapeHtml(row.resultDisplay)}

${t("table.best_lap")}: ${formatLap(row.bestLapMs)}

${t("table.last_lap")}: ${formatLap(row.lastLapMs)}

+

${t("table.split")}: ${formatLap(row.lastSplitMs)}${Number.isFinite(row.lastSplitDeltaMs) ? ` (${row.lastSplitDeltaMs >= 0 ? "+" : ""}${formatLap(Math.abs(row.lastSplitDeltaMs))})` : ""}

+

${t("table.pit")}: ${row.pitActive ? t("timing.pit_active") : `${Number(row.pitStops || 0)} / ${formatLap(row.totalPitMs)}`}

${t("table.own_delta")}: ${escapeHtml(row.lapDelta || "-")}

@@ -192,6 +194,8 @@ function renderLeaderboard(rows, deps) { t("table.leader_gap"), t("table.ahead_gap"), t("table.own_delta"), + t("table.split"), + t("table.pit"), "", ], rows.map((row, idx) => { @@ -214,6 +218,8 @@ function renderLeaderboard(rows, deps) { ${escapeHtml(row.leaderGap || row.gap || "-")} ${escapeHtml(row.gapAhead || "-")} ${escapeHtml(row.lapDelta || "-")} + ${formatLap(row.lastSplitMs)} + ${row.pitActive ? `${t("timing.pit_active")}` : `${Number(row.pitStops || 0)} / ${formatLap(row.totalPitMs)}`} `; @@ -297,8 +303,16 @@ export function renderTimingView(deps) {

${t("timing.decoder_connection")}

- + + + + + + + + +
@@ -307,9 +321,12 @@ export function renderTimingView(deps) {
${t("timing.status")} - ${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")} + ${t("timing.main_decoder")}: ${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")} ${t("timing.last_message")}: ${state.decoder.lastMessageAt ? new Date(state.decoder.lastMessageAt).toLocaleString() : "-"} + ${state.settings.splitDecoderEnabled ? `${t("timing.split_decoder")}: ${state.decoder.splitConnected ? t("timing.connected") : t("timing.disconnected")}${t("timing.last_message")}: ${state.decoder.splitLastMessageAt ? new Date(state.decoder.splitLastMessageAt).toLocaleString() : "-"}` : ""} + ${state.settings.pitDecoderEnabled ? `${t("timing.pit_entry_decoder")}: ${state.decoder.pitEntryConnected ? t("timing.connected") : t("timing.disconnected")}${t("timing.last_message")}: ${state.decoder.pitEntryLastMessageAt ? new Date(state.decoder.pitEntryLastMessageAt).toLocaleString() : "-"}${t("timing.pit_exit_decoder")}: ${state.decoder.pitExitConnected ? t("timing.connected") : t("timing.disconnected")}${t("timing.last_message")}: ${state.decoder.pitExitLastMessageAt ? new Date(state.decoder.pitExitLastMessageAt).toLocaleString() : "-"}` : ""}

${escapeHtml(state.decoder.lastError || "")}

+

${escapeHtml(state.decoder.splitLastError || state.decoder.pitEntryLastError || state.decoder.pitExitLastError || "")}

@@ -406,8 +423,13 @@ export function renderTimingView(deps) { const input = document.getElementById("timingWsUrl"); if (input && input.value.trim()) { state.settings.wsUrl = input.value.trim(); - saveState(); } + state.settings.splitDecoderEnabled = Boolean(document.getElementById("timingSplitEnabled")?.checked); + state.settings.splitWsUrl = String(document.getElementById("timingSplitWsUrl")?.value || "").trim(); + state.settings.pitDecoderEnabled = Boolean(document.getElementById("timingPitEnabled")?.checked); + state.settings.pitEntryWsUrl = String(document.getElementById("timingPitEntryWsUrl")?.value || "").trim(); + state.settings.pitExitWsUrl = String(document.getElementById("timingPitExitWsUrl")?.value || "").trim(); + saveState(); connectDecoder(); }); diff --git a/tests/decoder_runtime.test.js b/tests/decoder_runtime.test.js new file mode 100644 index 0000000..e2655e9 --- /dev/null +++ b/tests/decoder_runtime.test.js @@ -0,0 +1,137 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { connectDecoderHelper, processDecoderMessageHelper } from "../src/decoder_runtime.js"; +import { ensureSessionResult } from "../src/timing_logic.js"; + +function createHarness() { + const session = { + id: "session_1", + eventId: "event_1", + mode: "race", + type: "team_race", + status: "running", + startMode: "mass", + startedAt: 1_000, + }; + const state = { + events: [{ + id: "event_1", + mode: "race", + teams: [{ id: "team_1", name: "Team One", driverIds: ["driver_1"], carIds: [] }], + }], + drivers: [{ id: "driver_1", name: "Driver One", transponder: "123" }], + cars: [], + resultsBySession: {}, + }; + let saveCount = 0; + const overlayEvents = []; + const deps = { + state, + t: (key) => key, + getActiveSession: () => session, + ensureSessionResult: (sessionId) => ensureSessionResult(sessionId, { state }), + normalizeStartMode: () => "mass", + getSessionLapWindow: () => ({ minLapMs: 0, maxLapMs: Number.POSITIVE_INFINITY }), + findEventTeamForPassing: (event, driverId) => event.teams.find((team) => team.driverIds.includes(driverId)) || null, + persistPassingToBackend: () => {}, + pushOverlayEvent: (type, label) => overlayEvents.push({ type, label }), + buildLeaderboard: () => [], + formatLap: (ms) => `${ms}ms`, + announcePassing: () => {}, + saveState: () => { saveCount += 1; }, + lastOverlayLeaderKeyBySession: {}, + lastOverlayTop3BySession: {}, + lastOverlayBestLapByKey: {}, + }; + const passing = (timestamp, role) => processDecoderMessageHelper({ + msg: "PASSING", + transponder: "123", + rtc_time: new Date(timestamp).toISOString(), + }, deps, role); + return { state, session, passing, overlayEvents, getSaveCount: () => saveCount }; +} + +test("split decoder records intermediate time without changing main lap state", () => { + const harness = createHarness(); + harness.passing(17_000, "main"); + const result = harness.state.resultsBySession.session_1; + const entry = result.competitors.team_team_1; + assert.equal(entry.laps, 1); + assert.equal(entry.lastTimestamp, 17_000); + + harness.passing(25_000, "split"); + assert.equal(entry.laps, 1); + assert.equal(entry.lastTimestamp, 17_000); + assert.equal(entry.lastSplitMs, 8_000); + assert.equal(entry.lastSplitLap, 2); + assert.equal(result.splits.length, 1); + assert.equal(result.passings.length, 1); +}); + +test("pit entry and exit create one timed stop without adding laps", () => { + const harness = createHarness(); + harness.passing(17_000, "main"); + harness.passing(30_000, "pit_entry"); + harness.passing(31_000, "pit_entry"); + harness.passing(42_500, "pit_exit"); + + const result = harness.state.resultsBySession.session_1; + const entry = result.competitors.team_team_1; + assert.equal(entry.laps, 1); + assert.equal(entry.pitActive, false); + assert.equal(entry.pitStops, 1); + assert.equal(entry.lastPitStopMs, 12_500); + assert.equal(entry.totalPitMs, 12_500); + assert.equal(result.pitEvents.length, 2); + assert.equal(result.pitStops.length, 1); + assert.equal(result.passings.length, 1); +}); + +test("connect decoder opens one websocket per enabled role", () => { + const OriginalWebSocket = globalThis.WebSocket; + class FakeWebSocket { + constructor(url) { this.url = url; } + close() {} + } + globalThis.WebSocket = FakeWebSocket; + try { + const clients = {}; + const timers = {}; + let disconnected = 0; + const state = { + settings: { + wsUrl: "ws://server:9000", + splitDecoderEnabled: true, + splitWsUrl: "ws://server:9001", + pitDecoderEnabled: true, + pitEntryWsUrl: "ws://server:9002", + pitExitWsUrl: "ws://server:9003", + }, + decoder: {}, + }; + connectDecoderHelper({ + state, + t: (key) => key, + saveState: () => {}, + getWsClient: (role) => clients[role] || null, + setWsClient: (role, client) => { clients[role] = client; }, + getReconnectTimer: (role) => timers[role] || null, + setReconnectTimer: (role, timer) => { timers[role] = timer; }, + getCurrentView: () => "dashboard", + renderView: () => {}, + updateConnectionBadge: () => {}, + processDecoderMessage: () => {}, + disconnectDecoder: () => { disconnected += 1; }, + }); + assert.equal(disconnected, 1); + assert.deepEqual(Object.fromEntries(Object.entries(clients).map(([role, client]) => [role, client.url])), { + main: "ws://server:9000", + split: "ws://server:9001", + pit_entry: "ws://server:9002", + pit_exit: "ws://server:9003", + }); + } finally { + globalThis.WebSocket = OriginalWebSocket; + } +}); diff --git a/tests/run.js b/tests/run.js index 4e49c48..9da3190 100644 --- a/tests/run.js +++ b/tests/run.js @@ -4,4 +4,5 @@ import "./race_timing_logic.test.js"; import "./judging_logic.test.js"; import "./runtime_persistence.test.js"; import "./audio_runtime.test.js"; +import "./decoder_runtime.test.js"; import "./server_persistence.integration.test.js"; diff --git a/tests/runtime_persistence.test.js b/tests/runtime_persistence.test.js index 26325a3..829f3e4 100644 --- a/tests/runtime_persistence.test.js +++ b/tests/runtime_persistence.test.js @@ -61,6 +61,11 @@ test("backend hydration normalizes persisted entities and settings", () => { activeSessionId: "session_1", settings: { wsUrl: "ws://decoder:9000", + splitDecoderEnabled: true, + splitWsUrl: "ws://decoder:9001", + pitDecoderEnabled: true, + pitEntryWsUrl: "ws://decoder:9002", + pitExitWsUrl: "ws://decoder:9003", backendUrl: "http://race:8081", language: "en", theme: "light", @@ -96,6 +101,10 @@ test("backend hydration normalizes persisted entities and settings", () => { assert.equal(state.training.normalized, true); assert.equal(state.activeSessionId, "session_1"); assert.equal(state.settings.theme, "light"); + assert.equal(state.settings.splitDecoderEnabled, true); + assert.equal(state.settings.splitWsUrl, "ws://decoder:9001"); + assert.equal(state.settings.pitDecoderEnabled, true); + assert.equal(state.settings.pitExitWsUrl, "ws://decoder:9003"); assert.equal(state.settings.autoReconnect, false); assert.equal(state.settings.audioEnabled, false); assert.equal(state.settings.speakerPassingCueEnabled, true); diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index eb67ffe..def05fc 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -87,6 +87,40 @@ test("SQLite API persists state, passings, and overlay session results", { timeo assert.equal(health.ok, true); assert.equal(health.dbPath, join(dataDir, "rc_timing.sqlite")); + const ammcConfigResponse = await fetch(`${baseUrl}/api/ammc/config`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + managedEnabled: true, + decoderHost: "192.168.1.10", + wsPort: 9000, + splitEnabled: true, + splitDecoderHost: "192.168.1.11", + splitWsPort: 9001, + pitEnabled: true, + pitEntryDecoderHost: "192.168.1.12", + pitEntryWsPort: 9002, + pitExitDecoderHost: "192.168.1.13", + pitExitWsPort: 9003, + }), + }); + const ammcConfigPayload = await ammcConfigResponse.json(); + assert.equal(ammcConfigResponse.status, 200); + assert.equal(ammcConfigPayload.config.splitWsPort, 9001); + assert.equal(ammcConfigPayload.config.pitExitDecoderHost, "192.168.1.13"); + assert.equal(ammcConfigPayload.status.role, "main"); + assert.equal(ammcConfigPayload.status.split.role, "split"); + assert.equal(ammcConfigPayload.status.pitEntry.wsPort, 9002); + + const duplicatePortResponse = await fetch(`${baseUrl}/api/ammc/config`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ splitEnabled: true, splitWsPort: 9000 }), + }); + const duplicatePortPayload = await duplicatePortResponse.json(); + assert.equal(duplicatePortResponse.status, 400); + assert.match(duplicatePortPayload.error, /unique WebSocket port/); + const emptyResponse = await fetch(`${baseUrl}/api/state`); assert.deepEqual(await emptyResponse.json(), { state: null, updatedAt: null }); diff --git a/windows/README.md b/windows/README.md index 23fee05..677dff7 100644 --- a/windows/README.md +++ b/windows/README.md @@ -39,7 +39,7 @@ Set-ExecutionPolicy -Scope Process Bypass .\windows\setup.ps1 ``` -Install, add inbound firewall rules for ports `8081` and `9000`, and start the server: +Install, add inbound firewall rules for `8081` and decoder channels `9000-9003`, and start the server: ```powershell Start-Process powershell -Verb RunAs -ArgumentList '-ExecutionPolicy Bypass -File .\windows\setup.ps1 -OpenFirewall -StartServer' @@ -116,16 +116,20 @@ The recommended normal operation is to let the backend manage AMMC: 1. Start RaceController with `npm.cmd start`. 2. Open `Settings`. 3. Enable `Managed AMMC`. -4. Set the decoder IP and WebSocket port `9000`. -5. Save and click `Start AMMC`. -6. Use the server WebSocket URL and connect the decoder. +4. Set the Main decoder IP and WebSocket port `9000`. +5. Optionally enable Split (`9001`) and Pit entry/exit (`9002`/`9003`) with the IP address of each physical decoder. +6. Save and click `Start AMMC`. +7. Use the server WebSocket URLs and connect the decoders. AMMC always runs on the computer that runs `npm.cmd start`. Opening the UI from a laptop does not start AMMC on that laptop. ## Network access - Admin/UI/backend: TCP `8081` -- AMMC WebSocket: TCP `9000` +- Main AMMC WebSocket: TCP `9000` +- Optional Split/Pit AMMC WebSockets: TCP `9001-9003` + +Each physical decoder has its own IP. Each AMMC process on the Windows backend has its own WebSocket port. Main is the only channel that counts laps; Split records intermediate times, while Pit entry and Pit exit open and close a timed pit stop. Find the Windows server address with: diff --git a/windows/setup.ps1 b/windows/setup.ps1 index 8390f42..db531ca 100644 --- a/windows/setup.ps1 +++ b/windows/setup.ps1 @@ -117,7 +117,7 @@ if ($OpenFirewall) { if (-not $isAdmin) { Write-Warning "Firewall rules require an Administrator PowerShell. Setup continues without them." } else { - foreach ($port in @(8081, 9000)) { + foreach ($port in @(8081, 9000, 9001, 9002, 9003)) { $ruleName = "JMK RB RaceController TCP $port" if (-not (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue)) { New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP -LocalPort $port -Action Allow | Out-Null