Files
Live_RC/src/decoder_runtime.js
2026-08-28 21:12:43 +02:00

502 lines
20 KiB
JavaScript

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;
}