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

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