Add multi-decoder split and pit timing support

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

View File

@@ -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"}`;
}