function parseRtcTime(value) { if (!value || typeof value !== "string") { return null; } const ts = Date.parse(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(); const isOpenPractice = sessionType === "open_practice"; const isFreePractice = sessionType === "free_practice"; const isOpenMonitoringSession = isOpenPractice || isFreePractice; const event = state.events.find((item) => item.id === session?.eventId) || null; if (session.mode === "track") { const matchingAssignments = (session.assignments || []).filter((a) => { const car = state.cars.find((c) => c.id === a.carId); return car?.transponder === transponder; }); if (matchingAssignments.length > 1) { return { key: `track_ambiguous_${transponder}`, driverId: null, driverName: `Ambiguous TP ${transponder}`, carId: null, carName: t("common.unknown_car"), }; } const assignment = matchingAssignments[0]; if (assignment) { const driver = state.drivers.find((d) => d.id === assignment.driverId); const car = state.cars.find((c) => c.id === assignment.carId); return { key: `track_${assignment.id}`, driverId: driver?.id || null, driverName: driver?.name || t("common.unknown_driver"), speechName: driver?.speechName || driver?.name || "", carId: car?.id || null, carName: car?.name || t("common.unknown_car"), }; } return { key: `track_tp_${transponder}`, driverId: null, driverName: t("common.unassigned_driver"), carId: null, carName: t("common.unknown_car"), }; } if (session.mode === "race" && sessionType === "team_race") { const driver = state.drivers.find((d) => d.transponder === transponder) || null; const car = state.cars.find((c) => c.transponder === transponder) || null; const team = event ? findEventTeamForPassing(event, driver?.id || null, car?.id || null) : null; if (!team) { return { key: `ignore_team_${transponder}`, ignore: true, }; } const memberBits = [driver?.name || "", car?.name || ""].filter(Boolean); return { key: `team_${team.id}`, teamId: team.id, teamName: team.name, displayName: team.name, subLabel: memberBits.join(" • ") || transponder, driverId: driver?.id || null, driverName: driver?.name || team.name, speechName: driver?.speechName || driver?.name || team.name, carId: car?.id || null, carName: car?.name || t("common.unknown_car"), }; } const driver = state.drivers.find((d) => d.transponder === transponder); if (driver) { if (!isOpenMonitoringSession && Array.isArray(session.driverIds) && session.driverIds.length && !session.driverIds.includes(driver.id)) { return { key: `ignore_${driver.id}`, ignore: true, }; } return { key: `driver_${driver.id}`, driverId: driver.id, driverName: driver.name, speechName: driver.speechName || driver.name, displayName: driver.name, subLabel: driver.transponder || "", carId: null, carName: t("common.driver_car"), }; } return { key: `driver_tp_${transponder}`, driverId: null, driverName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"), displayName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"), subLabel: transponder, carId: null, carName: t("common.unknown_car"), }; } 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, } = 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; state.decoder[lastErrorKey] = ""; saveState(); try { setWsClient(role, new WebSocket(url)); } catch (error) { state.decoder[lastErrorKey] = t("error.ws_invalid", { msg: error instanceof Error ? error.message : String(error) }); saveState(); renderView(); return; } const wsClient = getWsClient(role); wsClient.onopen = () => { state.decoder[connectedKey] = true; state.decoder[lastErrorKey] = ""; saveState(); updateConnectionBadge(); if (["timing", "overlay"].includes(getCurrentView())) { renderView(); } }; wsClient.onmessage = (event) => { state.decoder[lastMessageKey] = Date.now(); let parsed; try { parsed = JSON.parse(String(event.data)); } catch { return; } const messages = Array.isArray(parsed) ? parsed : [parsed]; const changed = messages.reduce((didChange, message) => processDecoderMessage(message, role) || didChange, false); updateConnectionBadge(); if (changed && ["timing", "dashboard", "overlay", "training"].includes(getCurrentView())) { renderView(); } }; wsClient.onclose = () => { state.decoder[connectedKey] = false; saveState(); updateConnectionBadge(); if (state.settings.autoReconnect) { 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)); } if (["timing", "overlay"].includes(getCurrentView())) { renderView(); } }; wsClient.onerror = () => { state.decoder[lastErrorKey] = t("error.decoder_connection"); saveState(); updateConnectionBadge(); if (["timing", "overlay"].includes(getCurrentView())) { renderView(); } }; } export function disconnectDecoderHelper(options = {}, deps) { const { state, saveState, getWsClient, setWsClient, getReconnectTimer, updateConnectionBadge } = deps; 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, decoderRole = "main") { const { state, t, getActiveSession, ensureSessionResult, normalizeStartMode, getSessionLapWindow, findEventTeamForPassing, persistPassingToBackend, pushOverlayEvent, buildLeaderboard, formatLap, announcePassing, saveState, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, lastOverlayBestLapByKey, triggerRaceAudio = () => {}, getTrackRecordMs = () => null, } = deps; if (!msg || typeof msg !== "object") { return; } const type = String(msg.msg || msg.type || "").toUpperCase(); if (type !== "PASSING") { return; } const session = getActiveSession(); if (!session || session.status !== "running") { return; } const timestamp = parseRtcTime(msg.rtc_time) || Date.now(); const transponder = String(msg.transponder ?? msg.tran_code ?? "").replace("ID:", "").trim(); if (!transponder) { return; } const result = ensureSessionResult(session.id); const competitor = resolveCompetitor(session, transponder, { state, t, findEventTeamForPassing }); if (competitor.ignore) { triggerRaceAudio("unknown_transponder", { variables: { transponder }, scope: transponder, logData: { raceId: session.eventId, sessionId: session.id } }); return; } const key = competitor.key; if (!result.competitors[key]) { result.competitors[key] = { key, teamId: competitor.teamId || null, teamName: competitor.teamName || "", driverId: competitor.driverId, driverName: competitor.driverName, speechName: competitor.speechName || competitor.driverName, displayName: competitor.displayName || competitor.driverName, subLabel: competitor.subLabel || competitor.carName || "", carId: competitor.carId, carName: competitor.carName, transponder, laps: 0, lastLapMs: null, bestLapMs: null, startTimestamp: null, lastTimestamp: null, }; } const entry = result.competitors[key]; entry.teamId = competitor.teamId || entry.teamId || null; entry.teamName = competitor.teamName || entry.teamName || ""; entry.displayName = competitor.displayName || entry.displayName || competitor.driverName; entry.subLabel = competitor.subLabel || entry.subLabel || competitor.carName || ""; entry.driverId = competitor.driverId ?? entry.driverId; entry.driverName = competitor.driverName || entry.driverName; entry.speechName = competitor.speechName || entry.speechName || entry.driverName; entry.carId = competitor.carId ?? entry.carId; entry.carName = competitor.carName || entry.carName; entry.transponder = transponder; const startMode = normalizeStartMode(session.startMode); if (decoderRole === "pit_entry") { if (entry.pitActive) return; entry.pitEnteredAt = timestamp; entry.pitActive = true; entry.pitEntryDriverId = entry.driverId || null; entry.pitEntryDriverName = entry.driverName || ""; entry.pitEntryCarId = entry.carId || null; entry.pitEntryCarName = entry.carName || ""; entry.pitEntryTransponder = transponder; 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`); triggerRaceAudio("entering_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } }); saveState(); return true; } if (decoderRole === "pit_exit") { const enteredAt = Number(entry.pitEnteredAt || 0); if (!enteredAt || timestamp <= enteredAt) return; const pitStopMs = timestamp - enteredAt; const pitDriverId = entry.pitEntryDriverId ?? entry.driverId ?? null; const pitDriverName = entry.pitEntryDriverName || entry.driverName || ""; const pitCarId = entry.pitEntryCarId ?? entry.carId ?? null; const pitCarName = entry.pitEntryCarName || entry.carName || ""; const pitTransponder = entry.pitEntryTransponder || transponder; 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: pitDriverId, driverName: pitDriverName, carId: pitCarId, carName: pitCarName, pitStopMs }); result.pitStops.push({ enteredAt, exitedAt: timestamp, pitStopMs, transponder: pitTransponder, exitTransponder: transponder, competitorKey: key, driverId: pitDriverId, driverName: pitDriverName, carId: pitCarId, carName: pitCarName }); entry.pitEntryDriverId = null; entry.pitEntryDriverName = ""; entry.pitEntryCarId = null; entry.pitEntryCarName = ""; entry.pitEntryTransponder = ""; pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT ${formatLap(pitStopMs)}`); triggerRaceAudio("leaving_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } }); saveState(); return true; } 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 true; } if (startMode === "staggered" && !entry.startTimestamp) { entry.startTimestamp = timestamp; entry.lastTimestamp = timestamp; announcePassing(entry); saveState(); return true; } if (!entry.startTimestamp) { entry.startTimestamp = session.startedAt || timestamp; } const baseTs = entry.lastTimestamp || entry.startTimestamp || session.startedAt || timestamp; const lapMs = Math.max(0, timestamp - baseTs); const { minLapMs, maxLapMs } = getSessionLapWindow(session); let validLap = true; let invalidReason = ""; if (minLapMs > 0 && lapMs > 0 && lapMs < minLapMs) { validLap = false; invalidReason = "below_min"; } else if (Number.isFinite(maxLapMs) && maxLapMs > 0 && lapMs > maxLapMs) { validLap = false; invalidReason = "above_max"; } const passing = { timestamp, transponder, teamId: entry.teamId, teamName: entry.teamName, driverId: entry.driverId, driverName: entry.driverName, speechName: entry.speechName, displayName: entry.displayName, subLabel: entry.subLabel, carId: entry.carId, carName: entry.carName, competitorKey: key, lapMs, validLap, invalidReason, strength: msg.strength, loopId: String(msg.loop_id || ""), resend: Boolean(msg.resend), }; if (!validLap) { result.passings.push(passing); if (invalidReason === "above_max") { entry.lastTimestamp = timestamp; } persistPassingToBackend(session.id, passing); saveState(); return true; } const previousPersonalBest = Number(entry.bestLapMs || 0) || null; const leaderboardBefore = buildLeaderboard(session); const previousPosition = leaderboardBefore.findIndex((row) => row.key === key); const previousLeaderKey = leaderboardBefore[0]?.key || null; const previousSessionFastest = leaderboardBefore.map((row) => Number(row.bestLapMs || 0)).filter((value) => value > 500).sort((a, b) => a - b)[0] || null; const previousTrackRecord = Number(getTrackRecordMs(session) || 0) || null; entry.laps += 1; entry.lastLapMs = lapMs; entry.lastTimestamp = timestamp; if (lapMs > 500 && (!entry.bestLapMs || lapMs < entry.bestLapMs)) { entry.bestLapMs = lapMs; } result.passings.push(passing); persistPassingToBackend(session.id, passing); pushOverlayEvent("passing", `${entry.displayName || entry.driverName} • ${formatLap(entry.lastLapMs)}`); const leaderboard = buildLeaderboard(session); const currentPosition = leaderboard.findIndex((row) => row.key === key); const audioVariables = { driverName: entry.speechName || entry.displayName || entry.driverName, lapTime: formatLap(lapMs) }; if (previousPersonalBest && lapMs < previousPersonalBest) triggerRaceAudio("personal_best", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); if (previousSessionFastest && lapMs < previousSessionFastest) triggerRaceAudio("fastest_lap", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); if (previousTrackRecord && lapMs < previousTrackRecord) triggerRaceAudio("track_record", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); if (previousPosition >= 0 && currentPosition >= 0 && currentPosition < previousPosition) triggerRaceAudio("position_gain", { driverId: entry.driverId, competitorKey: key }); if (previousPosition >= 0 && currentPosition > previousPosition) triggerRaceAudio("position_loss", { driverId: entry.driverId, competitorKey: key }); const leader = leaderboard[0]; if (leader?.key && lastOverlayLeaderKeyBySession[session.id] !== leader.key) { lastOverlayLeaderKeyBySession[session.id] = leader.key; pushOverlayEvent("leader", `${leader.displayName || leader.driverName} • P1`); if (previousLeaderKey && previousLeaderKey !== leader.key) triggerRaceAudio("new_leader", { variables: { driverName: leader.displayName || leader.driverName }, driverId: leader.driverId, competitorKey: leader.key, logData: { raceId: session.eventId, driverId: leader.driverId } }); } if (entry.bestLapMs && Number.isFinite(entry.bestLapMs)) { const bestKey = `${session.id}:${entry.key}`; const previousBest = lastOverlayBestLapByKey[bestKey]; if (!previousBest || entry.bestLapMs < previousBest) { lastOverlayBestLapByKey[bestKey] = entry.bestLapMs; pushOverlayEvent("bestlap", `${entry.displayName || entry.driverName} • ${formatLap(entry.bestLapMs)}`); } } const top3Keys = leaderboard.slice(0, 3).map((row) => row.key); const previousTop3 = lastOverlayTop3BySession[session.id] || []; if (top3Keys.join("|") !== previousTop3.join("|")) { lastOverlayTop3BySession[session.id] = top3Keys; if (previousTop3.length) { pushOverlayEvent("top3", `${t("overlay.mode_leaderboard")} • Top 3 updated`); } } announcePassing(entry); saveState(); return true; }