← Back to tech insights

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:

  1. Native (Swift/C++) — Best performance, but macOS-only and much slower to build
  2. Tauri — Smaller bundle size, but the Rust backend was overkill for serial comm
  3. 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 upload
  • 0x02 — Frame data chunk (max 256 bytes per packet)
  • 0x03 — End frame, trigger display
  • 0x04 — Set brightness
  • 0x05 — 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:

  1. Resize image to matrix dimensions
  2. Convert to raw RGB bytes
  3. Handle serpentine wiring (rows alternate direction)
  4. 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

  1. Test with real hardware early — software simulation hides many real-world issues (timing, CRC edge cases, dropped bytes)
  2. Serial ports are flaky — always implement retry logic with exponential backoff
  3. Serpentine wiring trips everyone up — document and visualize it clearly in the UI
  4. Electron bundle bloat — use electron-builder's asar format and ship only what's needed; I went from 280MB to 95MB
  5. Hardware-first design — design your data format around hardware constraints, not the other way around

Explore J-Tech Pixel LED Upload Bridge: Portfolio | GitHub