Skip to content

Commit

Permalink
WIP on supporting RMonitor protocol.
Browse files Browse the repository at this point in the history
In particular, Race Monitor, but also eventually for the IMSA
replay files.
  • Loading branch information
jamesremuscat committed May 14, 2024
1 parent 6284f7e commit 625123b
Show file tree
Hide file tree
Showing 6 changed files with 286 additions and 1 deletion.
2 changes: 2 additions & 0 deletions src/dates.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
import duration from 'dayjs/plugin/duration.js';
import toObject from 'dayjs/plugin/toObject.js';

dayjs.extend(customParseFormat);
dayjs.extend(duration);
dayjs.extend(toObject);

export default dayjs;
3 changes: 2 additions & 1 deletion src/services/index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { RaceMonitor } from './rmonitor/index.js';
import { T71 } from './t71.js';

export * from './events.js';
export * from './services.js';
export * from './watchdog.js';

export const SERVICE_PROVIDERS = [T71];
export const SERVICE_PROVIDERS = [RaceMonitor, T71];

/**
* Call with a {@link Service} class (not an instance) to add that service to the
Expand Down
147 changes: 147 additions & 0 deletions src/services/rmonitor/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { EventEmitter } from '../../eventEmitter.js';

export class RMonitorClient extends EventEmitter {
constructor() {
super();

this.clear = this.clear.bind(this);
this.handleMessages = this.handleMessages.bind(this);
this.handleMessage = this.handleMessage.bind(this);

this.clear();
}

clear() {
this.state = {
classes: {},
competitors: {},
session: {},
settings: {}
};
}

handlers = {
// Competitor information 1
'A': (regNumber, number, transponder, firstName, lastName, nationality, classNumber) => {
this.state.competitors[regNumber] = {
...this.state.competitors[regNumber] || {},
regNumber,
number,
transponder,
firstName,
lastName,
nationality,
classNumber
};
},
// Competitor information 2
'COMP': (regNumber, number, classNumber, firstName, lastName, nationality, additional) => {
this.state.competitors[regNumber] = {
...this.state.competitors[regNumber] || {},
number,
firstName,
lastName,
nationality,
classNumber,
additional
};
},
// Run/session information
'B': (number, description) => {
this.state.session = {
...this.state.session || {},
number,
description
};
},
// Class information
'C': (id, description) => {
this.state.classes[id] = description;
},
// Track settings
'E': (name, value) => {
this.state.settings[name] = value;
},
// Heartbeat
'F': (lapsRemain, timeRemain, timestamp, timeElapsed, flagState) => {
this.state.session = {
...this.state.session || {},
flagState,
lapsRemain,
timeElapsed,
timeRemain,
timestamp
};
},
// Race information
'G': (position, regNumber, laps, totalTime) => {
this.state.competitors[regNumber] = {
...this.state.competitors[regNumber] || {},
position,
laps,
totalTime
};
},
// Practice/Qualifying information
'H': (bestPosition, regNumber, bestLapNum, bestLapTime) => {
this.state.competitors[regNumber] = {
...this.state.competitors[regNumber] || {},
bestPosition,
bestLapNum,
bestLapTime
};
},
// Clear
'I': () => {
this.clear();
},
// Start/finish crossing information
'J': (regNumber, laptime, totalTime) => {
this.state.competitors[regNumber] = {
...this.state.competitors[regNumber] || {},
lastLapTime: laptime,
totalTime
};
},
// Race Monitor: s/f crossing
'RMHL': (regNumber, laps, position, lastLapTime, lastFlag, totalTime) => {
this.state.competitors[regNumber] = {
...this.state.competitors[regNumber] || {},
laps,
lastFlag,
lastLapTime,
position,
totalTime
};
},
// Race Monitor: transponder last seen
'RMLT': (regNumber, timestamp) => {
this.state.competitors[regNumber] = {
...this.state.competitors[regNumber] || {},
lastSeen: timestamp
};
}
};

handleMessages(messages) {
messages.split('\r\n').forEach(
this.handleMessage
);
return this.state;
}

handleMessage(message) {
if (message.startsWith('$')) {
const [msgType, ...args] = message.trim().slice(1).split(',');

if (this.handlers[msgType]) {
this.handlers[msgType](...args.map(a => a.replaceAll('"', '').trim()));
this.emit('update', { ...this.state });
}
else {
console.log('Unknown: ', msgType, args);
}
return this.state;
}
}
}
1 change: 1 addition & 0 deletions src/services/rmonitor/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { RaceMonitor } from './service.js';
69 changes: 69 additions & 0 deletions src/services/rmonitor/service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Service } from '../services.js';
import { RMonitorClient } from './client.js';
import { getManifest, getState } from './translate.js';

export class RaceMonitor extends Service {
start(connectionService) {
const { groups: { raceID } } = this.service.source.match(RaceMonitor.regex);

connectionService.fetch(`https://api.race-monitor.com/Info/WebRaceList?raceID=${raceID}`).then(
(wrlText) => {
const wrl = JSON.parse(wrlText);

const token = wrl.LiveTimingToken;
const host = wrl.LiveTimingHost;
const currentRace = wrl.CurrentRaces.find(r => r.ReceivingData);

if (!currentRace) {
this.emitError('Could not find an active session');
}
else {
const wsUrl = `wss://${host}/instance/${currentRace.Instance}/${token}`;

this.socket = connectionService.createWebsocket(wsUrl);

const client = new RMonitorClient();

this.socket.on('connect', () => {
this.emitInfo('Connected to upstream timing source');
this.socket.send(`$JOIN,${currentRace.Instance},${token}`);
});

this.socket.on('message', (msg) => {
const data = msg.data || msg.toString;
client.handleMessages(data);
});

client.on('update', (state) => {
this._pendingState = state;
});

setInterval(
() => {
if (this._pendingState) {
console.log(this._pendingState);

const manifest = getManifest(this._pendingState);
this.onManifestChange(manifest);

const state = getState(this._pendingState);
console.log(state);
this.onStateChange(state);

this._pendingState = null;
}
},
1000
);
}
}
);
}

stop() {
this.socket?.close();
this._updateInterval && clearInterval(this._updateInterval);
}
}

RaceMonitor.regex = /http(s?):\/\/(www\.)?race-monitor.com\/Live\/Race\/(?<raceID>[0-9]+)/;
65 changes: 65 additions & 0 deletions src/services/rmonitor/translate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import dayjs from '../../dates.js';
import { Stat } from '../../racing.js';

export const getManifest = (state) => {
const descParts = (state.session.description || '').split(' - ');

const name = descParts.length === 1 ? 'RMonitor' : descParts[0];
const description = descParts.length === 1 ? descParts : descParts[1];

return {
name,
description,
colSpec: [
Stat.NUM,
Stat.DRIVER,
Stat.LAPS,
Stat.LAST_LAP,
Stat.BEST_LAP
]
};
};

export const getState = (state) => {
return {
cars: mapCars(Object.values(state.competitors)),
session: {}
};
};

const mapCars = (competitors) => {
const sortedCompetitors = competitors.sort(
(a, b) => {
const positionDiff = parseInt(a.position || 0, 10) - parseInt(b.position || 0, 10);
if (positionDiff === 0) {
return (b.lastSeen || 0) - (a.lastSeen || 0);
}
return positionDiff;
}
);

return sortedCompetitors.map(
car => {
const lastLap = parseTime(car.lastLapTime);
const bestLap = parseTime(car.bestLapTime);
return [
car.number,
`${car.lastName.toUpperCase()}, ${car.firstName}`,
car.laps,
lastLap > 0 ? lastLap : null,
bestLap > 0 ? bestLap : null,
car.regNumber,
car.position
];
}
);
};

const parseTime = (time) => {
if (!time) {
return null;
}
const parsed = dayjs(time, 'HH:mm:ss.SSS').toObject();
const duration = dayjs.duration({ hours: parsed.hours, minutes: parsed.minutes, seconds: parsed.seconds, milliseconds: parsed.milliseconds });
return duration.asSeconds();
};

0 comments on commit 625123b

Please sign in to comment.