February 19, 2026 · 11 min
Building a Desktop App to Control Pixel LED Matrices
How I built J-Tech Pixel LED Upload Bridge — a cross-platform desktop app that translates image data into LED matrix control signals over serial communication.
Building a Desktop App to Control Pixel LED Matrices
TL;DR: J-Tech Pixel LED Upload Bridge connects a software interface to physical LED hardware. This post covers the Electron app architecture, serial communication protocol, image-to-LED mapping, and the surprisingly tricky UX challenges of hardware control software.
The Problem
LED pixel matrices are powerful display tools — think art installations, retail displays, and stage setups. But the software ecosystem for controlling them is terrible:
- Vendor-provided tools are Windows-only
- Configuration requires technical knowledge most artists don't have
- No preview capability before uploading to hardware
- Tedious bitmap-to-matrix conversion
J-Tech Pixel LED Upload Bridge was built to solve all of this with a modern desktop experience.
Tech Stack
| Component | Technology |
|-----------|-----------|
| Desktop Shell | Electron |
| UI Framework | React + TypeScript |
| Serial Communication | Node.js serialport library |
| Image Processing | Sharp + Canvas API |
| LED Preview | WebGL (hardware-accelerated rendering) |
| State Management | Zustand |
| Packaging | electron-builder |
Why Electron?
I evaluated three options:
- Native (Swift/C++) — Best performance, but macOS-only and much slower to build
- Tauri — Smaller bundle size, but the Rust backend was overkill for serial comm
- Electron — Cross-platform, Node.js interop for
serialport, fast iteration
Electron's biggest advantage here: the serialport npm package works natively in Node.js (inside Electron's main process), giving direct USB/serial access without any bridge layer.
Architecture: Main vs Renderer Process
Electron has a strict two-process model:
┌──────────────────────────────────────────────────────────┐
│ Electron App │
│ │
│ ┌─────────────────────┐ ┌──────────────────────┐ │
│ │ Main Process │ │ Renderer Process │ │
│ │ (Node.js) │ │ (Chromium/React) │ │
│ │ │ │ │ │
│ │ - serialport │◄──►│ - React UI │ │
│ │ - File system │IPC │ - LED Preview │ │
│ │ - Image processing │ │ - Settings │ │
│ │ - Hardware comms │ │ - Animation editor │ │
│ └─────────────────────┘ └──────────────────────┘ │
└──────────────────────────────────────────────────────────┘
All hardware communication happens in the main process (where Node.js runs). The renderer process (React UI) communicates via IPC (Inter-Process Communication).
// Main process: ipc-handlers.ts
import { ipcMain } from 'electron';
import { SerialPortManager } from './serial-port-manager';
const serialManager = new SerialPortManager();
ipcMain.handle('serial:listPorts', async () => {
return await SerialPortManager.listPorts();
});
ipcMain.handle('serial:connect', async (_, portPath: string) => {
return await serialManager.connect(portPath);
});
ipcMain.handle('serial:uploadFrame', async (_, frameData: Uint8Array) => {
return await serialManager.uploadFrame(frameData);
});
// Renderer process: serial-api.ts (preload bridge)
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('serialAPI', {
listPorts: () => ipcRenderer.invoke('serial:listPorts'),
connect: (portPath: string) => ipcRenderer.invoke('serial:connect', portPath),
uploadFrame: (frameData: Uint8Array) => ipcRenderer.invoke('serial:uploadFrame', frameData),
});
Serial Communication: Talking to LED Hardware
The LED matrix accepts commands over serial (USB-CDC), following a custom binary protocol:
Packet Structure:
┌────────┬────────┬────────────┬──────────────────────────┬────────┐
│ Start │ Cmd │ Length │ Payload │ CRC │
│ 0xFF │ 1 byte │ 2 bytes │ n bytes │ 1 byte │
└────────┴────────┴────────────┴──────────────────────────┴────────┘
Commands:
0x01— Begin frame upload0x02— Frame data chunk (max 256 bytes per packet)0x03— End frame, trigger display0x04— Set brightness0x05— Request hardware status
class SerialPortManager {
private port: SerialPort | null = null;
async connect(portPath: string, baudRate = 115200): Promise<boolean> {
return new Promise((resolve, reject) => {
this.port = new SerialPort({
path: portPath,
baudRate,
autoOpen: false,
});
this.port.open((err) => {
if (err) reject(err);
else resolve(true);
});
});
}
async uploadFrame(rgbData: Uint8Array): Promise<void> {
if (!this.port) throw new Error('Not connected');
// Send begin command
await this.sendPacket(0x01, Buffer.alloc(0));
// Chunk the frame data into 256-byte packets
const chunkSize = 256;
for (let i = 0; i < rgbData.length; i += chunkSize) {
const chunk = rgbData.slice(i, i + chunkSize);
await this.sendPacket(0x02, Buffer.from(chunk));
}
// Send end command to trigger display
await this.sendPacket(0x03, Buffer.alloc(0));
}
private async sendPacket(command: number, payload: Buffer): Promise<void> {
const packetLength = 4 + payload.length; // header(4) + payload
const packet = Buffer.alloc(packetLength);
packet[0] = 0xFF; // Start byte
packet[1] = command; // Command
packet.writeUInt16BE(payload.length, 2); // Payload length
payload.copy(packet, 4); // Payload
packet[packetLength - 1] = this.crc8(packet.slice(0, -1)); // CRC
return new Promise((resolve, reject) => {
this.port!.write(packet, (err) => {
if (err) reject(err);
else resolve();
});
});
}
private crc8(data: Buffer): number {
let crc = 0;
for (const byte of data) {
crc ^= byte;
for (let i = 0; i < 8; i++) {
if (crc & 0x80) crc = ((crc << 1) ^ 0x07) & 0xFF;
else crc = (crc << 1) & 0xFF;
}
}
return crc;
}
}
Image-to-LED Mapping
A pixel LED matrix has a fixed resolution (e.g., 64×32 pixels). Uploading an image requires:
- Resize image to matrix dimensions
- Convert to raw RGB bytes
- Handle serpentine wiring (rows alternate direction)
- Apply gamma correction for accurate colors
async function processImageForMatrix(
imagePath: string,
matrixWidth: number,
matrixHeight: number,
serpentine: boolean
): Promise<Uint8Array> {
// Resize image to exact matrix dimensions
const rawPixels = await sharp(imagePath)
.resize(matrixWidth, matrixHeight, { fit: 'fill' })
.raw()
.toBuffer();
const pixels = new Uint8Array(rawPixels);
const output = new Uint8Array(matrixWidth * matrixHeight * 3);
for (let y = 0; y < matrixHeight; y++) {
for (let x = 0; x < matrixWidth; x++) {
// Serpentine wiring: odd rows run right-to-left
const physicalX = (serpentine && y % 2 === 1)
? (matrixWidth - 1 - x)
: x;
const srcIdx = (y * matrixWidth + x) * 3;
const dstIdx = (y * matrixWidth + physicalX) * 3;
// Gamma correction (2.2 gamma for LED color accuracy)
output[dstIdx] = gammaCorrect(pixels[srcIdx]); // R
output[dstIdx + 1] = gammaCorrect(pixels[srcIdx + 1]); // G
output[dstIdx + 2] = gammaCorrect(pixels[srcIdx + 2]); // B
}
}
return output;
}
function gammaCorrect(value: number): number {
return Math.round(Math.pow(value / 255, 2.2) * 255);
}
The Live Preview with WebGL
The most satisfying feature: a real-time software preview of what the LED matrix will look like, including gamma correction and brightness simulation.
// WebGL-based LED preview renderer
class LEDPreviewRenderer {
private gl: WebGLRenderingContext;
private texture: WebGLTexture;
constructor(canvas: HTMLCanvasElement) {
this.gl = canvas.getContext('webgl')!;
this.setupShaders();
this.texture = this.gl.createTexture()!;
}
render(ledData: Uint8Array, width: number, height: number) {
const { gl } = this;
// Upload pixel data as texture
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(
gl.TEXTURE_2D, 0,
gl.RGB, width, height, 0,
gl.RGB, gl.UNSIGNED_BYTE,
ledData
);
// Draw with LED simulation (circular LED dots with bloom effect)
gl.useProgram(this.ledSimProgram);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
}
UX Challenges Unique to Hardware Tools
Building UI for hardware control is different from web apps:
1. Serial port discovery is asynchronous and error-prone Users plug/unplug USB constantly. The port list must refresh automatically and handle disconnection gracefully.
2. Upload progress feedback is critical Uploading a large frame takes 1–3 seconds. Users need to see byte-level progress, not just a spinner.
3. Hardware errors are confusing
CRC mismatch error means nothing to a user. I mapped hardware error codes to human-readable explanations.
4. Unsaved changes = expensive LED uploads I added a "preview mode" (software simulation only, no hardware upload) plus explicit "Upload to Hardware" buttons.
Lessons Learned
- Test with real hardware early — software simulation hides many real-world issues (timing, CRC edge cases, dropped bytes)
- Serial ports are flaky — always implement retry logic with exponential backoff
- Serpentine wiring trips everyone up — document and visualize it clearly in the UI
- Electron bundle bloat — use electron-builder's
asarformat and ship only what's needed; I went from 280MB to 95MB - Hardware-first design — design your data format around hardware constraints, not the other way around