iobroker.f1 0.1.3

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.
package/build/main.js ADDED
@@ -0,0 +1,1028 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ const utils = __importStar(require("@iobroker/adapter-core"));
40
+ const axios_1 = __importDefault(require("axios"));
41
+ const ws_1 = __importDefault(require("ws"));
42
+ // ── Constants ─────────────────────────────────────────────────────────────────
43
+ const SUBSCRIBE_STREAMS = [
44
+ "TrackStatus",
45
+ "SessionStatus",
46
+ "SessionInfo",
47
+ "WeatherData",
48
+ "LapCount",
49
+ "ExtrapolatedClock",
50
+ "TimingData",
51
+ "DriverList",
52
+ "TimingAppData",
53
+ "RaceControlMessages",
54
+ "TopThree",
55
+ "TeamRadio",
56
+ "PitStopSeries",
57
+ "TyreStintSeries",
58
+ ];
59
+ const SESSION_DURATIONS = {
60
+ "Practice 1": 60,
61
+ "Practice 2": 60,
62
+ "Practice 3": 60,
63
+ Qualifying: 60,
64
+ "Sprint Qualifying": 45,
65
+ Sprint: 45,
66
+ Race: 120,
67
+ };
68
+ const TRACK_STATUS_MAP = {
69
+ 1: "AllClear",
70
+ 2: "Yellow",
71
+ 3: "Flag",
72
+ 4: "SafetyCar",
73
+ 5: "RedFlag",
74
+ 6: "VSCDeployed",
75
+ 7: "VSCEnding",
76
+ 8: "SafetyCarEnding",
77
+ };
78
+ // ── Adapter class ─────────────────────────────────────────────────────────────
79
+ class F1 extends utils.Adapter {
80
+ JOLPICA_BASE = "https://api.jolpi.ca/ergast/f1";
81
+ ERGAST_BASE = "https://ergast.com/api/f1";
82
+ SIGNALR_BASE = "https://livetiming.formula1.com/signalr";
83
+ // HTTP clients
84
+ ergastApi;
85
+ ltApi;
86
+ // Timers
87
+ scheduleInterval;
88
+ liveCheckInterval;
89
+ reconnectTimeout;
90
+ // Live state
91
+ currentLiveSession = null;
92
+ lastSavedSession = "";
93
+ ws = null;
94
+ wsConnecting = false;
95
+ // In-memory SignalR stream caches (merged incrementally)
96
+ driverList = {};
97
+ timingData = {};
98
+ timingAppData = {};
99
+ rcMessages = [];
100
+ constructor(options = {}) {
101
+ super({ ...options, name: "f1" });
102
+ this.ergastApi = axios_1.default.create({
103
+ timeout: 15000,
104
+ headers: { "User-Agent": "ioBroker.f1/1.0" },
105
+ });
106
+ this.ltApi = axios_1.default.create({
107
+ baseURL: "https://livetiming.formula1.com",
108
+ timeout: 8000,
109
+ headers: { "User-Agent": "ioBroker.f1/1.0" },
110
+ });
111
+ this.on("ready", this.onReady.bind(this));
112
+ this.on("stateChange", this.onStateChange.bind(this));
113
+ this.on("unload", this.onUnload.bind(this));
114
+ }
115
+ async onReady() {
116
+ this.log.info("Starting F1 adapter...");
117
+ await this.initializeStates();
118
+ await this.setStateAsync("info.connection", { val: false, ack: true });
119
+ // Initial full data load
120
+ await this.refreshJolpicaData();
121
+ // Hourly Jolpica refresh
122
+ this.scheduleInterval = setInterval(() => void this.refreshJolpicaData(), 60 * 60 * 1000);
123
+ // Live check every 60 seconds
124
+ await this.checkLiveStatus();
125
+ this.liveCheckInterval = setInterval(() => void this.checkLiveStatus(), 60 * 1000);
126
+ await this.setStateAsync("info.connection", { val: true, ack: true });
127
+ }
128
+ onStateChange(id, state) {
129
+ if (!state || state.ack) {
130
+ return;
131
+ }
132
+ this.log.debug(`State change: ${id}`);
133
+ }
134
+ onUnload(callback) {
135
+ try {
136
+ if (this.scheduleInterval) {
137
+ clearInterval(this.scheduleInterval);
138
+ }
139
+ if (this.liveCheckInterval) {
140
+ clearInterval(this.liveCheckInterval);
141
+ }
142
+ if (this.reconnectTimeout) {
143
+ clearTimeout(this.reconnectTimeout);
144
+ }
145
+ this.disconnectSignalR();
146
+ callback();
147
+ }
148
+ catch {
149
+ callback();
150
+ }
151
+ }
152
+ // ── State Initialization ──────────────────────────────────────────────────
153
+ async initializeStates() {
154
+ const channels = [
155
+ {
156
+ id: "schedule",
157
+ name: "Race Schedule",
158
+ states: [
159
+ { id: "next_race_name", name: "Next Race Name", type: "string", role: "text" },
160
+ { id: "next_race_round", name: "Next Race Round", type: "number", role: "value" },
161
+ { id: "next_race_circuit", name: "Next Race Circuit", type: "string", role: "text" },
162
+ { id: "next_race_country", name: "Next Race Country", type: "string", role: "text" },
163
+ { id: "next_race_date", name: "Next Race Date (UTC)", type: "string", role: "date" },
164
+ {
165
+ id: "next_race_countdown_days",
166
+ name: "Days until Race",
167
+ type: "number",
168
+ role: "value",
169
+ unit: "days",
170
+ },
171
+ { id: "next_session_name", name: "Next Session Name", type: "string", role: "text" },
172
+ { id: "next_session_type", name: "Next Session Type", type: "string", role: "text" },
173
+ { id: "next_session_date", name: "Next Session Date (UTC)", type: "string", role: "date" },
174
+ {
175
+ id: "next_session_countdown_hours",
176
+ name: "Hours until Session",
177
+ type: "number",
178
+ role: "value",
179
+ unit: "h",
180
+ },
181
+ { id: "weekend_json", name: "Current Weekend Sessions (JSON)", type: "string", role: "json" },
182
+ { id: "calendar", name: "Full Season Calendar (JSON)", type: "string", role: "json" },
183
+ ],
184
+ },
185
+ {
186
+ id: "standings",
187
+ name: "Championship Standings",
188
+ states: [
189
+ { id: "drivers", name: "Driver Standings (JSON)", type: "string", role: "json" },
190
+ { id: "teams", name: "Team Standings (JSON)", type: "string", role: "json" },
191
+ { id: "last_update", name: "Last Update", type: "string", role: "date" },
192
+ ],
193
+ },
194
+ {
195
+ id: "results",
196
+ name: "Session Results",
197
+ states: [
198
+ { id: "race", name: "Race Result (JSON)", type: "string", role: "json" },
199
+ { id: "qualifying", name: "Qualifying Result (JSON)", type: "string", role: "json" },
200
+ { id: "sprint", name: "Sprint Result (JSON)", type: "string", role: "json" },
201
+ { id: "fp1", name: "Practice 1 Result (JSON)", type: "string", role: "json" },
202
+ { id: "fp2", name: "Practice 2 Result (JSON)", type: "string", role: "json" },
203
+ { id: "fp3", name: "Practice 3 Result (JSON)", type: "string", role: "json" },
204
+ { id: "last_update", name: "Last Update", type: "string", role: "date" },
205
+ ],
206
+ },
207
+ {
208
+ id: "live",
209
+ name: "Live Session Data (F1 Live Timing)",
210
+ states: [
211
+ { id: "is_live", name: "Session Active", type: "boolean", role: "indicator" },
212
+ { id: "session_name", name: "Session Name", type: "string", role: "text" },
213
+ { id: "session_status", name: "Session Status", type: "string", role: "text" },
214
+ { id: "track_status", name: "Track Status", type: "string", role: "text" },
215
+ { id: "laps_current", name: "Current Lap", type: "number", role: "value" },
216
+ { id: "laps_total", name: "Total Laps", type: "number", role: "value" },
217
+ { id: "time_remaining", name: "Time Remaining", type: "string", role: "text" },
218
+ { id: "time_elapsed", name: "Time Elapsed", type: "string", role: "text" },
219
+ { id: "weather", name: "Track Weather (JSON)", type: "string", role: "json" },
220
+ { id: "race_control", name: "Race Control Messages (JSON)", type: "string", role: "json" },
221
+ { id: "top_three", name: "Top 3 Drivers (JSON)", type: "string", role: "json" },
222
+ { id: "drivers", name: "All Drivers with Position/Tyre (JSON)", type: "string", role: "json" },
223
+ { id: "tyres", name: "Current Tyres per Driver (JSON)", type: "string", role: "json" },
224
+ { id: "pit_stops", name: "Pit Stops (JSON)", type: "string", role: "json" },
225
+ { id: "team_radio", name: "Team Radio (JSON)", type: "string", role: "json" },
226
+ { id: "last_update", name: "Last Update", type: "string", role: "date" },
227
+ ],
228
+ },
229
+ ];
230
+ for (const channel of channels) {
231
+ await this.setObjectNotExistsAsync(channel.id, {
232
+ type: "channel",
233
+ common: { name: channel.name },
234
+ native: {},
235
+ });
236
+ for (const state of channel.states) {
237
+ await this.setObjectNotExistsAsync(`${channel.id}.${state.id}`, {
238
+ type: "state",
239
+ common: {
240
+ name: state.name,
241
+ type: state.type,
242
+ role: state.role,
243
+ read: true,
244
+ write: false,
245
+ ...(state.unit && { unit: state.unit }),
246
+ },
247
+ native: {},
248
+ });
249
+ }
250
+ }
251
+ }
252
+ // ── Jolpica / Ergast data ─────────────────────────────────────────────────
253
+ /**
254
+ * Fetch from Jolpica with automatic fallback to ergast.com.
255
+ * Returns null (instead of throwing) on 404 — endpoint not found on both hosts.
256
+ *
257
+ * @param path - API path, e.g. "/current/last/results.json"
258
+ */
259
+ async fetchErgast(path) {
260
+ // Helper to detect "not found" errors so we don't waste the fallback on them
261
+ const isNotFound = (e) => {
262
+ const status = e?.response?.status;
263
+ return status === 404 || status === 410;
264
+ };
265
+ try {
266
+ const res = await this.ergastApi.get(`${this.JOLPICA_BASE}${path}`);
267
+ return res.data;
268
+ }
269
+ catch (jolpicaErr) {
270
+ if (isNotFound(jolpicaErr)) {
271
+ this.log.debug(`Jolpica 404 for: ${path} — skipping fallback`);
272
+ return null;
273
+ }
274
+ // Network error / 5xx → try ergast.com
275
+ this.log.debug(`Jolpica unavailable, falling back to ergast.com for: ${path}`);
276
+ try {
277
+ const res = await this.ergastApi.get(`${this.ERGAST_BASE}${path}`);
278
+ return res.data;
279
+ }
280
+ catch (ergastErr) {
281
+ if (isNotFound(ergastErr)) {
282
+ this.log.debug(`Ergast 404 for: ${path}`);
283
+ return null;
284
+ }
285
+ throw ergastErr;
286
+ }
287
+ }
288
+ }
289
+ async refreshJolpicaData() {
290
+ try {
291
+ const races = await this.fetchSchedule();
292
+ const allSessions = races.flatMap(r => this.buildSessionsFromRace(r));
293
+ await this.updateScheduleStates(races, allSessions, new Date());
294
+ void this.updateStandings();
295
+ void this.updateLatestResults();
296
+ }
297
+ catch (error) {
298
+ const msg = error instanceof Error ? error.message : String(error);
299
+ this.log.warn(`Jolpica refresh failed: ${msg}`);
300
+ }
301
+ }
302
+ async fetchSchedule() {
303
+ try {
304
+ const data = await this.fetchErgast("/current.json");
305
+ return data?.MRData?.RaceTable?.Races ?? [];
306
+ }
307
+ catch (error) {
308
+ const msg = error instanceof Error ? error.message : String(error);
309
+ this.log.warn(`Schedule fetch failed: ${msg}`);
310
+ return [];
311
+ }
312
+ }
313
+ buildSessionsFromRace(race) {
314
+ const sessions = [];
315
+ const round = parseInt(race.round, 10);
316
+ const base = {
317
+ round,
318
+ raceName: race.raceName,
319
+ circuit: race.Circuit.circuitName,
320
+ country: race.Circuit.Location.country,
321
+ };
322
+ const add = (name, type, dt) => {
323
+ if (!dt) {
324
+ return;
325
+ }
326
+ const startUTC = new Date(`${dt.date}T${dt.time}`);
327
+ const durationMin = SESSION_DURATIONS[name] ?? 90;
328
+ const endUTC = new Date(startUTC.getTime() + durationMin * 60 * 1000);
329
+ sessions.push({
330
+ ...base,
331
+ name,
332
+ type,
333
+ startUTC: startUTC.toISOString(),
334
+ endUTC: endUTC.toISOString(),
335
+ });
336
+ };
337
+ add("Practice 1", "Practice", race.FirstPractice);
338
+ add("Practice 2", "Practice", race.SecondPractice);
339
+ add("Practice 3", "Practice", race.ThirdPractice);
340
+ add("Sprint Qualifying", "SprintQualifying", race.SprintQualifying);
341
+ add("Sprint", "Sprint", race.Sprint);
342
+ add("Qualifying", "Qualifying", race.Qualifying);
343
+ add("Race", "Race", { date: race.date, time: race.time });
344
+ return sessions;
345
+ }
346
+ async updateScheduleStates(races, allSessions, now) {
347
+ if (races.length === 0) {
348
+ return;
349
+ }
350
+ // Full season calendar
351
+ const calendar = races.map(r => ({
352
+ round: parseInt(r.round, 10),
353
+ race_name: r.raceName,
354
+ circuit: r.Circuit.circuitName,
355
+ country: r.Circuit.Location.country,
356
+ date: r.date,
357
+ time: r.time,
358
+ }));
359
+ await this.setStateAsync("schedule.calendar", { val: JSON.stringify(calendar, null, 2), ack: true });
360
+ // Next race (keep current race as "next" for 3h after start — same as f1_sensor)
361
+ const GRACE_MS = 3 * 60 * 60 * 1000;
362
+ const nextRace = races.find(r => new Date(`${r.date}T${r.time}`).getTime() + GRACE_MS > now.getTime());
363
+ if (nextRace) {
364
+ const raceDate = new Date(`${nextRace.date}T${nextRace.time}`);
365
+ const daysUntil = Math.max(0, Math.ceil((raceDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)));
366
+ await this.setStateAsync("schedule.next_race_name", { val: nextRace.raceName, ack: true });
367
+ await this.setStateAsync("schedule.next_race_round", {
368
+ val: parseInt(nextRace.round, 10),
369
+ ack: true,
370
+ });
371
+ await this.setStateAsync("schedule.next_race_circuit", {
372
+ val: nextRace.Circuit.circuitName,
373
+ ack: true,
374
+ });
375
+ await this.setStateAsync("schedule.next_race_country", {
376
+ val: nextRace.Circuit.Location.country,
377
+ ack: true,
378
+ });
379
+ await this.setStateAsync("schedule.next_race_date", { val: raceDate.toISOString(), ack: true });
380
+ await this.setStateAsync("schedule.next_race_countdown_days", { val: daysUntil, ack: true });
381
+ const weekendRound = parseInt(nextRace.round, 10);
382
+ const weekendSessions = allSessions.filter(s => s.round === weekendRound);
383
+ await this.setStateAsync("schedule.weekend_json", {
384
+ val: JSON.stringify(weekendSessions, null, 2),
385
+ ack: true,
386
+ });
387
+ }
388
+ // Next individual session
389
+ const nextSession = allSessions.find(s => new Date(s.startUTC) > now);
390
+ if (nextSession) {
391
+ const startDate = new Date(nextSession.startUTC);
392
+ const hoursUntil = Math.max(0, Math.ceil((startDate.getTime() - now.getTime()) / (1000 * 60 * 60)));
393
+ await this.setStateAsync("schedule.next_session_name", { val: nextSession.name, ack: true });
394
+ await this.setStateAsync("schedule.next_session_type", { val: nextSession.type, ack: true });
395
+ await this.setStateAsync("schedule.next_session_date", {
396
+ val: nextSession.startUTC,
397
+ ack: true,
398
+ });
399
+ await this.setStateAsync("schedule.next_session_countdown_hours", {
400
+ val: hoursUntil,
401
+ ack: true,
402
+ });
403
+ }
404
+ }
405
+ // ── Live Session Detection ─────────────────────────────────────────────────
406
+ detectLiveSession(sessions, now) {
407
+ const PRE_MS = 30 * 60 * 1000; // 30 min pre-buffer
408
+ const POST_MS = 10 * 60 * 1000; // 10 min post-buffer
409
+ for (const session of sessions) {
410
+ const start = new Date(new Date(session.startUTC).getTime() - PRE_MS);
411
+ const end = new Date(new Date(session.endUTC).getTime() + POST_MS);
412
+ if (now >= start && now <= end) {
413
+ return session;
414
+ }
415
+ }
416
+ return null;
417
+ }
418
+ async checkLiveStatus() {
419
+ try {
420
+ const races = await this.fetchSchedule();
421
+ const allSessions = races.flatMap(r => this.buildSessionsFromRace(r));
422
+ const now = new Date();
423
+ const prevSession = this.currentLiveSession;
424
+ this.currentLiveSession = this.detectLiveSession(allSessions, now);
425
+ if (this.currentLiveSession) {
426
+ await this.setStateAsync("live.is_live", { val: true, ack: true });
427
+ await this.setStateAsync("live.session_name", {
428
+ val: this.currentLiveSession.name,
429
+ ack: true,
430
+ });
431
+ // Connect SignalR if not already connected
432
+ if (!this.ws || this.ws.readyState === ws_1.default.CLOSED) {
433
+ void this.connectSignalR();
434
+ }
435
+ }
436
+ else {
437
+ await this.setStateAsync("live.is_live", { val: false, ack: true });
438
+ if (this.ws) {
439
+ this.disconnectSignalR();
440
+ }
441
+ // Session just ended → refresh results & standings
442
+ if (prevSession) {
443
+ const savedKey = `${prevSession.round}-${prevSession.type}`;
444
+ if (savedKey !== this.lastSavedSession) {
445
+ this.lastSavedSession = savedKey;
446
+ this.log.info(`Session ended: ${prevSession.name} (round ${prevSession.round}). Refreshing results...`);
447
+ void this.updateLatestResults();
448
+ if (prevSession.type === "Race") {
449
+ void this.updateStandings();
450
+ }
451
+ }
452
+ }
453
+ }
454
+ }
455
+ catch (error) {
456
+ const msg = error instanceof Error ? error.message : String(error);
457
+ this.log.warn(`Live check failed: ${msg}`);
458
+ }
459
+ }
460
+ // ── SignalR Connection (F1 Live Timing) ───────────────────────────────────
461
+ async connectSignalR() {
462
+ if (this.wsConnecting) {
463
+ return;
464
+ }
465
+ this.wsConnecting = true;
466
+ try {
467
+ // 1. Negotiate to get connection token + cookies
468
+ const negRes = await this.ltApi.get("/signalr/negotiate", {
469
+ params: {
470
+ clientProtocol: "1.5",
471
+ connectionData: '[{"name":"Streaming"}]',
472
+ },
473
+ });
474
+ const token = encodeURIComponent(negRes.data.ConnectionToken);
475
+ const cookies = (negRes.headers["set-cookie"] ?? []).join("; ");
476
+ // 2. Build WebSocket URL
477
+ const wsUrl = `wss://livetiming.formula1.com/signalr/connect` +
478
+ `?clientProtocol=1.5&transport=webSockets` +
479
+ `&connectionData=${encodeURIComponent('[{"name":"Streaming"}]')}` +
480
+ `&connectionToken=${token}`;
481
+ // 3. Connect
482
+ this.ws = new ws_1.default(wsUrl, { headers: { Cookie: cookies } });
483
+ this.ws.on("open", () => {
484
+ this.log.info("F1 Live Timing: WebSocket connected");
485
+ const subscribeMsg = JSON.stringify({
486
+ H: "Streaming",
487
+ M: "Subscribe",
488
+ A: [SUBSCRIBE_STREAMS],
489
+ I: 1,
490
+ });
491
+ this.ws.send(subscribeMsg);
492
+ });
493
+ this.ws.on("message", (raw) => {
494
+ let str;
495
+ if (Buffer.isBuffer(raw)) {
496
+ str = raw.toString("utf8");
497
+ }
498
+ else if (Array.isArray(raw)) {
499
+ str = Buffer.concat(raw).toString("utf8");
500
+ }
501
+ else {
502
+ str = Buffer.from(raw).toString("utf8");
503
+ }
504
+ void this.handleWsMessage(str);
505
+ });
506
+ this.ws.on("close", () => {
507
+ this.log.info("F1 Live Timing: WebSocket disconnected");
508
+ this.ws = null;
509
+ // Reconnect after 5s if still in live window
510
+ if (this.currentLiveSession) {
511
+ this.reconnectTimeout = setTimeout(() => void this.connectSignalR(), 5000);
512
+ }
513
+ });
514
+ this.ws.on("error", (err) => {
515
+ this.log.warn(`F1 Live Timing WebSocket error: ${err.message}`);
516
+ });
517
+ }
518
+ catch (error) {
519
+ const msg = error instanceof Error ? error.message : String(error);
520
+ this.log.warn(`F1 Live Timing connect failed: ${msg}`);
521
+ if (this.currentLiveSession) {
522
+ this.reconnectTimeout = setTimeout(() => void this.connectSignalR(), 15000);
523
+ }
524
+ }
525
+ finally {
526
+ this.wsConnecting = false;
527
+ }
528
+ }
529
+ disconnectSignalR() {
530
+ if (this.reconnectTimeout) {
531
+ clearTimeout(this.reconnectTimeout);
532
+ this.reconnectTimeout = undefined;
533
+ }
534
+ if (this.ws) {
535
+ this.ws.removeAllListeners();
536
+ this.ws.close();
537
+ this.ws = null;
538
+ }
539
+ // Reset in-memory caches
540
+ this.driverList = {};
541
+ this.timingData = {};
542
+ this.timingAppData = {};
543
+ this.rcMessages = [];
544
+ }
545
+ // ── SignalR Message Processing ─────────────────────────────────────────────
546
+ async handleWsMessage(raw) {
547
+ let payload;
548
+ try {
549
+ payload = JSON.parse(raw);
550
+ }
551
+ catch {
552
+ return;
553
+ }
554
+ for (const msg of payload?.M ?? []) {
555
+ if (msg.M !== "feed" || !Array.isArray(msg.A) || msg.A.length < 2) {
556
+ continue;
557
+ }
558
+ const [stream, data] = msg.A;
559
+ await this.handleStreamData(stream, data);
560
+ }
561
+ }
562
+ async handleStreamData(stream, data) {
563
+ try {
564
+ switch (stream) {
565
+ case "TrackStatus":
566
+ await this.onTrackStatus(data);
567
+ break;
568
+ case "SessionStatus":
569
+ await this.onSessionStatus(data);
570
+ break;
571
+ case "SessionInfo":
572
+ await this.onSessionInfo(data);
573
+ break;
574
+ case "WeatherData":
575
+ await this.onWeatherData(data);
576
+ break;
577
+ case "LapCount":
578
+ await this.onLapCount(data);
579
+ break;
580
+ case "ExtrapolatedClock":
581
+ await this.onExtrapolatedClock(data);
582
+ break;
583
+ case "DriverList":
584
+ this.driverList = this.deepMerge(this.driverList, data);
585
+ await this.publishDrivers();
586
+ break;
587
+ case "TimingData":
588
+ if (data?.Lines) {
589
+ this.timingData = this.deepMerge(this.timingData, data.Lines);
590
+ await this.publishDrivers();
591
+ }
592
+ break;
593
+ case "TimingAppData":
594
+ if (data?.Lines) {
595
+ this.timingAppData = this.deepMerge(this.timingAppData, data.Lines);
596
+ await this.publishDrivers();
597
+ }
598
+ break;
599
+ case "RaceControlMessages":
600
+ await this.onRaceControl(data);
601
+ break;
602
+ case "TopThree":
603
+ await this.onTopThree(data);
604
+ break;
605
+ case "TeamRadio":
606
+ await this.onTeamRadio(data);
607
+ break;
608
+ case "PitStopSeries":
609
+ await this.onPitStops(data);
610
+ break;
611
+ case "TyreStintSeries":
612
+ await this.onTyreStints(data);
613
+ break;
614
+ }
615
+ await this.setStateAsync("live.last_update", { val: new Date().toISOString(), ack: true });
616
+ }
617
+ catch (error) {
618
+ const msg = error instanceof Error ? error.message : String(error);
619
+ this.log.debug(`Stream ${stream} error: ${msg}`);
620
+ }
621
+ }
622
+ async onTrackStatus(data) {
623
+ const statusCode = String(data?.Status ?? "");
624
+ const mapped = TRACK_STATUS_MAP[statusCode] ?? data?.Message ?? statusCode;
625
+ await this.setStateAsync("live.track_status", { val: mapped, ack: true });
626
+ }
627
+ async onSessionStatus(data) {
628
+ const status = String(data?.Status ?? data ?? "");
629
+ await this.setStateAsync("live.session_status", { val: status, ack: true });
630
+ }
631
+ async onSessionInfo(data) {
632
+ const name = String(data?.Name ?? data?.Type ?? "");
633
+ if (name) {
634
+ await this.setStateAsync("live.session_name", { val: name, ack: true });
635
+ }
636
+ }
637
+ async onWeatherData(data) {
638
+ const weather = {
639
+ air_temperature: parseFloat(data?.AirTemp ?? 0),
640
+ track_temperature: parseFloat(data?.TrackTemp ?? 0),
641
+ humidity: parseFloat(data?.Humidity ?? 0),
642
+ pressure: parseFloat(data?.Pressure ?? 0),
643
+ rainfall: parseFloat(data?.Rainfall ?? 0),
644
+ wind_speed: parseFloat(data?.WindSpeed ?? 0),
645
+ wind_direction: parseInt(String(data?.WindDirection ?? 0), 10),
646
+ };
647
+ await this.setStateAsync("live.weather", { val: JSON.stringify(weather, null, 2), ack: true });
648
+ }
649
+ async onLapCount(data) {
650
+ if (data?.CurrentLap != null) {
651
+ await this.setStateAsync("live.laps_current", {
652
+ val: parseInt(String(data.CurrentLap), 10),
653
+ ack: true,
654
+ });
655
+ }
656
+ if (data?.TotalLaps != null) {
657
+ await this.setStateAsync("live.laps_total", {
658
+ val: parseInt(String(data.TotalLaps), 10),
659
+ ack: true,
660
+ });
661
+ }
662
+ }
663
+ async onExtrapolatedClock(data) {
664
+ if (data?.Remaining != null) {
665
+ await this.setStateAsync("live.time_remaining", {
666
+ val: String(data.Remaining),
667
+ ack: true,
668
+ });
669
+ }
670
+ if (data?.Elapsed != null) {
671
+ await this.setStateAsync("live.time_elapsed", { val: String(data.Elapsed), ack: true });
672
+ }
673
+ }
674
+ async onRaceControl(data) {
675
+ // Messages can come as object {"0": {...}, "1": {...}} or array
676
+ const incoming = data?.Messages ? Object.values(data.Messages) : Array.isArray(data) ? data : [];
677
+ if (incoming.length === 0) {
678
+ return;
679
+ }
680
+ this.rcMessages.push(...incoming);
681
+ if (this.rcMessages.length > 50) {
682
+ this.rcMessages = this.rcMessages.slice(-50);
683
+ }
684
+ await this.setStateAsync("live.race_control", {
685
+ val: JSON.stringify(this.rcMessages.slice(-20), null, 2),
686
+ ack: true,
687
+ });
688
+ }
689
+ async onTopThree(data) {
690
+ const lines = Array.isArray(data?.Lines) ? data.Lines : [];
691
+ if (lines.length === 0) {
692
+ return;
693
+ }
694
+ const top3 = lines.slice(0, 3).map((l) => ({
695
+ position: parseInt(String(l.Position ?? 0), 10),
696
+ racing_number: String(l.RacingNumber ?? ""),
697
+ full_name: String(l.FullName ?? ""),
698
+ name_acronym: String(l.Tla ?? ""),
699
+ team: String(l.Team ?? ""),
700
+ }));
701
+ await this.setStateAsync("live.top_three", { val: JSON.stringify(top3, null, 2), ack: true });
702
+ }
703
+ async onTeamRadio(data) {
704
+ const captures = data?.Captures ? Object.values(data.Captures) : Array.isArray(data) ? data : [];
705
+ if (captures.length === 0) {
706
+ return;
707
+ }
708
+ await this.setStateAsync("live.team_radio", {
709
+ val: JSON.stringify(captures.slice(-10), null, 2),
710
+ ack: true,
711
+ });
712
+ }
713
+ async onPitStops(data) {
714
+ const stops = [];
715
+ for (const [num, pits] of Object.entries(data ?? {})) {
716
+ if (Array.isArray(pits)) {
717
+ for (const p of pits) {
718
+ stops.push({ racing_number: num, ...p });
719
+ }
720
+ }
721
+ }
722
+ if (stops.length > 0) {
723
+ await this.setStateAsync("live.pit_stops", {
724
+ val: JSON.stringify(stops, null, 2),
725
+ ack: true,
726
+ });
727
+ }
728
+ }
729
+ async onTyreStints(data) {
730
+ const tyres = [];
731
+ for (const [num, stints] of Object.entries(data ?? {})) {
732
+ if (Array.isArray(stints) && stints.length > 0) {
733
+ const current = stints[stints.length - 1];
734
+ tyres.push({
735
+ racing_number: num,
736
+ compound: current.Compound ?? "",
737
+ total_laps: current.TotalLaps ?? 0,
738
+ is_new: current.New ?? false,
739
+ });
740
+ }
741
+ }
742
+ if (tyres.length > 0) {
743
+ await this.setStateAsync("live.tyres", { val: JSON.stringify(tyres, null, 2), ack: true });
744
+ }
745
+ }
746
+ /**
747
+ * Merge DriverList + TimingData + TimingAppData into one `live.drivers` state.
748
+ * This mirrors what f1_sensor does with its LiveDriversCoordinator.
749
+ */
750
+ async publishDrivers() {
751
+ const drivers = [];
752
+ for (const [num, info] of Object.entries(this.driverList)) {
753
+ if (!info || typeof info !== "object") {
754
+ continue;
755
+ }
756
+ const timing = this.timingData[num] ?? {};
757
+ const appData = this.timingAppData[num] ?? {};
758
+ const stints = appData?.Stints ? Object.values(appData.Stints) : [];
759
+ const currentStint = stints.length > 0 ? stints[stints.length - 1] : null;
760
+ const position = parseInt(String(timing?.Position ?? 0), 10) || null;
761
+ drivers.push({
762
+ racing_number: info.RacingNumber ?? num,
763
+ full_name: info.FullName ?? "",
764
+ name_acronym: info.Tla ?? "",
765
+ team_name: info.TeamName ?? "",
766
+ team_colour: info.TeamColour ?? "",
767
+ position,
768
+ gap_to_leader: timing?.GapToLeader ?? null,
769
+ interval: timing?.IntervalToPositionAhead?.Value ?? null,
770
+ last_lap_time: timing?.LastLapTime?.Value ?? null,
771
+ tyre_compound: currentStint?.Compound ?? null,
772
+ tyre_laps: currentStint?.TotalLaps ?? null,
773
+ tyre_new: currentStint?.New ?? null,
774
+ });
775
+ }
776
+ if (drivers.length === 0) {
777
+ return;
778
+ }
779
+ drivers.sort((a, b) => (a.position ?? 99) - (b.position ?? 99));
780
+ await this.setStateAsync("live.drivers", {
781
+ val: JSON.stringify(drivers, null, 2),
782
+ ack: true,
783
+ });
784
+ }
785
+ // ── Standings ─────────────────────────────────────────────────────────────
786
+ async updateStandings() {
787
+ const delays = [10000, 30000, 90000];
788
+ for (let attempt = 0; attempt < 3; attempt++) {
789
+ try {
790
+ const [driverRes, constructorRes] = await Promise.all([
791
+ this.fetchErgast("/current/driverstandings.json?limit=100"),
792
+ this.fetchErgast("/current/constructorstandings.json?limit=100"),
793
+ ]);
794
+ const driverStandings = driverRes?.MRData?.StandingsTable?.StandingsLists?.[0]?.DriverStandings ?? [];
795
+ const constructorStandings = constructorRes?.MRData?.StandingsTable?.StandingsLists?.[0]?.ConstructorStandings ?? [];
796
+ if (driverStandings.length > 0) {
797
+ const drivers = driverStandings.map((s) => ({
798
+ position: parseInt(String(s.position), 10),
799
+ driver_number: parseInt(String(s.Driver.permanentNumber), 10),
800
+ full_name: `${s.Driver.givenName} ${s.Driver.familyName}`,
801
+ name_acronym: s.Driver.code ?? "",
802
+ team_name: s.Constructors?.[0]?.name ?? "",
803
+ team_colour: this.getTeamColour(s.Constructors?.[0]?.constructorId ?? ""),
804
+ points: parseFloat(String(s.points)),
805
+ wins: parseInt(String(s.wins), 10),
806
+ }));
807
+ await this.setStateAsync("standings.drivers", {
808
+ val: JSON.stringify(drivers, null, 2),
809
+ ack: true,
810
+ });
811
+ }
812
+ if (constructorStandings.length > 0) {
813
+ const teams = constructorStandings.map((s) => ({
814
+ position: parseInt(String(s.position), 10),
815
+ team_name: s.Constructor.name,
816
+ team_colour: this.getTeamColour(s.Constructor.constructorId),
817
+ points: parseFloat(String(s.points)),
818
+ wins: parseInt(String(s.wins), 10),
819
+ }));
820
+ await this.setStateAsync("standings.teams", {
821
+ val: JSON.stringify(teams, null, 2),
822
+ ack: true,
823
+ });
824
+ }
825
+ await this.setStateAsync("standings.last_update", {
826
+ val: new Date().toISOString(),
827
+ ack: true,
828
+ });
829
+ this.log.debug("Standings updated");
830
+ return;
831
+ }
832
+ catch (error) {
833
+ const msg = error instanceof Error ? error.message : String(error);
834
+ if (attempt < 2) {
835
+ this.log.warn(`Standings fetch failed (attempt ${attempt + 1}/3): ${msg}. Retrying in ${delays[attempt] / 1000}s...`);
836
+ await new Promise(resolve => setTimeout(resolve, delays[attempt]));
837
+ }
838
+ else {
839
+ this.log.error(`Failed to update standings after 3 attempts: ${msg}`);
840
+ }
841
+ }
842
+ }
843
+ }
844
+ // ── Results ───────────────────────────────────────────────────────────────
845
+ async updateLatestResults() {
846
+ const wrap = async (label, fn) => {
847
+ try {
848
+ await fn();
849
+ }
850
+ catch (e) {
851
+ this.log.warn(`Results [${label}] failed: ${e instanceof Error ? e.message : String(e)}`);
852
+ }
853
+ };
854
+ let round = null;
855
+ await wrap("race", () => this.updateRaceResults());
856
+ await wrap("qualifying", async () => {
857
+ round = await this.updateQualifyingResults();
858
+ });
859
+ await wrap("sprint", () => this.updateSprintResults());
860
+ if (round != null) {
861
+ await Promise.allSettled([
862
+ wrap("fp1", () => this.updatePracticeResults(round, "fp1", 1)),
863
+ wrap("fp2", () => this.updatePracticeResults(round, "fp2", 2)),
864
+ wrap("fp3", () => this.updatePracticeResults(round, "fp3", 3)),
865
+ ]);
866
+ }
867
+ await this.setStateAsync("results.last_update", { val: new Date().toISOString(), ack: true });
868
+ this.log.info("Results updated");
869
+ }
870
+ async updateRaceResults() {
871
+ const data = await this.fetchErgast("/current/last/results.json?limit=100");
872
+ const race = data?.MRData?.RaceTable?.Races?.[0];
873
+ if (!race) {
874
+ this.log.debug("No race results from Ergast");
875
+ return;
876
+ }
877
+ const results = race.Results.map(r => ({
878
+ position: parseInt(r.positionText, 10) || 0,
879
+ driver_number: parseInt(r.number, 10),
880
+ name_acronym: r.Driver.code ?? "",
881
+ full_name: `${r.Driver.givenName} ${r.Driver.familyName}`,
882
+ team_name: r.Constructor.name,
883
+ team_colour: this.getTeamColour(r.Constructor.constructorId),
884
+ best_lap_time: this.parseLapTimeToSeconds(r.FastestLap?.Time?.time),
885
+ lap_count: parseInt(r.laps, 10),
886
+ status: r.status,
887
+ race_name: race.raceName ?? "",
888
+ round: parseInt(race.round, 10),
889
+ }));
890
+ await this.setStateAsync("results.race", { val: JSON.stringify(results, null, 2), ack: true });
891
+ }
892
+ async updateQualifyingResults() {
893
+ const data = await this.fetchErgast("/current/last/qualifying.json?limit=100");
894
+ const race = data?.MRData?.RaceTable?.Races?.[0];
895
+ if (!race) {
896
+ this.log.debug("No qualifying results from Ergast");
897
+ return null;
898
+ }
899
+ const round = parseInt(race.round, 10);
900
+ const results = race.QualifyingResults.map(r => ({
901
+ position: parseInt(r.position, 10),
902
+ driver_number: parseInt(r.number, 10),
903
+ name_acronym: r.Driver.code ?? "",
904
+ full_name: `${r.Driver.givenName} ${r.Driver.familyName}`,
905
+ team_name: r.Constructor.name,
906
+ team_colour: this.getTeamColour(r.Constructor.constructorId),
907
+ best_lap_time: this.parseLapTimeToSeconds(r.Q3 ?? r.Q2 ?? r.Q1),
908
+ lap_count: 0,
909
+ q1: r.Q1,
910
+ q2: r.Q2,
911
+ q3: r.Q3,
912
+ race_name: race.raceName ?? "",
913
+ round,
914
+ }));
915
+ await this.setStateAsync("results.qualifying", {
916
+ val: JSON.stringify(results, null, 2),
917
+ ack: true,
918
+ });
919
+ return round;
920
+ }
921
+ async updateSprintResults() {
922
+ const data = await this.fetchErgast("/current/sprint.json?limit=100");
923
+ const races = data?.MRData?.RaceTable?.Races ?? [];
924
+ const race = races[races.length - 1];
925
+ if (!race) {
926
+ this.log.debug("No sprint results from Ergast");
927
+ return;
928
+ }
929
+ const results = race.SprintResults.map(r => ({
930
+ position: parseInt(r.positionText, 10) || 0,
931
+ driver_number: parseInt(r.number, 10),
932
+ name_acronym: r.Driver.code ?? "",
933
+ full_name: `${r.Driver.givenName} ${r.Driver.familyName}`,
934
+ team_name: r.Constructor.name,
935
+ team_colour: this.getTeamColour(r.Constructor.constructorId),
936
+ best_lap_time: null,
937
+ lap_count: parseInt(r.laps, 10),
938
+ status: r.status,
939
+ race_name: race.raceName ?? "",
940
+ round: parseInt(race.round, 10),
941
+ }));
942
+ await this.setStateAsync("results.sprint", { val: JSON.stringify(results, null, 2), ack: true });
943
+ }
944
+ async updatePracticeResults(round, stateId, num) {
945
+ const data = await this.fetchErgast(`/current/${round}/practice/${num}.json`);
946
+ const race = data?.MRData?.RaceTable?.Races?.[0];
947
+ if (!race?.PracticeResults?.length) {
948
+ this.log.debug(`No Practice ${num} results for round ${round}`);
949
+ return;
950
+ }
951
+ const results = race.PracticeResults.map(r => ({
952
+ position: parseInt(r.position, 10),
953
+ driver_number: parseInt(r.number, 10),
954
+ name_acronym: r.Driver.code ?? "",
955
+ full_name: `${r.Driver.givenName} ${r.Driver.familyName}`,
956
+ team_name: r.Constructor.name,
957
+ team_colour: this.getTeamColour(r.Constructor.constructorId),
958
+ best_lap_time: this.parseLapTimeToSeconds(r.time),
959
+ lap_count: parseInt(r.laps, 10),
960
+ race_name: race.raceName ?? "",
961
+ round,
962
+ }));
963
+ await this.setStateAsync(`results.${stateId}`, {
964
+ val: JSON.stringify(results, null, 2),
965
+ ack: true,
966
+ });
967
+ }
968
+ // ── Helpers ───────────────────────────────────────────────────────────────
969
+ /**
970
+ * Deep merge two plain objects (for SignalR incremental updates)
971
+ *
972
+ * @param target
973
+ * @param source
974
+ */
975
+ deepMerge(target, source) {
976
+ const result = { ...target };
977
+ for (const [key, val] of Object.entries(source)) {
978
+ if (val !== null &&
979
+ typeof val === "object" &&
980
+ !Array.isArray(val) &&
981
+ result[key] !== null &&
982
+ typeof result[key] === "object" &&
983
+ !Array.isArray(result[key])) {
984
+ result[key] = this.deepMerge(result[key], val);
985
+ }
986
+ else {
987
+ result[key] = val;
988
+ }
989
+ }
990
+ return result;
991
+ }
992
+ parseLapTimeToSeconds(timeStr) {
993
+ if (!timeStr) {
994
+ return null;
995
+ }
996
+ const parts = timeStr.split(":");
997
+ if (parts.length === 2) {
998
+ return parseInt(parts[0], 10) * 60 + parseFloat(parts[1]);
999
+ }
1000
+ const val = parseFloat(timeStr);
1001
+ return isNaN(val) ? null : val;
1002
+ }
1003
+ getTeamColour(constructorId) {
1004
+ const colours = {
1005
+ mercedes: "00D2BE",
1006
+ ferrari: "E8002D",
1007
+ red_bull: "3671C6",
1008
+ mclaren: "FF8000",
1009
+ alpine: "0093CC",
1010
+ aston_martin: "229971",
1011
+ haas: "B6BABD",
1012
+ alphatauri: "6692FF",
1013
+ rb: "6692FF",
1014
+ williams: "64C4FF",
1015
+ sauber: "52E252",
1016
+ kick_sauber: "52E252",
1017
+ audi: "52E252",
1018
+ };
1019
+ return colours[constructorId] ?? "FFFFFF";
1020
+ }
1021
+ }
1022
+ if (require.main !== module) {
1023
+ module.exports = (options) => new F1(options);
1024
+ }
1025
+ else {
1026
+ (() => new F1())();
1027
+ }
1028
+ //# sourceMappingURL=main.js.map