← Blog
/POST · ALFIE MILLS

A Forza Rev Gauge on the Arduino UNO Q's LED Matrix

The Arduino UNO Q is a strange and lovely board: it pairs a microcontroller with a Linux-capable side, and the two talk to each other over a built-in bridge. That setup is perfect for something I'd wanted for a while, a little physical rev counter that lights up while I'm playing Forza Horizon 4. Forza happily broadcasts telemetry over the network, so all I had to do was catch it and draw it.

How the two halves talk

The trick with the UNO Q is splitting the work across the two sides. The Linux/Python side is good at networking; the sketch side is good at driving hardware in real time. They communicate through Arduino_RouterBridge, where Python exposes functions and the sketch calls them.

On the Python side, exposing a function is one decorator:

from bridge import expose

@expose
def get_rpm_percent():
    """Returns RPM as percentage (0-100) of usable range."""
    ...

@expose
def is_race_on():
    return _telemetry['is_race_on']

And the sketch calls them like remote procedure calls:

int rpmPercent = Bridge.function_call("get_rpm_percent").toInt();
int isRaceOn   = Bridge.function_call("is_race_on").toInt();

Clean separation. The Python side never thinks about LEDs, and the sketch never thinks about sockets.

Catching Forza's telemetry

Forza Horizon 4 has a "data out" feature that fires UDP packets at an IP and port of your choosing. Point it at the UNO Q on port 5005 and the packets start flowing. The Python side runs a listener on a background thread so it never blocks the bridge:

def udp_listener():
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(("0.0.0.0", 5005))
    while True:
        data, addr = sock.recvfrom(1500)
        parse_telemetry(data)

The packet itself is a fixed binary struct, and the fields I care about are right at the front. Forza uses little-endian, so I unpack them with struct:

is_race_on  = struct.unpack('<i', data[0:4])[0]
max_rpm     = struct.unpack('<f', data[8:12])[0]
idle_rpm    = struct.unpack('<f', data[12:16])[0]
current_rpm = struct.unpack('<f', data[16:20])[0]

A couple of things bit me here. The packet is at least 324 bytes (Forza has a "dash" format with extra fields), so I drop anything shorter than that. And IsRaceOn is genuinely useful: it goes to zero in menus and pause screens, so I can show an idle pattern instead of a frozen gauge.

The percentage maths is worth getting right too. A redline at 8000 RPM and an idle at 800 means the bottom tenth of the dial is dead weight. So I scale against the usable range, idle to redline, not zero to redline:

usable_range = max_rpm - idle
current_above_idle = max(0, current - idle)
percent = (current_above_idle / usable_range) * 100

That way the bars actually start moving the moment you touch the throttle.

Drawing the gauge

The UNO Q's onboard matrix is 12 columns by 8 rows. I keep a frame buffer, redraw it at 20Hz, and push it out with renderBitmap:

uint8_t frame[MATRIX_HEIGHT][MATRIX_WIDTH];
const int UPDATE_INTERVAL = 50;  // 20Hz
...
matrix.renderBitmap(frame, MATRIX_HEIGHT, MATRIX_WIDTH);

The number of lit columns maps straight from RPM percentage to the 12 columns:

int litColumns = map(rpmPercent, 0, 100, 0, MATRIX_WIDTH);
litColumns = constrain(litColumns, 0, MATRIX_WIDTH);

To make it look like a real tachometer rather than a flat bar graph, each column gets a different height (short at the left, taller toward the right) so the lit area sweeps up in a curve as you rev:

int getBarHeight(int col) {
  if (col < 2)      return 2 + col;          // 2, 3
  else if (col < 5) return 4 + (col - 2);    // 4, 5, 6
  else if (col < 9) return 6 + (col - 5) / 2;// 6, 6, 7, 7
  else              return 8;                 // full height at redline
}

The last detail is the shift light. Once you're past 85% the redline columns flash, toggled by a separate timer so the flash rate is independent of the gauge update:

bool inRedline = rpmPercent >= 85;
if (isRedlineCol && inRedline && !flashState) {
  continue;  // skip drawing this column during the flash-off phase
}

Building it

The sketch targets the UNO Q's Zephyr core, declared in sketch.yaml:

profiles:
  default:
    fqbn: arduino:zephyr:unoq
    platforms:
      - platform: arduino:zephyr
    libraries:
      - ArduinoGraphics (1.1.4)

The whole thing is two small files (a Python receiver and an Arduino sketch) glued by the bridge. Fire up Forza, point its telemetry at the board, and the matrix turns into a shift light that sweeps with the engine and flashes at the redline. It's a small project, but it's the kind that makes the UNO Q's split-brain design click: let Linux do the networking, let the microcontroller do the blinking, and let the bridge carry one integer between them.