How I created a sim racing speedometer

A sim racing speedometer, in a 3D printed shell, dial showing engine RPM and a four-digit seven-segment display showing vehicle speed

I like sim racing and thought it would be fun to have real things moving around based on the game. Directly, creating a speedometer came to mind. This would be a nice side project and would reduce the number of HUD in the game. This enhances the experience by having more physical components and a better visibility in game.

The goal is a speedometer that shows the engine RPM and the vehicle speed in km/h.

The requirements

I wanted to build something modular, repairable, and with off-the-shelf components, specifically what I have lying around. I have too much stuff from Arduino kits that are not used and might never be used.

That's why I used an Arduino UNO as the microcontroller.

A lot of microcontrollers could have done the job. The final product is a bit huge because of it, but in terms of power the UNO is doing fine and it was not an issue.

For displaying the speed, I used a 3461BS four-digit seven-segment display paired with a HC595 8 bit shift register. The bit shift register allows controlling up to 8 outputs using a serial input, 2 pins, clock and data. This reduces the amount of pins used on the Arduino.

The Arduino UNO can use up to 19 GPIO. So 7 (segments) + 4 (digits) + 1 (servo, PWM) = 12 pins. Using the HC595 was not necessary by any means. I just wanted to try it out and learn how this basic component works.

First prototype

The first prototype was made using a breadboard and a cardboard dial.

First prototype with the UNO and a breadboard
First prototype with the servo motor
First prototype with all the components mounted on a breadboard and the servo attached to a cardboard dial

At that point, I started writing the software part. It will get detailed in (Software)[#Software], but the prototype was promising!

Polishing

I then created a custom board for all the components. All the resistors, the DIP socket for the HC595 and a decoupling capacitor for the servo motor were mounted and soldered on a strip board for a clean finished project.

Strip board with soldered components
Hand soldered strip board :) You don't want to look to the other way.

Female pin headers were also soldered to provide a connector-like connection between this board, the Arduino, and the 3461BS.

The custom board was tested with everything plugged in, replacing the breadboard.

Prototype using the strip board
Prototype cardboard speedometer using the strip board

The 3461BS was also soldered on a little board with pin headers, to have an easier time screwing it to the shell.

Final look

I designed the shell in FreeCAD to learn 3D modeling and then 3D printed it in PLA. Several revisions of the design were needed before landing on this one. The final 3D printed case had some small clearance issues that I fixed by manual scraping. I then assembled everything into a tiny and neat product.

Fitting all components inside the shell
Fitting all the components together in the tight shell. Threaded inserts were used to facilitate screw mounting of the components and to allow disassembly!

The two boards and the Arduino are all screwed onto the shell. All connections are removable. This inherently made everything bulkier, but I think it turns out pretty great. I am proud of having designed a project with the look and feel pretty close to a real product, that is possible to disassemble (unlike real high-tech products in this day and age).

Software

The software needs to receive the game information and communicate it to the Arduino.

The games I play are Forza Horizon 4 and 5 and Assetto Corsa. I only implemented the software for Forza games for now but plan to add Assetto Corsa one day.

All recent Forza games have a remote telemetry feature where game state is sent as UDP packet to a server. The server address and port can be set in the game settings.

To receive Forza state, we need a UDP server, listening and parsing incoming packets. forza-telemetry from austinbaccus helped me to get the right part of the UDP packet for engine RPM and vehicle speed, and better understand Forza own documentation.

Sending data to Arduino

I used the USB UART of the Arduino, so one can just plug it to the gaming computer and it will also power everything.

The software then just opens the serial TTY file to write to it. My Arduino is detected as /dev/ttyACM0.

I noticed connecting to the Arduino UART triggers a reset. After some research this is necessary for sketch flashing and preventable by adding a capacitor (22uF) between RESET and GND.

This is done on purpose by Arduino to put the microcontroller in flash mode easily when flashing a sketch. Without this, one needs to manually reset the board at the right time when flashing.

To have the minimum amount of latency, I have set the baud rate to a whopping 2 000 000 Hz! I of course ran into some stability issues that I will detail further.

The data are received in the following format and units from the Forza games:

  • Engine RPM: received as Float 32.
  • Vehicle speed: received as Float 32 in m/s.

But, this is way too much information. I would like to have one byte per data.

The servo motor used for the RPM uses a PWM signal of the UNO. The PWM is generated using a 10bit ADC. So the maximum would be 10 bits. Secondly, the RPM is between 0 and let say 15000 maximum, and is an integer. Mapping this range to 10 bits would be the ideal solution but, I think using 1 byte and not 2 is also nice.

I choose to go with only the RPM/100 as an integer. Values range from 0 to 150. This fits in one byte and will be very easy to send over UART.

I could have chosen to map to 0-255 value range, but this was already good enough and dead simple!

For the vehicle speed, the end result will be displayed on a four-digit display. As I never saw a real car speedometer showing speed in numerical form WITH a floating point, the speed will be in integer.

Once again there is also real-life limits. A car will never go slower than 0 and the world record (production car) is as of today 496.22 km/h. It is safe to assume 511 km/h could be around the corner quickly in video games, so let put the max at 1023 km/h.

Unfortunately, the same trick cannot be used to reduce the number of bytes, as dividing by 4 to go into a 0 to 255 range would mean losing precision. This precision is needed when the car runs at slow speed. The experience will not be realistic if the speed jumps from 0 to 3 with nothing in between while accelerating slowly.

I split it in half. Having one byte holding the 5 most significant bits and the second byte holding the 5 least significant bits.

The issues

The flow was : sending the bytes in the order speed1, speed2, rpm over and over again, reading and parsing back the data on the Arduino. This was not working. The servo was moving back and forth; the display was doing cryptic black magic LED invocations.

The issue was simple, there was no way to know on the Arduino which one was the first packet of the three bytes. So, even if technically the Arduino starts to read in the same order as the host computer is sending the data, only one packet miss is needed for everything to fall apart.

I tried slowing down the baud rate, and indeed, it was working at a very, very slow baud rate. This was not usable at all. And I can't explain why.

To have 60 refreshes per second, the 3 bytes need to be sent in 1/60 of a second. That's one byte per 1/180 of a second, so 180 bytes per second, so 1440 bits/s. And, at this baud rate, it was terribly laggy. As of today, I didn't think more to explain why it's laggy and what I'm missing.

Anyway, having a baud rate of 2 Mbits/s is funny, so let's continue.

The solution

The solution I chose was a custom packet frame, with a defined start and end byte. As all three data bytes are 5 bits and 7 bits long, I can choose any number greater than 127 as a start and end value. I chose simply: start is 255, end is 254.

So the frame is like this:

  • Start byte = 255
  • Speed 5 bits MSB
  • Speed 5 bits LSB
  • RPM 7 bit
  • End byte = 254

This was working amazingly well!

After some initial testing in Python, the final host code has been rewritten in C.

If you have to do low level stuff and to deal with raw bits, Python is a nightmare. I lost time troubleshooting with print() converting to ASCII and no way to show raw bytes or in binary form easily.

The POC host code was using a plain while loop: waiting for the game UDP packet, parsing it, formatting the data, and sending over UART.

I'm currently planning to implement a better UX on the software:

  • auto-detect game
  • support Forza AND Asseto Corsa
  • small terminal UI showing the Arduino and game status (connected / not connected)
  • multithreading between the server and the UART for non-blocking IO