I built an RGB controller with Arduino – Miloš Švaňa

6 min read Original article ↗

Before Christmas, I bought a new computer case. It came with three built-in RGB fans. Unfortunately, the fans use a 3-pin aRGB connection, while my now quite old motherboard has a 4-pin RGB header. I am sure that I could find an adapter that would solve this issue right away. But there is no fun in that. Having experimented with Arduino for some time, now was the time to use my skills to build something useful.

Hardware

I already owned an Arduino R3 board and could definitely find some wires to connect the board to the aRGB connector. But I needed a way to power the board (and through it the LEDs in the fans) that also looks nice from the outside. And for reasons which I’ll describe later, I also needed to transfer some data from my PC to the Arduino.

Arduino R3 has a female USB-B connector that can deliver both data and power. And I also have a USB-A to USB-B cable that I’ve been using in all of my previous experiments with Arduino. However, I didn’t want to connect it to an outside USB port – that would be ugly as hell. I knew that my motherboard had some internal USB headers that I could utilize. After doing some research, I discovered that I can use a 4-pin header to a female USB-A adapter. So I bought one, and while squeezing my butt, I made sure that I connected it to the motherboard correctly.

Connecting the Arduino board to the aRGB connector was quite easy. I looked up the pin descriptions: there is a 5V power input, a data connection, and ground. Arduino provides both a dedicated 5V power pin and a ground pin. For the data, I chose to use pin no. 2.

So the final setup looks like this:

Motherboard -> 4-pin to female USB A -> USB A to USB B -> Arduino -> 5V power -> aRGB input
                                                                  -> data     ->
                                                                  -> ground   ->

In the real world, the setup looks a bit messier:

Software

I wanted the fan LEDs to react to what I was doing on the PC. Changing the color depending on resource utilization sounded like a fun idea:

  • RAM usage would determine the intensity of blue,
  • CPU usage would determine the intensity of green,
  • GPU usage would determine the intensity of red.

This is why I needed the Arduino to talk to the PC. After experimenting with multiple options, I decided to simply send the exact color intensities as values between 0 and 255 over a serial connection. The Arduino board would receive the intensities and simply configure the LEDs. Here is the code:

#include <FastLED.h>

#define NUM_LEDS 8
#define DATA_PIN 2
#define SERIAL_TIMEOUT 5000

CRGB leds[NUM_LEDS];

void setup() {
  Serial.begin(9600);
  Serial.setTimeout(SERIAL_TIMEOUT);
  FastLED.addLeds<WS2812B, DATA_PIN, GRB>(leds, NUM_LEDS);
}

void loop() {
  uint8_t red_gpu = 1;
  uint8_t green_cpu = 1;
  uint8_t blue_ram = 1;

  String data = Serial.readStringUntil('\n');

  if (data != NULL) { 
    sscanf(data.c_str(), "%d;%d;%d", &blue_ram, &green_cpu, &red_gpu);
  }
 
  fill_solid(leds, NUM_LEDS, CRGB(red_gpu, green_cpu, blue_ram));
  FastLED.show();
}

Instead of implementing the necessary aRGB controls myself, I used the FastLED library, which I could download easily from the Arduino IDE.

The Arduino board constantly monitors the serial connection and waits for a string that contains three numbers separated by semicolons. These numbers should have values between 0 and 255, and they can be used directly by the FastRGBs fill_solid() function to set the color.

I discovered that the USB header on the motherboard stays powered on even if the PC is turned off. So, if there is no data for more than 5 seconds, I set the color to (1, 1, 1), which results in a subtle dim white light.

The final piece of the puzzle is a simple Python script that opens a serial connection, regularly checks the resource utilization, and sends the color configuration to the Arduino:

import subprocess
import serial
import sys


def main():
    serial_port: str = sys.argv[1]
    baud_rate = 9600

    try:
        ser = serial.Serial(serial_port, baud_rate, timeout=1)
        while True:
            ram_usage = get_ram_usage()
            cpu_usage = get_cpu_usage()
            gpu_usage = get_gpu_usage()
            print(f"RAM: {ram_usage}, CPU: {cpu_usage}, GPU: {gpu_usage}")

            data = f"{ram_usage};{cpu_usage};{gpu_usage}\n"
            ser.write(data.encode())

    except serial.SerialException as e:
        print(f"Error opening serial port: {e}")
        exit(1)
    except KeyboardInterrupt:
        print("Exiting...")
    finally:
        ser.close()


def get_ram_usage() -> int:
    ram_usage_str = (
        subprocess.check_output(
            "free | grep Mem | awk '{printf \"%.2f\", $3/$2}'",
            shell=True,
        )
        .decode()
        .strip()
    )
    ram_usage = int(255 * float(ram_usage_str))
    return ram_usage


def get_cpu_usage() -> int:
    cpu_usage_str = (
        subprocess.check_output(
            "mpstat 1 1 | awk '/Average:/ {print 100 - $12 - $6}'",
            shell=True,
        )
        .decode()
        .strip()
    )
    cpu_usage: int = int(255 * float(cpu_usage_str) / 100)
    return cpu_usage


def get_gpu_usage() -> int:
    try:
        gpu_usage_str = (
            subprocess.check_output(
                "nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader | awk '{sum+=$1} END {print sum/NR}'",
                shell=True,
            )
            .decode()
            .strip()
        )
        gpu_usage: int = int(255 * float(gpu_usage_str) / 100)
    except subprocess.CalledProcessError:
        gpu_usage = 1
    return gpu_usage


if __name__ == "__main__":
    main()

To gather information about resource utilization, I use command-line tools available on Ubuntu 24.04, which I run on my PC: free for memory usage, mpstat for CPU usage, and nvidia-smi for GPU usage. I am using a bit of awk magic to extract what I need from their output. I relied on an LLM to come up with these commands, and I just verified that they work as expected.

I know it’s not recommended these days, but I installed pyserial as a global Python dependency and then added this short line to /etc/crontab to run the script after each boot:

@reboot         root    cd /home/milos/projects/rgb && python3 pcstate.py /dev/ttyACM0

The result

Here is the final result in all its glory:

In the video, I am running a single prompt in Ollama. While the PC is idle (but turned on), the LEDs are mostly blue. During text generation, the LEDs are white with a hint of pink, signalling that all three resources – RAM, CPU, and GPU are being utilized to a high degree, with GPU being utilized a bit more. Finally, as the generation ends, you can see a brief flash of teal color signaling that the GPU has completed its work, and now it’s just the CPU performing some postprocessing.


There is no takeaway from this article. I just wanted to show you a cool thing that I did and maybe inspire you to do something similar.

You can find the project code here.