Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,5 @@ site/node_modules/
docs/dashboard.json
ka-local/register-task.ps1
ka-local/update-and-launch.ps1
ka-local/iracing-api.ts
docs/GANTRY_LAP_GRAPH_PLAN.md
1 change: 1 addition & 0 deletions .storybook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './telemetryDecorator';
export * from './channelSnapshotDecorator';
export * from './DynamicTelemetrySelector';
export * from './mockDashboardBridge';
export * from './raceControlDecorator';
109 changes: 109 additions & 0 deletions .storybook/raceControlDecorator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { Decorator } from '@storybook/react-vite';
import { useEffect } from 'react';
import { useRaceControlStore } from '../src/frontend/context/RaceControlStore/RaceControlStore';
import { IncidentType } from '../src/types/raceControl';
import type { Incident } from '../src/types/raceControl';

const mockIncidents: Incident[] = [
{
id: '0-1823.5',
carIdx: 0,
driverName: 'R. Grosjean',
carNumber: '77',
teamName: 'Alpine Racing',
sessionNum: 0,
sessionTime: 1823.5,
lapNum: 12,
replayFrameNum: 109410,
type: IncidentType.Crash,
lapDistPct: 0.482,
timestamp: Date.now() - 60000,
debug: {
trigger: 'sustained-slow',
evidence:
'avgSpeed 8.2 km/h < threshold 15 km/h for 12 consecutive frames (threshold: 10)',
thresholds: {
slowSpeedThreshold: 15,
slowFrameThreshold: 10,
suddenStopFromSpeed: 80,
suddenStopToSpeed: 20,
suddenStopFrames: 3,
offTrackDebounce: 3,
pitEntryDebounce: 3,
cooldownSeconds: 5,
},
carStateAtDetection: {
speedHistory: [9.1, 8.8, 8.5, 8.3, 8.2],
currentAvgSpeed: 8.58,
recentRawSpeeds: [9.1, 8.8, 8.5],
slowFrameCount: 12,
offTrackFrameCount: 0,
prevTrackSurface: 3,
prevSessionFlags: 0,
prevOnPitRoad: false,
prevLapDistPct: 0.4821,
},
frameHistory: [
{
speed: 45.2,
lapDistPct: 0.478,
trackSurface: 3,
sessionTime: 1823.4,
},
{
speed: 8.2,
lapDistPct: 0.482,
trackSurface: 3,
sessionTime: 1823.5,
},
],
},
},
{
id: '1-1801.2',
carIdx: 1,
driverName: 'O. Jarvis',
carNumber: '60',
teamName: 'JOTA',
sessionNum: 0,
sessionTime: 1801.2,
lapNum: 12,
replayFrameNum: 108072,
type: IncidentType.PitEntry,
lapDistPct: 0.97,
timestamp: Date.now() - 82000,
},
{
id: '2-1750.0',
carIdx: 2,
driverName: 'F. Albuquerque',
carNumber: '22',
teamName: 'United Autosports',
sessionNum: 0,
sessionTime: 1750.0,
lapNum: 11,
replayFrameNum: 105000,
type: IncidentType.OffTrack,
lapDistPct: 0.312,
timestamp: Date.now() - 133000,
},
];

const RaceControlLoader = () => {
const setIncidents = useRaceControlStore((s) => s.setIncidents);
useEffect(() => {
setIncidents(mockIncidents);
}, [setIncidents]);
return null;
};

const RaceControlDecoratorComponent = (Story: Parameters<Decorator>[0]) => (
<>
<RaceControlLoader />
<Story />
</>
);
RaceControlDecoratorComponent.displayName = 'RaceControlDecoratorComponent';

export const RaceControlDecorator: () => Decorator = () =>
RaceControlDecoratorComponent;
6 changes: 6 additions & 0 deletions src/app/bridge/iracingSdk/iracingSdkBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,5 +335,11 @@ export async function publishIRacingSDKEvents(
fuelProjectionRuntime?.dispose();
perfMetrics.stopReporting();
},
changeCameraNumber: (carNumber, group, camera) =>
sdk.changeCameraNumber(carNumber, group, camera),
changeReplayPosition: (position, frame) =>
sdk.changeReplayPosition(position, frame),
triggerReplaySessionSearch: (sessionNum, sessionTimeMs) =>
sdk.triggerReplaySessionSearch(sessionNum, sessionTimeMs),
};
}
6 changes: 6 additions & 0 deletions src/app/bridge/iracingSdk/mock-data/generateMockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,12 @@ export function generateMockData(sessionData?: {
sessionCallbacks.clear();
runningStateCallbacks.clear();
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
changeCameraNumber: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
changeReplayPosition: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
triggerReplaySessionSearch: () => {},
};
}

Expand Down
177 changes: 177 additions & 0 deletions src/app/bridge/raceControlBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { ipcMain } from 'electron';
import type { Session, Telemetry } from '@irdashies/types';
import { getCurrentBridge, onBridgeChanged } from './iracingSdk/setup';
import {
loadIncidents,
clearIncidents,
pruneOldSessions,
} from '../storage/incidentStorage';
import type { IncidentThresholds } from '../../types/raceControl';
import logger from '../logger';

/** Small interface over IncidentRuntime — keeps this bridge decoupled from
* processor internals while still reaching updateThresholds/session state. */
export interface IncidentRuntimeHandle {
onSession: (session: Session) => void;
onFrame: (frame: Telemetry) => void;
updateThresholds: (thresholds: IncidentThresholds) => void;
getCurrentSessionId: () => string;
onSessionIdChanged: (cb: (sessionId: string) => void) => () => void;
}

const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);

const thresholdKeys: (keyof IncidentThresholds)[] = [
'slowSpeedThreshold',
'slowFrameThreshold',
'suddenStopFromSpeed',
'suddenStopToSpeed',
'suddenStopFrames',
'offTrackDebounce',
'pitEntryDebounce',
'cooldownSeconds',
];

const isValidThresholds = (value: unknown): value is IncidentThresholds => {
if (!value || typeof value !== 'object') return false;
const candidate = value as Record<string, unknown>;
return thresholdKeys.every(
(key) => isFiniteNumber(candidate[key]) && (candidate[key] as number) >= 0
);
};

const isValidRetention = (value: unknown): value is 'all' | 5 | 10 | 20 =>
value === 'all' || value === 5 || value === 10 || value === 20;

const isValidCarNumber = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.length <= 8;

interface ReplayIncidentPayload {
sessionTime: number;
sessionNum: number;
carNumber: string;
carIdx?: number;
driverName?: string;
type?: string;
}

const isValidReplayIncident = (
value: unknown
): value is ReplayIncidentPayload => {
if (!value || typeof value !== 'object') return false;
const candidate = value as Record<string, unknown>;
return (
isFiniteNumber(candidate.sessionTime) &&
isFiniteNumber(candidate.sessionNum) &&
isValidCarNumber(candidate.carNumber)
);
};

const isValidReplaySeconds = (value: unknown): value is number =>
isFiniteNumber(value) && value >= 0 && value <= 300;

export const setupRaceControlBridge = (runtime: IncidentRuntimeHandle) => {
let retention: 'all' | 5 | 10 | 20 = 'all';

runtime.onSessionIdChanged(() => {
void pruneOldSessions(retention).catch((err) =>
logger.error('[RaceControl] Failed to prune old sessions:', err)
);
});

let unsubscribeSession: (() => void) | undefined;
let unsubscribeTelemetry: (() => void) | undefined;

const wireToTelemetryBridge = () => {
// Clean up previous subscriptions before re-wiring
unsubscribeSession?.();
unsubscribeTelemetry?.();

const bridge = getCurrentBridge();
if (!bridge) return;

unsubscribeSession =
bridge.onSessionData((session) => runtime.onSession(session)) ??
undefined;
unsubscribeTelemetry =
bridge.onTelemetry((telemetry) => runtime.onFrame(telemetry)) ??
undefined;
};

wireToTelemetryBridge();
onBridgeChanged(wireToTelemetryBridge);

ipcMain.handle(
'raceControl:updateThresholds',
(_event, thresholds: unknown) => {
if (!isValidThresholds(thresholds)) {
logger.warn('[RaceControl] Rejected invalid thresholds payload');
return;
}
runtime.updateThresholds(thresholds);
}
);

ipcMain.handle('raceControl:updateRetention', (_event, value: unknown) => {
if (!isValidRetention(value)) {
logger.warn('[RaceControl] Rejected invalid retention value:', value);
return;
}
retention = value;
});

ipcMain.handle('raceControl:getIncidents', () => {
return loadIncidents(runtime.getCurrentSessionId());
});

ipcMain.handle('raceControl:clearIncidents', () => {
// Returned so the IPC reply waits for the delete; clearIncidents became
// async, and without this the renderer could reload before it completed.
return clearIncidents(runtime.getCurrentSessionId());
});

ipcMain.handle('raceControl:focusDriver', (_event, carNumber: unknown) => {
if (!isValidCarNumber(carNumber)) {
logger.warn(
'[RaceControl] Rejected invalid focusDriver carNumber:',
carNumber
);
return;
}
const bridge = getCurrentBridge();
if (!bridge) return;
logger.info(`[RaceControl] focusDriver #${carNumber}`);
bridge.changeCameraNumber(carNumber, 0, 0);
});

ipcMain.handle(
'raceControl:replayIncident',
(_event, incident: unknown, seconds: unknown) => {
if (!isValidReplayIncident(incident)) {
logger.warn(
'[RaceControl] Rejected invalid replayIncident incident payload'
);
return;
}
if (!isValidReplaySeconds(seconds)) {
logger.warn(
'[RaceControl] Rejected invalid replayIncident seconds:',
seconds
);
return;
}
const bridge = getCurrentBridge();
if (!bridge) return;
const targetTimeMs = Math.max(
0,
Math.round((incident.sessionTime - seconds) * 1000)
);
logger.info(
`[RaceControl] replayIncident car=${incident.carIdx ?? 'unknown'} (${incident.driverName ?? 'unknown'} #${incident.carNumber}) type=${incident.type ?? 'unknown'} sessionTime=${incident.sessionTime.toFixed(2)} sessionNum=${incident.sessionNum} offset=-${seconds}s targetTimeMs=${targetTimeMs}`
);
bridge.changeCameraNumber(incident.carNumber, 0, 0);
bridge.triggerReplaySessionSearch(incident.sessionNum, targetTimeMs);
}
);
};
25 changes: 25 additions & 0 deletions src/app/bridge/rendererExposeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import type {
PersonalBestLapBridge,
ChromiumFlagsBridge,
ChromiumFlagsType,
RaceControlBridge,
Incident,
IncidentThresholds,
SessionRetention,
} from '@irdashies/types';
import {
isRendererPerfMetricsEnabled,
Expand Down Expand Up @@ -92,6 +96,13 @@ export function exposeBridge() {
ipcRenderer.removeAllListeners('sessionData');
ipcRenderer.removeAllListeners('runningState');
},
// Broadcast commands are main-process only; the renderer drives them
// through raceControlBridge below, not through this bridge.
/* eslint-disable @typescript-eslint/no-empty-function */
changeCameraNumber: () => {},
changeReplayPosition: () => {},
triggerReplaySessionSearch: () => {},
/* eslint-enable @typescript-eslint/no-empty-function */
});

contextBridge.exposeInMainWorld('dashboardBridge', {
Expand Down Expand Up @@ -347,4 +358,18 @@ export function exposeBridge() {
saveFlags: (flags: ChromiumFlagsType) =>
ipcRenderer.invoke('chromiumFlags:save', flags),
} as ChromiumFlagsBridge);

contextBridge.exposeInMainWorld('raceControlBridge', {
getIncidents: () => ipcRenderer.invoke('raceControl:getIncidents'),
replayIncident: (incident: Incident, seconds: number) =>
ipcRenderer.invoke('raceControl:replayIncident', incident, seconds),
focusDriver: (carNumber: string) =>
ipcRenderer.invoke('raceControl:focusDriver', carNumber),
clearIncidents: () => ipcRenderer.invoke('raceControl:clearIncidents'),
updateThresholds: (thresholds: IncidentThresholds) =>
ipcRenderer.invoke('raceControl:updateThresholds', thresholds),
updateRetention: (retention: SessionRetention) =>
ipcRenderer.invoke('raceControl:updateRetention', retention),
showGantryWindow: () => ipcRenderer.invoke('raceControl:showGantryWindow'),
} as RaceControlBridge);
}
11 changes: 9 additions & 2 deletions src/app/irsdk/native/irsdk_node.cc
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,18 @@ Napi::Value iRacingSdkNode::BroadcastMessage(const Napi::CallbackInfo &info)
irsdk_broadcastMsg(msgType, arg1, arg2, -1);
break;

// irsdk_BroadcastMsg msg, int arg1, int arg2
// These two must use the integer overload. The float overload scales its
// argument by 65536 before packing, which corrupts a frame number or a
// session-time-in-ms and makes the replay seek land at the session start.
case irskd_BroadcastReplaySetPlayPosition: // arg2 == frame number
case irsdk_BroadcastReplaySearchSessionTime: // arg2 == sessionTime in ms
irsdk_broadcastMsg(msgType, arg1, arg2.Int32Value());
break;

// irsdk_BroadcastMsg msg, int arg1, float arg2
case irsdk_BroadcastPitCommand: // arg1 == irsdk_PitCommandMode
case irsdk_BroadcastFFBCommand: // arg1 == irsdk_FFBCommandMode
case irsdk_BroadcastReplaySearchSessionTime:
case irskd_BroadcastReplaySetPlayPosition:
printf("BroadcastMessage(msgType: %d, arg1: %d, arg2: %f)\n", msgType, arg1, (float)arg2.FloatValue());
irsdk_broadcastMsg(msgType, arg1, (float)arg2.FloatValue());
break;
Expand Down
Loading