Extract decoder runtime and remove dead timing helpers
This commit is contained in:
1173
src/app.js
1173
src/app.js
File diff suppressed because it is too large
Load Diff
367
src/decoder_runtime.js
Normal file
367
src/decoder_runtime.js
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
function parseRtcTime(value) {
|
||||||
|
if (!value || typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ts = Date.parse(value);
|
||||||
|
return Number.isFinite(ts) ? ts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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) {
|
||||||
|
const {
|
||||||
|
state, t, saveState, getWsClient, setWsClient, getReconnectTimer, setReconnectTimer,
|
||||||
|
getCurrentView, renderView, updateConnectionBadge, processDecoderMessage, disconnectDecoder,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
disconnectDecoder({ silent: true });
|
||||||
|
state.decoder.lastError = "";
|
||||||
|
saveState();
|
||||||
|
|
||||||
|
const url = state.settings.wsUrl;
|
||||||
|
try {
|
||||||
|
setWsClient(new WebSocket(url));
|
||||||
|
} catch (error) {
|
||||||
|
state.decoder.lastError = t("error.ws_invalid", { msg: error instanceof Error ? error.message : String(error) });
|
||||||
|
saveState();
|
||||||
|
renderView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsClient = getWsClient();
|
||||||
|
wsClient.onopen = () => {
|
||||||
|
state.decoder.connected = true;
|
||||||
|
state.decoder.lastError = "";
|
||||||
|
saveState();
|
||||||
|
updateConnectionBadge();
|
||||||
|
if (["timing", "settings", "overlay"].includes(getCurrentView())) {
|
||||||
|
renderView();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wsClient.onmessage = (event) => {
|
||||||
|
state.decoder.lastMessageAt = Date.now();
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(String(event.data));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
parsed.forEach(processDecoderMessage);
|
||||||
|
} else {
|
||||||
|
processDecoderMessage(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveState();
|
||||||
|
updateConnectionBadge();
|
||||||
|
if (["timing", "dashboard", "overlay"].includes(getCurrentView())) {
|
||||||
|
renderView();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wsClient.onclose = () => {
|
||||||
|
state.decoder.connected = false;
|
||||||
|
saveState();
|
||||||
|
updateConnectionBadge();
|
||||||
|
|
||||||
|
if (state.settings.autoReconnect) {
|
||||||
|
clearTimeout(getReconnectTimer());
|
||||||
|
setReconnectTimer(setTimeout(() => {
|
||||||
|
if (!state.decoder.connected) {
|
||||||
|
connectDecoderHelper(deps);
|
||||||
|
}
|
||||||
|
}, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["timing", "settings", "overlay"].includes(getCurrentView())) {
|
||||||
|
renderView();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wsClient.onerror = () => {
|
||||||
|
state.decoder.lastError = t("error.decoder_connection");
|
||||||
|
saveState();
|
||||||
|
updateConnectionBadge();
|
||||||
|
if (["timing", "settings", "overlay"].includes(getCurrentView())) {
|
||||||
|
renderView();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
state.decoder.connected = false;
|
||||||
|
if (!options.silent) {
|
||||||
|
saveState();
|
||||||
|
}
|
||||||
|
updateConnectionBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processDecoderMessageHelper(msg, deps) {
|
||||||
|
const {
|
||||||
|
state, t, getActiveSession, ensureSessionResult, normalizeStartMode, getSessionLapWindow,
|
||||||
|
findEventTeamForPassing, persistPassingToBackend, pushOverlayEvent, buildLeaderboard,
|
||||||
|
formatLap, announcePassing, saveState, lastOverlayLeaderKeyBySession,
|
||||||
|
lastOverlayTop3BySession, lastOverlayBestLapByKey,
|
||||||
|
} = 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) {
|
||||||
|
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,
|
||||||
|
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.carId = competitor.carId ?? entry.carId;
|
||||||
|
entry.carName = competitor.carName || entry.carName;
|
||||||
|
entry.transponder = transponder;
|
||||||
|
const startMode = normalizeStartMode(session.startMode);
|
||||||
|
|
||||||
|
if (startMode === "staggered" && !entry.startTimestamp) {
|
||||||
|
entry.startTimestamp = timestamp;
|
||||||
|
entry.lastTimestamp = timestamp;
|
||||||
|
announcePassing(entry);
|
||||||
|
saveState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 leader = leaderboard[0];
|
||||||
|
if (leader?.key && lastOverlayLeaderKeyBySession[session.id] !== leader.key) {
|
||||||
|
lastOverlayLeaderKeyBySession[session.id] = leader.key;
|
||||||
|
pushOverlayEvent("leader", `${leader.displayName || leader.driverName} • P1`);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user