├── images
└── shifting_leds.png
├── LICENSE
├── README.md
└── ac_shifting_leds.js
/images/shifting_leds.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d4rk/ac_shifting_leds/HEAD/images/shifting_leds.png
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Anoop
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Logitech G29 Shifter LEDs for Assetto Corsa & Dirt 4
2 |
3 | A **Linux** utility for [Assetto Corsa](https://www.protondb.com/app/244210) and [Dirt 4](https://www.feralinteractive.com/en/games/dirt4/) that lights up the shifting LEDs on the Logitech G29 wheel based on the car's engine RPM.
4 |
5 | 
6 |
7 | ## Requirements
8 | - NodeJS
9 | - Ubuntu: `sudo apt install nodejs`
10 | - [node-hid](https://github.com/node-hid/node-hid)
11 | - `npm install node-hid`
12 |
13 | ## Usage
14 |
15 | 1. Download [ac_shifting_leds.js](https://github.com/d4rk/ac_shifting_leds/raw/main/ac_shifting_leds.js).
16 |
17 | 2. In terminal, launch `ac_shifting_leds.js`:
18 | ```
19 | #:~/Downloads$ node ac_shifting_leds.js
20 | ```
21 |
22 | 3. Then launch your game of choice and start playing.
23 |
24 | If you switch back to the terminal, you should see some success messages:
25 | ```
26 | Connecting to Dirt / Codemasters
27 | Connecting to Assetto Corsa
28 | Assetto Corsa: subscribing to updates
29 | {
30 | carName: 'bmw_1m',
31 | driverName: 'Player',
32 | identifier: 4242,
33 | version: 1,
34 | trackName: 'drift',
35 | trackConfig: 'drift'
36 | }
37 | Peak RPM set to 7000
38 | Connected to Logitech G29 wheel
39 | Receiving data. First message:
40 | {
41 | "identifier": 97,
42 | "size": 328,
43 | ```
44 |
45 | ## Potential Issues
46 |
47 | - You may get HID permission errors if the G29 isn't accessible to your user account. If that's the case,
48 | then you may need to update your `udev` rules. See the [instructions](https://github.com/berarma/oversteer#permissions)
49 | in the Oversteer docs for more details.
50 |
51 | - In Dirt 4, you will need to enable UDP telemetry data. Open the following file in your favorite text editor:
52 | ```
53 | ~/.local/share/feral-interactive/DiRT 4/VFS/User/AppData/Roaming/My Games/DiRT 4/hardwaresettings/hardware_settings_config.xml
54 | ```
55 | and change:
56 | ```
57 | `
58 | ```
59 | to
60 | ```
61 | `
62 | ```
63 |
64 | ## Feedback
65 |
66 | Feel free to file issues [here](https://github.com/d4rk/ac_shifting_leds/issues).
67 |
--------------------------------------------------------------------------------
/ac_shifting_leds.js:
--------------------------------------------------------------------------------
1 | const buffer = require('buffer');
2 | const hid = require('node-hid')
3 | const udp = require('dgram');
4 | const EventEmitter = require('events');
5 | const { abort, exit } = require('process');
6 |
7 | // A Linux utility for the Logitech G29 wheel that connects to a running
8 | // instance of Assetto Corsa and lights up the shifting LEDs on the wheel
9 | // based on the engine RPM.
10 |
11 | // Features:
12 | // - Once running, it will attempt to auto connect to AC if the connection is lost.
13 | // - When peak RPM is reached, it will flash the LEDs (can be disabled).
14 |
15 | // Requirements:
16 | // 1. NodeJS
17 | // 2. node-hid
18 | //
19 | // Usage:
20 | // 1. Run this utility as `node ac_leds.js`.
21 | // 2. Run Assetto Corsa.
22 |
23 | /**
24 | * A helper class that parses a buffer of bytes into primitive types, advancing
25 | * the "cursor", as primitives are extracted.
26 | */
27 | class BufferReader {
28 | constructor(buffer) {
29 | this.offset = 0;
30 | this.buffer = buffer;
31 | }
32 |
33 | stringUtf16(length) {
34 | var string = this.buffer.toString('utf16le', this.offset, this.offset + length);
35 | // AC strings end in a `%` symbol, so strip everything after that.
36 | string = string.replace(/\%.*$/g, '');
37 | this.offset += length;
38 | return string;
39 | };
40 |
41 | uint32() {
42 | var number = this.buffer.readUInt32LE(this.offset);
43 | this.offset += 4;
44 | return number;
45 | };
46 |
47 | uint8() {
48 | var number = this.buffer.readUInt8(this.offset);
49 | this.offset += 1;
50 | return number;
51 | };
52 |
53 | float() {
54 | var number = this.buffer.readFloatLE(this.offset);
55 | this.offset += 4;
56 | return number;
57 | };
58 |
59 | boolean() {
60 | var number = this.buffer.readUInt8(this.offset);
61 | this.offset += 1;
62 | return Boolean(number);
63 | };
64 |
65 | skip(skipLen) {
66 | this.offset += skipLen;
67 | }
68 | }
69 |
70 | const OPERATION_ID_HANDSHAKE = 0;
71 | const OPERATION_ID_SUBSCRIBE_UPDATE = 1;
72 | const OPERATION_ID_SUBSCRIBE_SPOT = 2;
73 | const OPERATION_ID_SUBSCRIBE_DISMISS = 3;
74 |
75 | // Official specs (which are a bit outdated):
76 | // Binary format also inferred from:
77 | // https://github.com/bradland/ac_telemetry/blob/master/lib/ac_telemetry/bin_formats/rt_car_info.rb
78 |
79 |
80 | /** Base class that implements a UDP client with some reconnection logic. */
81 | class UDPGameClient extends EventEmitter {
82 | udpClient;
83 | host;
84 | port;
85 | listenOnPort;
86 | reconnectTimer;
87 | lastMessageTimestamp;
88 |
89 | constructor(host, port, listenOnPort = false) {
90 | super();
91 | if (this.constructor === UDPGameClient) {
92 | throw new Error("Instantiate a subclass, not this class");
93 | }
94 | this.host = host;
95 | this.port = port;
96 | this.listenOnPort = listenOnPort;
97 | }
98 |
99 | connect() {
100 | this.disconnect();
101 | this.udpClient = udp.createSocket('udp4');
102 | var _this = this;
103 | this.udpClient.on('message', function (msg, info) {
104 | _this._processUDPMessage(msg, info);
105 | _this.lastMessageTimestamp = Date.now();
106 | });
107 | if (this.listenOnPort) {
108 | this.udpClient.bind({ port: this.port, address: this.host });
109 | }
110 | this._setupReconnectTimer();
111 | }
112 |
113 | disconnect() {
114 | if (this.udpClient == undefined) {
115 | return;
116 | }
117 | this._stopReconnectTimer();
118 | this.udpClient.close();
119 | this.udpClient = null;
120 | }
121 |
122 | sendUDPMessage(message, errorFunction = undefined) {
123 | if (this.udpClient != undefined) {
124 | this.udpClient.send(message, this.port, this.host, errorFunction);
125 | }
126 | }
127 |
128 | _processUDPMessage(msg, info) {
129 | throw new Error("Should be implemented by subclasses");
130 | }
131 |
132 | _stopReconnectTimer() {
133 | if (this.reconnectTimer != undefined) {
134 | clearInterval(this.reconnectTimer);
135 | this.reconnectTimer = null;
136 | }
137 | }
138 |
139 | _setupReconnectTimer() {
140 | this._stopReconnectTimer();
141 | var _this = this;
142 | this.reconnectTimer = setInterval(function () { _this._reconnectIfNeeded(); }, 2000);
143 | }
144 |
145 | _reconnectIfNeeded() {
146 | // If we haven't gotten a message in the past 2 seconds, attempt a reconnect.
147 | if (this.lastMessageTimestamp == undefined
148 | || Date.now() - this.lastMessageTimestamp > 2000) {
149 | this.disconnect();
150 | this.connect();
151 | }
152 | }
153 | }
154 |
155 | /**
156 | * A class that connects to a running instance of Assetto Corsa using UDP.
157 | * Based on the specs from:
158 | * https://docs.google.com/document/d/1KfkZiIluXZ6mMhLWfDX1qAGbvhGRC3ZUzjVIt5FQpp4/pub
159 | *
160 | * Class emits 3 events:
161 | * - 'connected' - when a connection is established to AC
162 | * - 'disconnected' - when the connection is lost or dropped intentionally
163 | * - 'carInfo' - when a message with telemetry info is received
164 | */
165 | class ACClient extends UDPGameClient {
166 | handshakeStage = 0;
167 |
168 | constructor(host = 'localhost', port = 9996) {
169 | super(host, port);
170 | console.log("Connecting to Assetto Corsa");
171 | }
172 |
173 | connect() {
174 | super.connect();
175 | this._sendHandshakeRequest(OPERATION_ID_HANDSHAKE);
176 | }
177 |
178 | disconnect() {
179 | this.sendUDPMessage(this._handshakeRequest(OPERATION_ID_SUBSCRIBE_DISMISS));
180 | super.disconnect();
181 | this.handshakeStage = 0;
182 | this.emit('disconnected');
183 | }
184 |
185 | // Protected methods.
186 |
187 | _processUDPMessage(msg, info) {
188 | if (this.handshakeStage == 0) {
189 | this.handshakeStage++;
190 | var handshakeResponse = this._parseHandshakeResponse(msg);
191 | console.log("Assetto Corsa: subscribing to updates");
192 | this._sendHandshakeRequest(OPERATION_ID_SUBSCRIBE_UPDATE);
193 | this.emit('connected', handshakeResponse);
194 | } else {
195 | var carInfo = this._parseRTCarInfo(msg);
196 | this.emit('carInfo', carInfo);
197 | }
198 | }
199 |
200 | // Private methods.
201 |
202 | _sendHandshakeRequest(operationId) {
203 | this.sendUDPMessage(this._handshakeRequest(operationId),
204 | function (error) {
205 | if (error) {
206 | this.disconnect();
207 | }
208 | });
209 | }
210 |
211 | _handshakeRequest(operationId) {
212 | var buffer = Buffer.alloc(4 * 3);
213 | buffer.writeUInt32LE(0);
214 | buffer.writeUInt32LE(0, 4);
215 | buffer.writeUInt32LE(operationId, 8);
216 | return buffer;
217 | }
218 |
219 | _parseHandshakeResponse(msg) {
220 | var reader = new BufferReader(Buffer.from(msg));
221 | return {
222 | carName: reader.stringUtf16(100),
223 | driverName: reader.stringUtf16(100),
224 | identifier: reader.uint32(),
225 | version: reader.uint32(),
226 | trackName: reader.stringUtf16(100),
227 | trackConfig: reader.stringUtf16(100),
228 | };
229 | }
230 |
231 | /**
232 | * Based on the (outdated) info from the official spec, and the corrected spec from:
233 | * https://github.com/bradland/ac_telemetry/blob/master/lib/ac_telemetry/bin_formats/rt_car_info.rb
234 | */
235 | _parseRTCarInfo(msg) {
236 | var reader = new BufferReader(Buffer.from(msg));
237 |
238 | return {
239 | identifier: reader.uint32(),
240 | size: reader.uint32(),
241 |
242 | speed_Kmh: reader.float(),
243 | speed_Mph: reader.float(),
244 | speed_Ms: reader.float(),
245 |
246 | isAbsEnabled: reader.uint8(),
247 | isAbsInAction: reader.uint8(),
248 | isTcInAction: reader.uint8(),
249 | isTcEnabled: reader.uint8(),
250 | isInPit: reader.uint8(),
251 | isEngineLimiterOn: reader.uint8(),
252 |
253 | unused: reader.skip(2),
254 |
255 | accG_vertical: reader.float(),
256 | accG_horizontal: reader.float(),
257 | accG_frontal: reader.float(),
258 |
259 | lapTime: reader.uint32(),
260 | lastLap: reader.uint32(),
261 | bestLap: reader.uint32(),
262 | lapCount: reader.uint32(),
263 |
264 | gas: reader.float(),
265 | brake: reader.float(),
266 | clutch: reader.float(),
267 | engineRPM: reader.float(),
268 | steer: reader.float(),
269 | gear: reader.uint32(),
270 | cgHeight: reader.float(),
271 |
272 | wheelAngularSpeed: {
273 | a: reader.float(),
274 | b: reader.float(),
275 | c: reader.float(),
276 | d: reader.float(),
277 | },
278 | slipAngle: {
279 | a: reader.float(),
280 | b: reader.float(),
281 | c: reader.float(),
282 | d: reader.float(),
283 | },
284 | slipAngle_ContactPatch: {
285 | a: reader.float(),
286 | b: reader.float(),
287 | c: reader.float(),
288 | d: reader.float(),
289 | },
290 | slipRatio: {
291 | a: reader.float(),
292 | b: reader.float(),
293 | c: reader.float(),
294 | d: reader.float(),
295 | },
296 | tyreSlip: {
297 | a: reader.float(),
298 | b: reader.float(),
299 | c: reader.float(),
300 | d: reader.float(),
301 | },
302 | ndSlip: {
303 | a: reader.float(),
304 | b: reader.float(),
305 | c: reader.float(),
306 | d: reader.float(),
307 | },
308 | load: {
309 | a: reader.float(),
310 | b: reader.float(),
311 | c: reader.float(),
312 | d: reader.float(),
313 | },
314 | Dy: {
315 | a: reader.float(),
316 | b: reader.float(),
317 | c: reader.float(),
318 | d: reader.float(),
319 | },
320 | Mz: {
321 | a: reader.float(),
322 | b: reader.float(),
323 | c: reader.float(),
324 | d: reader.float(),
325 | },
326 | tyreDirtyLevel: {
327 | a: reader.float(),
328 | b: reader.float(),
329 | c: reader.float(),
330 | d: reader.float(),
331 | },
332 |
333 | camberRAD: {
334 | a: reader.float(),
335 | b: reader.float(),
336 | c: reader.float(),
337 | d: reader.float(),
338 | },
339 | tyreRadius: {
340 | a: reader.float(),
341 | b: reader.float(),
342 | c: reader.float(),
343 | d: reader.float(),
344 | },
345 | tyreLoadedRadius: {
346 | a: reader.float(),
347 | b: reader.float(),
348 | c: reader.float(),
349 | d: reader.float(),
350 | },
351 |
352 | suspensionHeight: {
353 | a: reader.float(),
354 | b: reader.float(),
355 | c: reader.float(),
356 | d: reader.float(),
357 | },
358 |
359 | carPositionNormalized: reader.float(),
360 |
361 | carSlope: reader.float(),
362 |
363 | carCoordinates: {
364 | x: reader.float(),
365 | y: reader.float(),
366 | z: reader.float(),
367 | }
368 | }
369 | }
370 | }
371 |
372 | /**
373 | * A class that accepts incoming connections from Codemasters / Dirt games.
374 | * Based on the specs from:
375 | * https://docs.google.com/spreadsheets/d/1eA518KHFowYw7tSMa-NxIFYpiWe5JXgVVQ_IMs7BVW0/edit#gid=0
376 | *
377 | * Class emits 2 events:
378 | * - 'disconnected' - when the connection is lost or dropped intentionally
379 | * - 'carInfo' - when a message with telemetry info is received
380 | */
381 | class CodemastersClient extends UDPGameClient {
382 | constructor(host = 'localhost', port = 20777) {
383 | super(host, port, true);
384 | console.log("Connecting to Dirt / Codemasters");
385 | }
386 |
387 | connect() {
388 | super.connect();
389 | }
390 |
391 | disconnect() {
392 | super.disconnect();
393 | this.emit('disconnected');
394 | }
395 |
396 | _processUDPMessage(msg, info) {
397 | var reader = new BufferReader(Buffer.from(msg));
398 | this.emit('carInfo', {
399 | unused1: reader.skip(37 * 4),
400 | engineRPM: reader.float() * 10.0,
401 | unused2: reader.skip(25 * 4),
402 | peakRPM: reader.float() * 10.0,
403 | });
404 | }
405 | }
406 |
407 | /**
408 | * A class that processes telemetry events from `ACClient` and lights up the LEDs
409 | * of the Logitech G29 wheel.
410 | */
411 | class ACLeds {
412 | acClient;
413 | device;
414 | peakRPM;
415 | flashLEDsTimer;
416 | previousLEDMask;
417 | LEDsOn;
418 | enableRedlineFlashing;
419 | loggedFirstMessage;
420 |
421 | constructor(acClient, enableRedlineFlashing = true) {
422 | this.acClient = acClient;
423 | var _this = this;
424 | acClient.on('connected', function (handshakeResponse) {
425 | _this.onConnected(handshakeResponse);
426 | });
427 | acClient.on('carInfo', function (carInfo) {
428 | _this.processCarInfo(carInfo);
429 | // Log the first message after a disconnect.
430 | if (!this.loggedFirstMessage) {
431 | console.log('Receiving data. First message:\n' + JSON.stringify(carInfo, null, ' '));
432 | this.loggedFirstMessage = true;
433 | }
434 | });
435 | acClient.on('disconnected', function () {
436 | this.loggedFirstMessage = false;
437 | })
438 | this.enableRedlineFlashing = enableRedlineFlashing;
439 | }
440 |
441 | start() {
442 | this.acClient.connect();
443 | }
444 |
445 | stop() {
446 | this.acClient.disconnect();
447 | }
448 |
449 | // Private methods.
450 |
451 | onConnected(handshakeResponse) {
452 | console.log(handshakeResponse);
453 | // Default peak RPM. This will be updated when `carInfo` messages start
454 | // coming in. Currently the AC protocol doesn't supply RPM range info of the cars.
455 | this.peakRPM = 7000;
456 | console.log("Peak RPM set to " + this.peakRPM);
457 | this.connectToWheelIfNeeded();
458 | }
459 |
460 | connectToWheelIfNeeded() {
461 | if (this.device != undefined) {
462 | return;
463 | }
464 | // Connect to the first Logitech G29.
465 | try {
466 | this.device = new hid.HID(1133, 49743);
467 | console.log("Connected to Logitech G29 wheel");
468 | } catch (e) {
469 | console.log("Could not open the Logitech G29 wheel");
470 | console.log(e);
471 | exit(1);
472 | }
473 | }
474 |
475 | processCarInfo(carInfo) {
476 | this.connectToWheelIfNeeded();
477 | this.setLEDsFromRPM(carInfo.engineRPM);
478 | if (carInfo.peakRPM != undefined) {
479 | this.peakRPM = carInfo.peakRPM;
480 | }
481 | }
482 |
483 | setLEDsFromRPM(rpm) {
484 | if (this.device == undefined) {
485 | return;
486 | }
487 | if (rpm > this.peakRPM) {
488 | this.peakRPM = rpm;
489 | }
490 | const rpmFrac = rpm / this.peakRPM;
491 |
492 | // Convert rpmFrac to an LED range.
493 | var LEDMask = 0x1;
494 | if (rpmFrac > 0.2) {
495 | LEDMask |= 0x2;
496 | }
497 | if (rpmFrac > 0.4) {
498 | LEDMask |= 0x4;
499 | }
500 | if (rpmFrac > 0.65) {
501 | LEDMask |= 0x8;
502 | }
503 | if (rpmFrac > 0.9) {
504 | LEDMask |= 0x10;
505 | }
506 | if (LEDMask == this.previousLEDMask) {
507 | return;
508 | }
509 | this.previousLEDMask = LEDMask;
510 | // If we're max-ed out i.e. probably redline, then flash all the LEDs.
511 | if (LEDMask == 0x1f && this.enableRedlineFlashing) {
512 | var _this = this;
513 | this.flashLEDsTimer = setInterval(function () { _this.flashLEDs(); }, 100);
514 | } else {
515 | if (this.flashLEDsTimer) {
516 | clearInterval(this.flashLEDsTimer);
517 | this.flashLEDsTimer = undefined;
518 | }
519 | this.device.write([0xf8, 0x12, LEDMask, 0x00, 0x00, 0x00, 0x01])
520 | }
521 | }
522 |
523 | flashLEDs() {
524 | if (this.LEDsOn) {
525 | this.device.write([0xf8, 0x12, 31, 0x00, 0x00, 0x00, 0x01])
526 | } else {
527 | this.device.write([0xf8, 0x12, 0, 0x00, 0x00, 0x00, 0x01])
528 | }
529 | this.LEDsOn = !this.LEDsOn;
530 | }
531 | }
532 |
533 | // Main entry point.
534 | var dirtLEDs = new ACLeds(new CodemastersClient(), enableRedlineFlashing = true);
535 | dirtLEDs.start();
536 |
537 | var acLEDs = new ACLeds(new ACClient(), enableRedlineFlashing = true);
538 | acLEDs.start();
539 |
--------------------------------------------------------------------------------