@hotlap.ai/telemetry 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
  import {
4
4
  createIRSDK
5
- } from "./index-t4qz2011.js";
5
+ } from "./index-ndbp3f5t.js";
6
6
  import"./index-hxw291vy.js";
7
7
  import"./index-c5jf6rjt.js";
8
8
  import"./index-wb6ebd0j.js";
@@ -12,20 +12,98 @@ import"./index-ftpvnye9.js";
12
12
  var TELEMETRY_PORT = 32100;
13
13
  var TELEMETRY_RATE_HZ = 60;
14
14
  var SESSION_INFO_RATE_HZ = 1;
15
+ var FIELD_PUBLISH_EVERY = 4;
16
+ var PROTOCOL_VERSION = 3;
17
+ var CORS_HEADERS = {
18
+ "Access-Control-Allow-Origin": "*",
19
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
20
+ "Access-Control-Allow-Headers": "Content-Type"
21
+ };
15
22
  var ir = createIRSDK();
16
23
  var tickCount = 0;
17
24
  var lastLap = -1;
18
25
  var lastSessionNum = -1;
19
- var sessionInfoSent = false;
20
- async function connectToIRacing() {
21
- console.log("Connecting to iRacing...");
22
- const connected = await ir.startup();
23
- if (!connected) {
24
- console.error("Failed to connect to iRacing. Make sure iRacing is running.");
25
- return false;
26
- }
27
- console.log("Connected to iRacing!");
28
- return true;
26
+ var lastSessionInfoFingerprint = "";
27
+ var telemetryKeys = [
28
+ "SessionTime",
29
+ "Lap",
30
+ "LapDistPct",
31
+ "Speed",
32
+ "RPM",
33
+ "Gear",
34
+ "Throttle",
35
+ "Brake",
36
+ "Clutch",
37
+ "SteeringWheelAngle",
38
+ "Lat",
39
+ "Lon",
40
+ "Alt",
41
+ "Yaw",
42
+ "Pitch",
43
+ "Roll",
44
+ "LapCurrentLapTime",
45
+ "LapLastLapTime",
46
+ "LapBestLapTime",
47
+ "LapDeltaToBestLap",
48
+ "FuelLevel",
49
+ "FuelLevelPct",
50
+ "OilTemp",
51
+ "WaterTemp",
52
+ "IsOnTrack",
53
+ "PlayerCarPosition",
54
+ "PlayerCarClassPosition",
55
+ "BrakeABSactive",
56
+ "dcBrakeBias",
57
+ "dcTractionControl",
58
+ "dcABS",
59
+ "LFtempCL",
60
+ "RFtempCL",
61
+ "LRtempCL",
62
+ "RRtempCL",
63
+ "LFpressure",
64
+ "RFpressure",
65
+ "LRpressure",
66
+ "RRpressure",
67
+ "LFwearM",
68
+ "RFwearM",
69
+ "LRwearM",
70
+ "RRwearM",
71
+ "SessionNum",
72
+ "OnPitRoad",
73
+ "PlayerCarIdx",
74
+ "CarIdxLapDistPct",
75
+ "SessionFlags",
76
+ "CarLeftRight"
77
+ ];
78
+ var fieldKeys = [
79
+ "CarIdxPosition",
80
+ "CarIdxClassPosition",
81
+ "CarIdxLap",
82
+ "CarIdxLapCompleted",
83
+ "CarIdxEstTime",
84
+ "CarIdxF2Time",
85
+ "CarIdxOnPitRoad",
86
+ "CarIdxTrackSurface",
87
+ "CarIdxLastLapTime",
88
+ "CarIdxBestLapTime",
89
+ "SessionState",
90
+ "SessionTimeRemain",
91
+ "SessionLapsRemainEx"
92
+ ];
93
+ var telemetryData = {};
94
+ for (const key of telemetryKeys) {
95
+ telemetryData[key] = null;
96
+ }
97
+ var fieldData = {};
98
+ for (const key of fieldKeys) {
99
+ fieldData[key] = null;
100
+ }
101
+ var roundedLapDistPct = new Array(64).fill(-1);
102
+ function numArray(value) {
103
+ return Array.isArray(value) ? value : [];
104
+ }
105
+ function boolArray(value) {
106
+ return Array.isArray(value) ? value : [];
29
107
  }
30
108
  function getSessionInfo() {
31
109
  const weekendInfo = ir.getSessionInfo("WeekendInfo");
@@ -36,6 +114,7 @@ function getSessionInfo() {
36
114
  const sessionNum = ir.get("SessionNum") ?? 0;
37
115
  return {
38
116
  trackId: weekendInfo.TrackID,
117
+ sectors: getSectorBoundaries(),
39
118
  trackName: weekendInfo.TrackName,
40
119
  trackDisplayName: weekendInfo.TrackDisplayName,
41
120
  trackLength: weekendInfo.TrackLength,
@@ -50,70 +129,230 @@ function getSessionInfo() {
50
129
  tickRate: 60
51
130
  };
52
131
  }
132
+ function getSectorBoundaries() {
133
+ const splitTimeInfo = ir.getSessionInfo("SplitTimeInfo");
134
+ const sectors = splitTimeInfo?.Sectors;
135
+ if (!Array.isArray(sectors))
136
+ return [];
137
+ return sectors.map((s) => ({ sectorNum: s.SectorNum ?? -1, startPct: s.SectorStartPct ?? -1 })).filter((s) => s.sectorNum >= 0 && s.startPct >= 0 && s.startPct < 1).sort((a, b) => a.startPct - b.startPct);
138
+ }
139
+ function readNullableNum(key) {
140
+ const value = telemetryData[key];
141
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
142
+ }
53
143
  function getTelemetryFrame() {
54
- ir.freezeVarBufferLatest();
144
+ ir.getMultipleInto(telemetryKeys, telemetryData);
55
145
  const frame = {
56
146
  tick: tickCount++,
57
- sessionTime: ir.get("SessionTime") ?? 0,
58
- lap: ir.get("Lap") ?? 0,
59
- lapDistPct: ir.get("LapDistPct") ?? 0,
60
- speed: ir.get("Speed") ?? 0,
61
- rpm: ir.get("RPM") ?? 0,
62
- gear: ir.get("Gear") ?? 0,
63
- throttle: ir.get("Throttle") ?? 0,
64
- brake: ir.get("Brake") ?? 0,
65
- clutch: ir.get("Clutch") ?? 0,
66
- steer: ir.get("SteeringWheelAngle") ?? 0,
67
- lat: ir.get("Lat") ?? 0,
68
- lon: ir.get("Lon") ?? 0,
69
- alt: ir.get("Alt") ?? 0,
70
- yaw: ir.get("Yaw") ?? 0,
71
- pitch: ir.get("Pitch") ?? 0,
72
- roll: ir.get("Roll") ?? 0,
73
- lapCurrentLapTime: ir.get("LapCurrentLapTime") ?? 0,
74
- lapLastLapTime: ir.get("LapLastLapTime") ?? 0,
75
- lapBestLapTime: ir.get("LapBestLapTime") ?? 0,
76
- lapDeltaToBestLap: ir.get("LapDeltaToBestLap") ?? 0,
77
- fuelLevel: ir.get("FuelLevel") ?? 0,
78
- fuelLevelPct: ir.get("FuelLevelPct") ?? 0,
79
- oilTemp: ir.get("OilTemp") ?? 0,
80
- waterTemp: ir.get("WaterTemp") ?? 0,
81
- isOnTrack: ir.get("IsOnTrack") ?? false,
82
- playerPosition: ir.get("PlayerCarPosition") ?? 0,
83
- playerClassPosition: ir.get("PlayerCarClassPosition") ?? 0,
84
- brakeABSactive: ir.get("BrakeABSactive") ?? false,
85
- dcBrakeBias: ir.get("dcBrakeBias") ?? 0,
86
- dcTractionControl: ir.get("dcTractionControl") ?? 0,
87
- dcABS: ir.get("dcABS") ?? 0,
88
- tireTempLF: ir.get("LFtempCL") ?? 0,
89
- tireTempRF: ir.get("RFtempCL") ?? 0,
90
- tireTempLR: ir.get("LRtempCL") ?? 0,
91
- tireTempRR: ir.get("RRtempCL") ?? 0
147
+ sessionTime: telemetryData.SessionTime ?? 0,
148
+ lap: telemetryData.Lap ?? 0,
149
+ lapDistPct: telemetryData.LapDistPct ?? 0,
150
+ speed: telemetryData.Speed ?? 0,
151
+ rpm: telemetryData.RPM ?? 0,
152
+ gear: telemetryData.Gear ?? 0,
153
+ throttle: telemetryData.Throttle ?? 0,
154
+ brake: telemetryData.Brake ?? 0,
155
+ clutch: telemetryData.Clutch ?? 0,
156
+ steer: telemetryData.SteeringWheelAngle ?? 0,
157
+ lat: telemetryData.Lat ?? 0,
158
+ lon: telemetryData.Lon ?? 0,
159
+ alt: telemetryData.Alt ?? 0,
160
+ yaw: telemetryData.Yaw ?? 0,
161
+ pitch: telemetryData.Pitch ?? 0,
162
+ roll: telemetryData.Roll ?? 0,
163
+ lapCurrentLapTime: telemetryData.LapCurrentLapTime ?? 0,
164
+ lapLastLapTime: telemetryData.LapLastLapTime ?? 0,
165
+ lapBestLapTime: telemetryData.LapBestLapTime ?? 0,
166
+ lapDeltaToBestLap: telemetryData.LapDeltaToBestLap ?? 0,
167
+ fuelLevel: telemetryData.FuelLevel ?? 0,
168
+ fuelLevelPct: telemetryData.FuelLevelPct ?? 0,
169
+ oilTemp: telemetryData.OilTemp ?? 0,
170
+ waterTemp: telemetryData.WaterTemp ?? 0,
171
+ isOnTrack: telemetryData.IsOnTrack ?? false,
172
+ playerPosition: telemetryData.PlayerCarPosition ?? 0,
173
+ playerClassPosition: telemetryData.PlayerCarClassPosition ?? 0,
174
+ brakeABSactive: telemetryData.BrakeABSactive ?? false,
175
+ dcBrakeBias: telemetryData.dcBrakeBias ?? 0,
176
+ dcTractionControl: telemetryData.dcTractionControl ?? 0,
177
+ dcABS: telemetryData.dcABS ?? 0,
178
+ tireTempLF: telemetryData.LFtempCL ?? 0,
179
+ tireTempRF: telemetryData.RFtempCL ?? 0,
180
+ tireTempLR: telemetryData.LRtempCL ?? 0,
181
+ tireTempRR: telemetryData.RRtempCL ?? 0,
182
+ tirePressureLF: readNullableNum("LFpressure"),
183
+ tirePressureRF: readNullableNum("RFpressure"),
184
+ tirePressureLR: readNullableNum("LRpressure"),
185
+ tirePressureRR: readNullableNum("RRpressure"),
186
+ tireWearLF: readNullableNum("LFwearM"),
187
+ tireWearRF: readNullableNum("RFwearM"),
188
+ tireWearLR: readNullableNum("LRwearM"),
189
+ tireWearRR: readNullableNum("RRwearM"),
190
+ sessionNum: telemetryData.SessionNum ?? 0,
191
+ onPitRoad: telemetryData.OnPitRoad ?? false,
192
+ playerCarIdx: telemetryData.PlayerCarIdx ?? -1,
193
+ carIdxLapDistPct: roundCarIdxLapDistPct(numArray(telemetryData.CarIdxLapDistPct)),
194
+ sessionFlags: (telemetryData.SessionFlags ?? 0) >>> 0,
195
+ carLeftRight: telemetryData.CarLeftRight ?? 0
92
196
  };
93
- ir.unfreezeVarBufferLatest();
94
197
  return frame;
95
198
  }
199
+ function roundCarIdxLapDistPct(raw) {
200
+ for (let i = 0;i < roundedLapDistPct.length; i++) {
201
+ const v = raw[i];
202
+ roundedLapDistPct[i] = v == null || v < 0 ? -1 : Math.round(v * 1e4) / 1e4;
203
+ }
204
+ return roundedLapDistPct;
205
+ }
206
+ var cachedPaceCarIdx = -1;
207
+ function getFieldData(frame) {
208
+ ir.getMultipleInto(fieldKeys, fieldData);
209
+ const round3 = (v) => Math.round(v * 1000) / 1000;
210
+ return {
211
+ tick: frame.tick,
212
+ sessionTime: frame.sessionTime,
213
+ sessionNum: frame.sessionNum,
214
+ sessionState: fieldData.SessionState ?? 0,
215
+ sessionTimeRemain: fieldData.SessionTimeRemain ?? -1,
216
+ sessionLapsRemain: fieldData.SessionLapsRemainEx ?? -1,
217
+ playerCarIdx: frame.playerCarIdx,
218
+ paceCarIdx: cachedPaceCarIdx,
219
+ position: numArray(fieldData.CarIdxPosition),
220
+ classPosition: numArray(fieldData.CarIdxClassPosition),
221
+ lap: numArray(fieldData.CarIdxLap),
222
+ lapCompleted: numArray(fieldData.CarIdxLapCompleted),
223
+ lapDistPct: frame.carIdxLapDistPct,
224
+ estTime: numArray(fieldData.CarIdxEstTime).map(round3),
225
+ f2Time: numArray(fieldData.CarIdxF2Time).map(round3),
226
+ onPitRoad: boolArray(fieldData.CarIdxOnPitRoad),
227
+ trackSurface: numArray(fieldData.CarIdxTrackSurface),
228
+ lastLapTime: numArray(fieldData.CarIdxLastLapTime).map(round3),
229
+ bestLapTime: numArray(fieldData.CarIdxBestLapTime).map(round3)
230
+ };
231
+ }
232
+ function computeSof(ratings) {
233
+ if (ratings.length === 0)
234
+ return 0;
235
+ const LN2 = Math.log(2);
236
+ const sum = ratings.reduce((acc, r) => acc + Math.pow(2, -r / 1600), 0);
237
+ if (sum <= 0)
238
+ return 0;
239
+ return Math.round(1600 / LN2 * Math.log(ratings.length / sum));
240
+ }
241
+ function buildSessionState(sessionInfoUpdate) {
242
+ const weekendInfo = ir.getSessionInfo("WeekendInfo");
243
+ const driverInfo = ir.getSessionInfo("DriverInfo");
244
+ if (!weekendInfo || !driverInfo)
245
+ return null;
246
+ const sessionNum = ir.get("SessionNum") ?? 0;
247
+ cachedPaceCarIdx = driverInfo.PaceCarIdx ?? -1;
248
+ const drivers = [];
249
+ for (const d of driverInfo.Drivers ?? []) {
250
+ if (!d || d.CarIdx == null || d.CarIdx < 0)
251
+ continue;
252
+ if (d.IsSpectator || d.CarIsPaceCar)
253
+ continue;
254
+ drivers.push({
255
+ carIdx: d.CarIdx,
256
+ userName: d.UserName ?? "Unknown",
257
+ teamName: d.TeamName || undefined,
258
+ carNumber: d.CarNumber ?? "",
259
+ carClassId: d.CarClassID ?? 0,
260
+ carClassShortName: d.CarClassShortName ?? "",
261
+ carClassColor: d.CarClassColor ?? 0,
262
+ carClassRelSpeed: d.CarClassRelSpeed ?? 0,
263
+ carClassEstLapTime: d.CarClassEstLapTime ?? 0,
264
+ iRating: d.IRating ?? 0,
265
+ licString: d.LicString ?? "",
266
+ licColor: d.LicColor ?? 0,
267
+ incidentCount: d.CurDriverIncidentCount ?? 0
268
+ });
269
+ }
270
+ const byClass = new Map;
271
+ for (const d of drivers) {
272
+ const list = byClass.get(d.carClassId);
273
+ if (list)
274
+ list.push(d);
275
+ else
276
+ byClass.set(d.carClassId, [d]);
277
+ }
278
+ const classes = [...byClass.entries()].map(([classId, list]) => ({
279
+ classId,
280
+ shortName: list[0]?.carClassShortName ?? "",
281
+ color: list[0]?.carClassColor ?? 0,
282
+ sof: computeSof(list.map((d) => d.iRating).filter((r) => r > 0)),
283
+ carCount: list.length
284
+ }));
285
+ const sessionsInfo = ir.getSessionInfo("SessionInfo");
286
+ const session = sessionsInfo?.Sessions?.find((s) => s.SessionNum === sessionNum) ?? sessionsInfo?.Sessions?.[sessionsInfo.Sessions.length - 1];
287
+ return {
288
+ sessionInfoUpdate,
289
+ subSessionId: weekendInfo.SubSessionID ?? 0,
290
+ trackId: weekendInfo.TrackID,
291
+ sessionNum,
292
+ sessionType: session?.SessionType ?? weekendInfo.EventType ?? "Unknown",
293
+ sessionLaps: session?.SessionLaps ?? "unlimited",
294
+ sessionTimeTotal: session?.SessionTime ?? "unlimited",
295
+ drivers,
296
+ classes,
297
+ results: (session?.ResultsPositions ?? []).map((r) => ({
298
+ carIdx: r.CarIdx ?? -1,
299
+ position: r.Position ?? 0,
300
+ classPosition: r.ClassPosition ?? 0,
301
+ fastestTime: r.FastestTime ?? -1,
302
+ lastTime: r.LastTime ?? -1,
303
+ lapsComplete: r.LapsComplete ?? 0,
304
+ incidents: r.Incidents ?? 0
305
+ })),
306
+ fastestLap: (session?.ResultsFastestLap ?? []).map((f) => ({
307
+ carIdx: f.CarIdx ?? -1,
308
+ fastestTime: f.FastestTime ?? -1
309
+ }))
310
+ };
311
+ }
312
+ var LAP_CHANGE_SETTLE_TICKS = 60;
313
+ var pendingLapChange = null;
314
+ function emitLapChange(pending, lapTime, server) {
315
+ const lapChangeMsg = {
316
+ type: "lap_change",
317
+ data: {
318
+ fromLap: pending.fromLap,
319
+ toLap: pending.toLap,
320
+ lapTime,
321
+ sessionNum: pending.sessionNum
322
+ }
323
+ };
324
+ server.publish("telemetry", JSON.stringify(lapChangeMsg));
325
+ console.log(`Lap ${pending.fromLap} completed: ${formatLapTime(lapTime)}`);
326
+ }
96
327
  function checkLapChange(frame, server) {
97
328
  const currentLap = frame.lap;
98
329
  const currentSessionNum = ir.get("SessionNum") ?? 0;
99
330
  if (currentSessionNum !== lastSessionNum) {
100
331
  lastSessionNum = currentSessionNum;
101
332
  lastLap = currentLap;
102
- sessionInfoSent = false;
333
+ lastSessionInfoFingerprint = "";
334
+ pendingLapChange = null;
103
335
  return;
104
336
  }
337
+ if (pendingLapChange) {
338
+ pendingLapChange.ticksWaited++;
339
+ const settled = frame.lapLastLapTime > 0 && frame.lapLastLapTime !== pendingLapChange.lastLapTimeAtChange;
340
+ if (settled || pendingLapChange.ticksWaited >= LAP_CHANGE_SETTLE_TICKS) {
341
+ emitLapChange(pendingLapChange, frame.lapLastLapTime, server);
342
+ pendingLapChange = null;
343
+ }
344
+ }
105
345
  if (currentLap !== lastLap && lastLap !== -1) {
106
- const lapChangeMsg = {
107
- type: "lap_change",
108
- data: {
109
- fromLap: lastLap,
110
- toLap: currentLap,
111
- lapTime: frame.lapLastLapTime,
112
- sessionNum: currentSessionNum
113
- }
346
+ if (pendingLapChange) {
347
+ emitLapChange(pendingLapChange, frame.lapLastLapTime, server);
348
+ }
349
+ pendingLapChange = {
350
+ fromLap: lastLap,
351
+ toLap: currentLap,
352
+ sessionNum: currentSessionNum,
353
+ ticksWaited: 0,
354
+ lastLapTimeAtChange: frame.lapLastLapTime
114
355
  };
115
- server.publish("telemetry", JSON.stringify(lapChangeMsg));
116
- console.log(`Lap ${lastLap} completed: ${formatLapTime(frame.lapLastLapTime)}`);
117
356
  }
118
357
  lastLap = currentLap;
119
358
  }
@@ -124,95 +363,192 @@ function formatLapTime(seconds) {
124
363
  const secs = (seconds % 60).toFixed(3).padStart(6, "0");
125
364
  return `${mins}:${secs}`;
126
365
  }
366
+ var cachedSessionStateJson = null;
127
367
  async function main() {
128
- const connected = await connectToIRacing();
129
- if (!connected) {
130
- process.exit(1);
131
- }
132
- const sessionInfo = getSessionInfo();
133
- if (sessionInfo) {
134
- console.log(`Track: ${sessionInfo.trackDisplayName}`);
135
- console.log(`Driver: ${sessionInfo.driverName}`);
136
- console.log(`Car: ${sessionInfo.carName} #${sessionInfo.carNumber}`);
137
- }
138
- const server = Bun.serve({
139
- port: TELEMETRY_PORT,
140
- fetch(req, server2) {
141
- const url = new URL(req.url);
142
- if (url.pathname === "/ws" || req.headers.get("upgrade") === "websocket") {
143
- const success = server2.upgrade(req);
144
- if (success) {
145
- return;
146
- }
147
- return new Response("WebSocket upgrade failed", { status: 400 });
148
- }
149
- if (url.pathname === "/status") {
150
- return Response.json({
151
- connected: ir.isConnected,
152
- initialized: ir.isInitialized,
153
- sessionInfo: getSessionInfo()
154
- });
155
- }
156
- if (url.pathname === "/health") {
157
- return new Response("OK", { status: 200 });
158
- }
159
- return new Response("Not found", { status: 404 });
160
- },
161
- websocket: {
162
- open(ws) {
163
- console.log("Client connected");
164
- ws.subscribe("telemetry");
165
- const statusMsg = {
166
- type: "status",
167
- data: {
168
- connected: ir.isConnected,
169
- initialized: ir.isInitialized
368
+ const server = (() => {
369
+ try {
370
+ return Bun.serve({
371
+ port: TELEMETRY_PORT,
372
+ hostname: "127.0.0.1",
373
+ fetch(req, server2) {
374
+ const url = new URL(req.url);
375
+ if (url.pathname === "/ws" || req.headers.get("upgrade") === "websocket") {
376
+ const success = server2.upgrade(req);
377
+ if (success) {
378
+ return;
379
+ }
380
+ return new Response("WebSocket upgrade failed", { status: 400, headers: CORS_HEADERS });
381
+ }
382
+ if (req.method === "OPTIONS") {
383
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
384
+ }
385
+ if (url.pathname === "/status") {
386
+ return Response.json({
387
+ connected: ir.isConnected,
388
+ initialized: ir.isInitialized,
389
+ protocol: PROTOCOL_VERSION,
390
+ sessionInfo: getSessionInfo()
391
+ }, { headers: CORS_HEADERS });
170
392
  }
171
- };
172
- ws.send(JSON.stringify(statusMsg));
173
- const sessionInfo2 = getSessionInfo();
174
- if (sessionInfo2) {
175
- const sessionMsg = {
176
- type: "session_info",
177
- data: sessionInfo2
178
- };
179
- ws.send(JSON.stringify(sessionMsg));
393
+ if (url.pathname === "/health") {
394
+ return Response.json({ ok: true, protocol: PROTOCOL_VERSION }, { headers: CORS_HEADERS });
395
+ }
396
+ if (url.pathname === "/shutdown" && req.method === "POST") {
397
+ console.log("Shutdown requested by client");
398
+ setTimeout(() => {
399
+ ir.shutdown();
400
+ process.exit(0);
401
+ }, 50);
402
+ return new Response("shutting down", { headers: CORS_HEADERS });
403
+ }
404
+ return new Response("Not found", { status: 404, headers: CORS_HEADERS });
405
+ },
406
+ websocket: {
407
+ open(ws) {
408
+ console.log("Client connected");
409
+ ws.subscribe("telemetry");
410
+ const statusMsg = {
411
+ type: "status",
412
+ data: {
413
+ connected: ir.isConnected,
414
+ initialized: ir.isInitialized
415
+ }
416
+ };
417
+ ws.send(JSON.stringify(statusMsg));
418
+ const sessionInfo = getSessionInfo();
419
+ if (sessionInfo) {
420
+ const sessionMsg = {
421
+ type: "session_info",
422
+ data: sessionInfo
423
+ };
424
+ ws.send(JSON.stringify(sessionMsg));
425
+ }
426
+ if (cachedSessionStateJson) {
427
+ ws.send(cachedSessionStateJson);
428
+ }
429
+ },
430
+ close(ws) {
431
+ console.log("Client disconnected");
432
+ },
433
+ message(ws, msg) {}
180
434
  }
181
- },
182
- close(ws) {
183
- console.log("Client disconnected");
184
- },
185
- message(ws, msg) {}
435
+ });
436
+ } catch (err) {
437
+ console.error("Could not bind port \u2014 another sidecar instance is likely running:", err);
438
+ process.exit(0);
186
439
  }
187
- });
440
+ })();
188
441
  console.log(`
189
442
  Telemetry server running at ws://127.0.0.1:${server.port}/ws`);
190
443
  console.log(`Status endpoint: http://127.0.0.1:${server.port}/status`);
191
- console.log(`Waiting for connections...
444
+ console.log(`Waiting for iRacing...
192
445
  `);
446
+ const REATTACH_AFTER_MS = 1e4;
447
+ let startupInFlight = false;
448
+ let wasConnected = false;
449
+ let disconnectedSince = null;
450
+ const ensureIRacing = async () => {
451
+ if (startupInFlight)
452
+ return;
453
+ if (ir.isInitialized) {
454
+ if (ir.isConnected) {
455
+ disconnectedSince = null;
456
+ return;
457
+ }
458
+ disconnectedSince ??= Date.now();
459
+ if (Date.now() - disconnectedSince < REATTACH_AFTER_MS)
460
+ return;
461
+ console.log("iRacing gone \u2014 detaching and waiting for a new session");
462
+ ir.shutdown();
463
+ lastSessionInfoFingerprint = "";
464
+ cachedSessionStateJson = null;
465
+ disconnectedSince = null;
466
+ }
467
+ startupInFlight = true;
468
+ try {
469
+ const ok = await ir.startup();
470
+ if (ok) {
471
+ console.log("Connected to iRacing!");
472
+ const sessionInfo = getSessionInfo();
473
+ if (sessionInfo) {
474
+ console.log(`Track: ${sessionInfo.trackDisplayName}`);
475
+ console.log(`Driver: ${sessionInfo.driverName}`);
476
+ console.log(`Car: ${sessionInfo.carName} #${sessionInfo.carNumber}`);
477
+ }
478
+ }
479
+ } catch (err) {
480
+ console.error("iRacing startup attempt failed:", err);
481
+ } finally {
482
+ startupInFlight = false;
483
+ }
484
+ };
485
+ await ensureIRacing();
486
+ setInterval(ensureIRacing, 2000);
193
487
  let lastSessionInfoTime = 0;
488
+ let fieldTickCounter = 0;
489
+ let lastSessionStateCheck = 0;
490
+ let lastSessionStatePublish = 0;
491
+ let lastPublishedSessionInfoUpdate = -1;
194
492
  setInterval(() => {
195
- if (!ir.isConnected) {
493
+ const connected = ir.isConnected && ir.isInitialized;
494
+ if (connected !== wasConnected) {
495
+ wasConnected = connected;
496
+ const statusMsg = {
497
+ type: "status",
498
+ data: { connected, initialized: ir.isInitialized }
499
+ };
500
+ server.publish("telemetry", JSON.stringify(statusMsg));
501
+ }
502
+ if (!connected) {
196
503
  return;
197
504
  }
505
+ ir.freezeVarBufferLatest();
198
506
  const frame = getTelemetryFrame();
507
+ fieldTickCounter++;
508
+ const field = fieldTickCounter % FIELD_PUBLISH_EVERY === 0 ? getFieldData(frame) : null;
509
+ ir.unfreezeVarBufferLatest();
199
510
  checkLapChange(frame, server);
200
511
  const telemetryMsg = {
201
512
  type: "telemetry",
202
513
  data: frame
203
514
  };
204
515
  server.publish("telemetry", JSON.stringify(telemetryMsg));
516
+ if (field) {
517
+ const fieldMsg = { type: "field", data: field };
518
+ server.publish("telemetry", JSON.stringify(fieldMsg));
519
+ }
205
520
  const now = Date.now();
206
521
  if (now - lastSessionInfoTime > 1000 / SESSION_INFO_RATE_HZ) {
207
522
  lastSessionInfoTime = now;
208
- const sessionInfo2 = getSessionInfo();
209
- if (sessionInfo2 && !sessionInfoSent) {
210
- const sessionMsg = {
211
- type: "session_info",
212
- data: sessionInfo2
213
- };
214
- server.publish("telemetry", JSON.stringify(sessionMsg));
215
- sessionInfoSent = true;
523
+ const weekendInfo = ir.getSessionInfo("WeekendInfo");
524
+ if (weekendInfo) {
525
+ const sessionNum = ir.get("SessionNum") ?? 0;
526
+ const fingerprint = `${weekendInfo.SubSessionID ?? 0}:${sessionNum}:${weekendInfo.TrackID ?? 0}`;
527
+ if (fingerprint !== lastSessionInfoFingerprint) {
528
+ const sessionInfo = getSessionInfo();
529
+ if (sessionInfo) {
530
+ const sessionMsg = {
531
+ type: "session_info",
532
+ data: sessionInfo
533
+ };
534
+ server.publish("telemetry", JSON.stringify(sessionMsg));
535
+ lastSessionInfoFingerprint = fingerprint;
536
+ }
537
+ }
538
+ }
539
+ }
540
+ if (now - lastSessionStateCheck > 500) {
541
+ lastSessionStateCheck = now;
542
+ const counter = ir.sessionInfoUpdate;
543
+ if (counter !== lastPublishedSessionInfoUpdate && now - lastSessionStatePublish >= 1000) {
544
+ const state = buildSessionState(counter);
545
+ if (state) {
546
+ const stateMsg = { type: "session_state", data: state };
547
+ cachedSessionStateJson = JSON.stringify(stateMsg);
548
+ server.publish("telemetry", cachedSessionStateJson);
549
+ lastPublishedSessionInfoUpdate = counter;
550
+ lastSessionStatePublish = now;
551
+ }
216
552
  }
217
553
  }
218
554
  }, 1000 / TELEMETRY_RATE_HZ);