├── 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 | ![image of the shifting LEDs on the G29](images/shifting_leds.png?raw=true) 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 | --------------------------------------------------------------------------------