Skip to content

Commit 76af7b8

Browse files
authored
feat: move car speeds to main-process channel (#660)
1 parent 50fa52c commit 76af7b8

25 files changed

Lines changed: 1460 additions & 299 deletions

docs/ARCHITECTURE_RULES.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ Imports flow **downward** in this list. A layer may import from any layer below
6666
| Time delta display (0.1 s) | `useTelemetryValuesRounded` | 2dp |
6767
| Reference-lap interpolation | `useTelemetryValuesRounded` | 4dp |
6868
| Throttle / Brake / Clutch / SteeringWheelAngle | `useTelemetryValues` ||
69-
| `CarSpeedsStore` inputs | `useTelemetryValues` ||
69+
| `CarSpeedsProcessor` inputs | full precision ||
7070
| `FuelLevel` / `SessionTime` thresholds | `useTelemetryValues` ||
7171

7272
### 2.3 No-round list (do not round, ever)
7373

74-
`Throttle`, `Brake`, `Clutch`, `SteeringWheelAngle`, `FuelLevel`, `FuelLevelPct`, `SessionTime`, anything fed into `CarSpeedsStore`, anything fed into a fuel-projection threshold.
74+
`Throttle`, `Brake`, `Clutch`, `SteeringWheelAngle`, `FuelLevel`, `FuelLevelPct`, `SessionTime`, anything fed into `CarSpeedsProcessor`, anything fed into a fuel-projection threshold.
7575

7676
**Enforcement:** `grep -rn "useTelemetryValues(['\"]CarIdx" src/frontend` should return only no-round-list entries.
7777

@@ -154,9 +154,7 @@ Imports flow **downward** in this list. A layer may import from any layer below
154154
id: 'mywidget',
155155
component: MyWidget,
156156
settingsComponent: MyWidgetSettings,
157-
defaultConfig: {
158-
/* ... */
159-
},
157+
defaultConfig: {/* ... */},
160158
displayName: 'My Widget',
161159
alwaysEnabled: false,
162160
settingsVersion: 1,

docs/IMPLEMENTATION_PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ Today every renderer wakes 25 times/sec regardless of what's mounted. A weather
266266
### Phase 4 — Main-process processors
267267

268268
- [x] LapTimesProcessor
269-
- [ ] CarSpeedsProcessor
269+
- [x] CarSpeedsProcessor
270270
- [ ] RelativeGapProcessor
271271
- [ ] ReferenceLapProcessor
272272
- [ ] SectorTimingProcessor

src/app/bridge/iracingSdk/iracingSdkBridge.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { SessionLifecycle } from '../../sessionLifecycle';
1111
import type { ChannelBus } from '../channelBridge';
1212
import { FuelProjectionRuntime } from '../../processors/fuelProjectionRuntime';
1313
import { LapTimesRuntime } from '../../processors/lapTimesRuntime';
14+
import { CarSpeedsRuntime } from '../../processors/carSpeedsRuntime';
1415

1516
// Keys consumed by the renderer. Anything outside this set is dropped before
1617
// the telemetry object crosses the IPC boundary — reducing structured-clone
@@ -155,6 +156,10 @@ export async function publishIRacingSDKEvents(
155156
lifecycle && channelBus
156157
? new LapTimesRuntime(channelBus, lifecycle, perfMetrics, isTapeReplay)
157158
: undefined;
159+
const carSpeedsRuntime =
160+
lifecycle && channelBus
161+
? new CarSpeedsRuntime(channelBus, lifecycle, perfMetrics, isTapeReplay)
162+
: undefined;
158163

159164
let shouldStop = false;
160165
let lastRunningState: boolean | undefined = undefined;
@@ -253,6 +258,7 @@ export async function publishIRacingSDKEvents(
253258
perfMetrics.markEnd('lifecycleTelemetry');
254259
fuelProjectionRuntime?.onFrame(telemetry);
255260
lapTimesRuntime?.onFrame(telemetry);
261+
carSpeedsRuntime?.onFrame(telemetry);
256262
if (
257263
perfTelemetryDeliveryEnabled &&
258264
overlayManager.hasLegacyStreamSubscribers('telemetry')
@@ -282,6 +288,7 @@ export async function publishIRacingSDKEvents(
282288
latestSession = session;
283289
lifecycle?._onSession(session);
284290
fuelProjectionRuntime?.onSession(session);
291+
carSpeedsRuntime?.onSession(session);
285292
overlayManager.publishMessage('sessionData', session);
286293
sessionCallbacks.forEach((callback) => callback(session));
287294
perfMetrics.markEnd('sessionPublish');
@@ -340,6 +347,7 @@ export async function publishIRacingSDKEvents(
340347
runningStateCallbacks.clear();
341348
fuelProjectionRuntime?.dispose();
342349
lapTimesRuntime?.dispose();
350+
carSpeedsRuntime?.dispose();
343351
perfMetrics.stopReporting();
344352
},
345353
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { IrSdkBridge, Session, Telemetry } from '@irdashies/types';
3+
import { ChannelBus } from '../../channelBridge';
4+
5+
const callbacks = vi.hoisted(() => ({
6+
telemetry: undefined as ((value: Telemetry) => void) | undefined,
7+
session: undefined as ((value: Session) => void) | undefined,
8+
}));
9+
const stop = vi.hoisted(() => vi.fn());
10+
11+
vi.mock('./generateMockData', () => ({
12+
generateMockData: (): IrSdkBridge => ({
13+
onTelemetry(callback) {
14+
callbacks.telemetry = callback;
15+
return () => undefined;
16+
},
17+
onSessionData(callback) {
18+
callbacks.session = callback;
19+
return () => undefined;
20+
},
21+
onRunningState() {
22+
return () => undefined;
23+
},
24+
stop,
25+
}),
26+
}));
27+
28+
vi.mock('../../../perfMetrics', () => ({
29+
TelemetryPerfMetrics: class {
30+
startReporting = vi.fn();
31+
stopReporting = vi.fn();
32+
markStart = vi.fn();
33+
markEnd = vi.fn();
34+
tick = vi.fn();
35+
},
36+
}));
37+
38+
import { publishIRacingSDKEvents } from './mockSdkBridge';
39+
40+
const telemetry = (pct: number, time: number) =>
41+
({
42+
CarIdxLapDistPct: { value: [pct] },
43+
SessionTime: { value: [time] },
44+
SessionNum: { value: [1] },
45+
}) as unknown as Telemetry;
46+
47+
describe('mockSdkBridge car-speed channel', () => {
48+
beforeEach(() => {
49+
callbacks.telemetry = undefined;
50+
callbacks.session = undefined;
51+
stop.mockReset();
52+
});
53+
54+
it('feeds mock session and telemetry through the car-speed runtime', async () => {
55+
const bus = new ChannelBus();
56+
const publish = vi.spyOn(bus, 'publish');
57+
const target = {
58+
id: 1,
59+
isDestroyed: () => false,
60+
isVisible: () => true,
61+
send: vi.fn(),
62+
};
63+
bus.subscribe(target, 'car-speeds.snapshot');
64+
const overlayManager = { publishMessage: vi.fn() };
65+
const bridge = await publishIRacingSDKEvents(
66+
overlayManager as never,
67+
undefined,
68+
bus
69+
);
70+
71+
callbacks.session?.({
72+
WeekendInfo: { TrackLength: '1 km' },
73+
} as Session);
74+
callbacks.telemetry?.(telemetry(0.1, 1));
75+
callbacks.telemetry?.(telemetry(0.11, 1.1));
76+
77+
expect(publish).toHaveBeenCalledWith(
78+
'car-speeds.snapshot',
79+
expect.objectContaining({ carSpeeds: [360] })
80+
);
81+
bridge.stop();
82+
expect(publish).toHaveBeenLastCalledWith(
83+
'car-speeds.snapshot',
84+
expect.objectContaining({ carSpeeds: [] })
85+
);
86+
});
87+
});

src/app/bridge/iracingSdk/mock-data/mockSdkBridge.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,31 @@
11
import { generateMockData } from './generateMockData';
22
import { OverlayManager } from '../../../overlayManager';
33
import { TelemetryPerfMetrics } from '../../../perfMetrics';
4+
import type { SessionLifecycle } from '../../../sessionLifecycle';
5+
import type { ChannelBus } from '../../channelBridge';
6+
import { CarSpeedsRuntime } from '../../../processors/carSpeedsRuntime';
47

5-
export async function publishIRacingSDKEvents(overlayManager: OverlayManager) {
8+
export async function publishIRacingSDKEvents(
9+
overlayManager: OverlayManager,
10+
lifecycle?: SessionLifecycle,
11+
channelBus?: ChannelBus
12+
) {
613
const perfMetrics = new TelemetryPerfMetrics();
714
perfMetrics.startReporting();
815

916
const bridge = generateMockData();
17+
const carSpeedsRuntime = channelBus
18+
? new CarSpeedsRuntime(channelBus, lifecycle, perfMetrics)
19+
: undefined;
1020

1121
bridge.onSessionData((session) => {
22+
carSpeedsRuntime?.onSession(session);
1223
overlayManager.publishMessage('sessionData', session);
1324
});
1425

1526
bridge.onTelemetry((telemetry) => {
1627
perfMetrics.markStart('processTelemetry');
28+
carSpeedsRuntime?.onFrame(telemetry);
1729
perfMetrics.markStart('broadcast');
1830
overlayManager.publishMessage('telemetry', telemetry);
1931
perfMetrics.markEnd('broadcast');
@@ -29,6 +41,7 @@ export async function publishIRacingSDKEvents(overlayManager: OverlayManager) {
2941
return {
3042
...bridge,
3143
stop: () => {
44+
carSpeedsRuntime?.dispose();
3245
perfMetrics.stopReporting();
3346
originalStop();
3447
},
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { Session, Telemetry } from '@irdashies/types';
3+
import { CarSpeedsProcessor } from './CarSpeedsProcessor';
4+
5+
const session = (trackLength = '1 km') =>
6+
({ WeekendInfo: { TrackLength: trackLength } }) as Session;
7+
8+
const frame = (lapDistPct: number[], sessionTime: number, sessionNum = 1) =>
9+
({
10+
CarIdxLapDistPct: { value: lapDistPct },
11+
SessionTime: { value: [sessionTime] },
12+
SessionNum: { value: [sessionNum] },
13+
}) as unknown as Telemetry;
14+
15+
describe('CarSpeedsProcessor', () => {
16+
it('derives smoothed km/h speeds at 10 Hz', () => {
17+
const processor = new CarSpeedsProcessor();
18+
processor.init(session());
19+
processor.onFrame(frame([0.1], 1));
20+
processor.onFrame(frame([0.11], 1.05));
21+
expect(processor.snapshot().carSpeeds).toEqual([0]);
22+
processor.onFrame(frame([0.11], 1.1));
23+
expect(processor.snapshot().carSpeeds[0]).toBe(360);
24+
processor.onFrame(frame([0.125], 1.2));
25+
expect(processor.snapshot().carSpeeds[0]).toBe(450);
26+
});
27+
28+
it('handles start-finish wrap-around', () => {
29+
const processor = new CarSpeedsProcessor();
30+
processor.init(session());
31+
processor.onFrame(frame([0.99], 1));
32+
processor.onFrame(frame([0.01], 1.2));
33+
expect(processor.snapshot().carSpeeds[0]).toBe(360);
34+
});
35+
36+
it('keeps speeds aligned when the driver array grows', () => {
37+
const processor = new CarSpeedsProcessor();
38+
processor.init(session());
39+
processor.onFrame(frame([0.1], 1));
40+
processor.onFrame(frame([0.11], 1.1));
41+
processor.onFrame(frame([0.12, 0.5], 1.2));
42+
expect(processor.snapshot().carSpeeds).toEqual([360, 0]);
43+
});
44+
45+
it('resets on an in-frame session change', () => {
46+
const processor = new CarSpeedsProcessor();
47+
processor.init(session());
48+
processor.onFrame(frame([0.1], 1));
49+
processor.onFrame(frame([0.11], 1.1));
50+
processor.onFrame(frame([0.2], 2, 2));
51+
expect(processor.snapshot()).toMatchObject({
52+
carSpeeds: [0],
53+
sessionNum: 2,
54+
});
55+
});
56+
57+
it('clears on disconnect and suppresses replay scrubbing', () => {
58+
const processor = new CarSpeedsProcessor();
59+
processor.init(session());
60+
processor.onFrame(frame([0.1], 1));
61+
processor.onLifecycle({ type: 'disconnect' });
62+
processor.onFrame(frame([0.2], 2));
63+
expect(processor.snapshot().carSpeeds).toEqual([]);
64+
65+
processor.onLifecycle({ type: 'enter', replay: true });
66+
processor.onFrame(frame([0.3], 3));
67+
expect(processor.snapshot().carSpeeds).toEqual([]);
68+
});
69+
70+
it('emits zero speeds until session track length is available', () => {
71+
const processor = new CarSpeedsProcessor();
72+
processor.onFrame(frame([0.1, 0.2], 1));
73+
expect(processor.snapshot().carSpeeds).toEqual([0, 0]);
74+
});
75+
});

0 commit comments

Comments
 (0)