What is this?

A 64x64 RGB LED matrix driven by a Raspberry Pi 4B that displays the album cover of the currently playing Spotify song. A web app on the Pi enables control via the browser — from anywhere, thanks to VPN routing and OIDC authentication.

In addition to the Spotify mode, 13 different C++ demos are available (Game of Life, 3D cube, color evolution, and more), which can be launched directly from the dashboard.

Raspberry Pi 4B with Adafruit RGB Matrix Bonnet
Moby — Play on 4096 pixels

The Hardware

The panel consists of three components: a Raspberry Pi 4B, an Adafruit RGB Matrix Bonnet, and a 64x64 HUB75 LED panel with 4096 RGB pixels.

The bonnet sits directly on the Pi’s GPIO pins and translates the GPIO signals into the HUB75 protocol. The driving is handled by the C++ library rpi-rgb-led-matrix, which operates the panel at a high refresh rate with minimal latency.

Soldering Work for 64x64

The bonnet does not work out of the box with a 64x64 panel. This is because such panels use a 1/32 scan and require a fifth address line (“E”) to address all 64 rows. Smaller panels like 32x32 or 64x32 get by with lines A through D (1/16 scan). The bonnet has a solder pad for this that connects GPIO 8 to the E pin — this jumper must be manually bridged with solder. Without this bridge, the panel can only address half of its rows, resulting in a distorted image.

I have practically no knowledge of electrical engineering — which is exactly why I chose this project. Always learning something new. I only discovered that the solder jumper is needed for 64x64 panels through numerous tutorials and forum posts. I would not have figured it out on my own. Since I do not own a soldering iron, my friend Sven kindly took care of it — thanks for that!

The challenge with HUB75 : the protocol multiplexes the 64 rows — at any given moment, only a few rows are active. The library must cycle through the rows fast enough for the human eye to perceive a stable image. On the Pi, this works reliably as long as no other process is blocking the CPU cores.

Spotify Integration

The most interesting part: the panel displays the album cover of the song currently playing on Spotify.

The OAuth Flow

Spotify does not offer simple API keys. Instead, the API uses OAuth 2.0 with the Authorization Code Flow:

  1. The user clicks “Connect with Spotify” and is redirected to Spotify
  2. After login, Spotify redirects back with an authorization code
  3. The backend exchanges the code for an access token and a refresh token
  4. The tokens are persisted in SQLite

From this point on, no further login is required. The access token expires after one hour but is automatically renewed via the refresh token. The Spotify app runs in development mode — that is sufficient for a single account.

From API Call to LED Pixel

When a new song starts, the following happens:

  1. The backend polls Spotify’s /v1/me/player/currently-playing every 5 seconds
  2. On track change, the smallest available cover image is downloaded (64x64 JPEG)
  3. led-image-viewer from the rpi-rgb-led-matrix library displays the image on the panel

The last step uses child_process.spawn to start the image viewer as a root process — GPIO access requires root privileges. A process manager ensures that only one process controls the matrix at a time: before a new cover is displayed, the previous process is cleanly terminated.

Auto Mode

In auto mode, the backend compares the current track with the last displayed one on every poll. A new cover is only loaded and displayed on an actual track change. This saves API calls and avoids unnecessary process restarts.

Auto mode runs server-side — so the cover changes even with the browser closed, as long as Spotify is playing on any device.

The Path to the Pi: Network Routing

The Pi is on the local network and is only reachable via WireGuard VPN. The web app, however, needs to be publicly accessible (with login). The solution is a three-stage routing chain:

Browser → Nginx (Hetzner server, SSL) → socat (VPN server) → Pi (172.30.0.4:3005)

The problem: the Hetzner server and the Pi are on different networks. The Hetzner server does not know the VPN subnet. A socat port forward on the VPN server bridges this — it listens on the Hetzner private network and forwards traffic through the WireGuard tunnel to the Pi.

Process Manager: One Process, One Matrix

The central hardware constraint: only one process can control the GPIO pins at a time. The process manager encapsulates this logic:

  • Start — Checks if a process is already running, terminates it if so, then starts the new one
  • Stop — Sends SIGTERM, waits up to 5 seconds, then SIGKILL
  • Status — Returns the current mode (demo or image), the PID, and the uptime

Every call — whether starting a demo, displaying a cover, or stopping — goes through the process manager. This prevents zombie processes and ensures the matrix is always in a defined state.

Deployment: GitHub Actions → SSH Jump → Pi

The deployment is unusual because the target is not a cloud server but a Pi behind a VPN. The GitHub Action uses an SSH jump via the VPN server:

GitHub Actions → SSH → VPN Server (Jump Host) → SSH → Pi (172.30.0.4)

On the Pi, the application is built and restarted — no Docker, directly Node.js with a systemd service. This saves the build time for Docker images, which would take significantly longer on a Pi.

What I Learned

  • GPIO access requires root — and therefore a clean process manager that tracks and cleans up processes. Orphaned root processes on a Pi without a monitor are hard to debug.
  • Spotify has no API keys — only OAuth 2.0. For a personal use case, it is overhead in setup, but the refresh token makes it maintenance-free afterward.
  • 64x64 pixels are surprisingly expressive — album covers are designed for small resolutions and look remarkably good even on an LED matrix.
  • Multi-hop SSH deployments work reliably with GitHub Actions but require proper key management and a stable VPN tunnel as a foundation.

Glossary

GPIO
General Purpose Input/Output — freely programmable pins on a microcontroller or single-board computer. Via GPIO, the Raspberry Pi controls the LED matrix directly at the hardware level without additional controllers.
HUB75
A standard interface for RGB LED panels that transmits row and color data over parallel pins. The Raspberry Pi drives the panel via GPIO pins mapped to the HUB75 connector by a HAT (like the Adafruit RGB Bonnet).
OAuth 2.0
An authorization framework that allows applications to access resources on behalf of a user without knowing their password. Forms the foundation for OpenID Connect and many login-with-third-party flows.
Refresh Token
A long-lived token that requests new access tokens in the background without requiring the user to log in again. Access tokens typically expire after one hour — the refresh token renews them automatically.
child_process.spawn
A Node.js function that starts an external process as a child process. Unlike exec, spawn streams stdout/stderr instead of waiting for completion — ideal for long-running processes like LED control.
OpenID Connect (OIDC)
An authentication protocol based on OAuth 2.0. The Identity Provider confirms a user's identity and returns information such as name, email, and roles in a signed ID token.
Single Sign-On (SSO)
An authentication method where a user logs in once and can then access multiple applications without logging in again. The Identity Provider manages the central session.
Reverse Proxy
A server that receives incoming requests and forwards them to backend services. Handles tasks like SSL termination, rate limiting, and access control, so the actual applications don't need to be directly accessible from the internet.
WireGuard
A modern VPN protocol focused on minimal code and strong cryptography. Compared to OpenVPN or IPSec, it's significantly leaner (~4,000 lines of kernel code), faster to connect, and easier to configure.
CI/CD
Continuous Integration and Continuous Deployment — automated processes that test, build, and deploy code after every push. Reduces manual errors and enables fast, reproducible deployments.