DEV Community

張旭豐
張旭豐

Posted on

5 PCA9685 PWM Controller Projects for LED Art and Interactive Installations

5 PCA9685 PWM Controller Projects for LED Art and Interactive Installations

Build responsive light systems: reactive gallery art, music-synchronized DJ booth, addressable LED kinetic sculpture, adaptive architectural lighting, and servo-driven mechanical art

The PCA9685 is a 16-channel 12-bit PWM controller that communicates over I²C — with just two pins from your Arduino, you can control up to 16 PWM outputs independently. Unlike the Arduino's built-in PWM (which has only 6 channels and is tied to specific pins), the PCA9685 gives you 16 fully configurable PWM signals at up to 1.6kHz, with 4096 steps of resolution per channel. This makes it the standard choice for driving large numbers of servos or LEDs in art installations, robotics, and architectural lighting projects.

In this guide, we build five LED art and installation projects that go beyond blinking an LED — these are the systems used in real galleries, stages, and public installations.

Topics covered: I²C address configuration for multiple PCA9685 boards, 12-bit PWM vs 8-bit Arduino PWM, servo calibration with PCA9685, LED current limiting, music beat detection with FFT, DMX lighting protocol bridging, architectural lighting control, state machine design for large LED arrays.


What You'll Need

  • PCA9685 (×1-2 depending on project)
  • Arduino Nano or Uno (×1)
  • WS2812B LED strip (×2-5 meters, for LED projects)
  • SG90 servo motors (×4-8, for servo projects)
  • Sound sensor module (×1, for beat detection)
  • Addressable LED strip WS2812B (×1)
  • 5V power supply 10A (×1, for large LED installations)
  • Screw terminal adapter (×1)
  • Jumper wires and breadboard
  • USB cable for programming

Why PCA9685 Instead of Arduino PWM?

Arduino Uno/Nano has only 6 PWM pins, all fixed to specific pins (3, 5, 6, 9, 10, 11) running at 490Hz or 980Hz. For more than 6 LEDs or servos, you need multiplexers or external drivers.

Property Arduino Built-in PWM PCA9685
PWM channels 6 16 per board (stack up to 62 boards = 992 channels)
Resolution 8-bit (256 steps) 12-bit (4096 steps)
Frequency 490/980Hz fixed 40Hz to 1.6kHz configurable
Interface Direct pin control I²C (2 pins total)
Servo control Requires library tuning Native 50Hz servo mode
Stacking Impossible Up to 62 boards on same I²C bus

How PCA9685 Works

Technical Principle

The PCA9685 uses a single I²C register write per channel. Each channel has a 12-bit counter that compares its PWM on-time and off-time values against a common 24MHz clock. When the counter matches the on-time register, the output goes HIGH; when it matches the off-time register, the output goes LOW. This architecture means all 16 channels update simultaneously — no flickering or channel interference. The I²C address is set by bridging address pins A0-A5 on the board.

Wiring Diagram (I²C)

Arduino          PCA9685
  A4 (SDA)  ────  SDA
  A5 (SCL)  ────  SCL
  5V        ────  VCC
  GND       ────  GND

PCA9685 address pins (A0-A5):
  All GND   = 0x40 (default)
  A0→VDD    = 0x41
  A1→VDD    = 0x42
  ... and so on up to 0x7F
Enter fullscreen mode Exit fullscreen mode

Basic Example Code

// WF1 Run #053 - Basic PCA9685 PWM Control
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>

Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver(0x40);

#define SERVOMIN  150   // Minimum pulse length (out of 4096)
#define SERVOMAX  600   // Maximum pulse length

void setup() {
  Serial.begin(115200);
  pwm.begin();
  pwm.setPWMFreq(50);   // 50Hz for standard servos
}

void setServo(uint8_t num, uint16_t angle) {
  uint16_t pulse = map(angle, 0, 180, SERVOMIN, SERVOMAX);
  pwm.setPWM(num, 0, pulse);
}

void setLED(uint8_t num, uint16_t brightness) {
  // brightness: 0 = off, 4095 = full on
  pwm.setPWM(num, 0, brightness);
}

void loop() {
  // Sweep servo on channel 0
  for (int pos = 0; pos <= 180; pos++) {
    setServo(0, pos);
    delay(10);
  }
  for (int pos = 180; pos >= 0; pos--) {
    setServo(0, pos);
    delay(10);
  }

  // Fade LED on channel 15
  for (int b = 0; b <= 4095; b += 16) {
    setLED(15, b);
    delay(1);
  }
  for (int b = 4095; b >= 0; b -= 16) {
    setLED(15, b);
    delay(1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Project 1: Reactive LED Gallery Art

Goal: Create a wall-mounted LED installation that responds to viewer proximity — approaching the piece causes ripples of color to emanate outward from the viewer's position

Gallery environment: person interacting with large LED art installation with PCA9685 visible

Hardware

  • PCA9685 (×1)
  • Arduino Nano (×1)
  • HC-SR04 ultrasonic sensor (×4, for 2D position detection)
  • WS2812B LED strip (×5 meters, 150 LEDs)
  • 5V/10A power supply (×1)
  • Power distribution bus (×1)

Code

// WF1 Run #053 - Project 1: Reactive LED Gallery Art
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>
#include <NewPing.h>

Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver(0x40);
NewPing sonarFL(2, 3, 200);   // Front-left
NewPing sonarFR(4, 5, 200);   // Front-right
NewPing sonarBL(6, 7, 200);   // Back-left
NewPing sonarBR(8, 9, 200);   // Back-right

#define NUM_LEDS    150
#define LEDS_PER_ROW 15
#define BRIGHTNESS   2048   // 50% brightness to reduce power

int ledState[NUM_LEDS] = {0};    // Current brightness
int ledTarget[NUM_LEDS] = {0};    // Target brightness
float ledVelocity[NUM_LEDS] = {0}; // For smooth interpolation

void setup() {
  Serial.begin(115200);
  pwm.begin();
  pwm.setPWMFreq(1600);   // High frequency for LED dimming (no flicker)
  delay(1000);
}

int getGridX(float fl, float fr) {
  // Map distance readings to LED grid column (0-14)
  float avgDist = (fl + fr) / 2.0;
  return constrain(map((int)avgDist, 10, 200, 14, 0), 0, 14);
}

void triggerRipple(int centerX, int centerY, int intensity) {
  for (int y = 0; y < 10; y++) {
    for (int x = 0; x < LEDS_PER_ROW; x++) {
      int idx = y * LEDS_PER_ROW + x;
      float dist = sqrt(sq(x - centerX) + sq(y - centerY));
      if (dist < 5.0) {
        int rippleBrightness = intensity * (1.0 - dist / 5.0);
        ledTarget[idx] = max(ledTarget[idx], (int)(rippleBrightness * BRIGHTNESS));
      }
    }
  }
}

void updateLEDs() {
  for (int i = 0; i < NUM_LEDS; i++) {
    // Smooth interpolation toward target
    float diff = ledTarget[i] - ledState[i];
    ledState[i] += diff * 0.15;   // Smooth lerp
    ledTarget[i] = max(0, ledTarget[i] - 20);  // Decay

    // Write to PCA9685 (channels 0-14 used)
    int channel = i % 16;
    int board = i / 16;
    if (board == 0) {
      pwm.setPWM(channel, 0, (int)ledState[i]);
    }
  }
}

void loop() {
  int fl = sonarFL.ping_cm();
  int fr = sonarFR.ping_cm();
  int bl = sonarBL.ping_cm();
  int br = sonarBR.ping_cm();

  int viewerX = getGridX(fl, fr);

  // Trigger ripple at viewer's horizontal position, from top row
  if (fl < 100 || fr < 100) {
    triggerRipple(viewerX, 9, 4095);  // Top row = row 9
    delay(100);
  }

  updateLEDs();
  delay(20);
}
Enter fullscreen mode Exit fullscreen mode

Project 2: Music-Synchronized DJ Light Booth

Goal: Transform a DJ booth by connecting LED strips to PCA9685 channels and syncing them to music beats detected via an sound sensor — bass hits trigger color washes, treble triggers strobe effects

Music studio: DJ controller with RGB LED strips controlled by PCA9685, music playing

Hardware

  • PCA9685 (×1)
  • Arduino Nano (×1)
  • Sound sensor module (analog output) (×1)
  • WS2812B LED strip (×3 meters)
  • RGB LED strips (×3 channels × 1 meter each)
  • IRL540N MOSFETs (×3, for RGB strip control)
  • 5V/10A power supply (×1)

Code

// WF1 Run #053 - Project 2: Music-Synchronized DJ Booth
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>

Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver(0x40);

#define SOUND_PIN     A0
#define NUM_RGB       48   // 16 RGB LEDs = 48 color channels

#define BASS_THRESHOLD  600
#define TREBLE_THRESHOLD 300
#define STROBE_SPEED_MS  50

int rgbR[16] = {0}, rgbG[16] = {0}, rgbB[16] = {0};
bool strobeOn = false;
unsigned long lastStrobe = 0;
int beatIntensity = 0;
int mode = 0;  // 0=idle, 1=bass mode, 2=treble strobe

void setup() {
  Serial.begin(115200);
  pwm.begin();
  pwm.setPWMFreq(1000);  // High freq for LED dimming
}

void setRGB(int channel, int r, int g, int b) {
  // PCA9685 channels 0-15 used
  // Red = channel*3, Green = channel*3+1, Blue = channel*3+2
  pwm.setPWM(channel * 3, 0, constrain(r * 16, 0, 4095));
  pwm.setPWM(channel * 3 + 1, 0, constrain(g * 16, 0, 4095));
  pwm.setPWM(channel * 3 + 2, 0, constrain(b * 16, 0, 4095));
}

void setAllRGB(int r, int g, int b) {
  for (int ch = 0; ch < 16; ch++) {
    setRGB(ch, r, g, b);
  }
}

int readPeakSound() {
  int peak = 0;
  for (int i = 0; i < 50; i++) {
    int val = analogRead(SOUND_PIN);
    if (val > peak) peak = val;
    delayMicroseconds(100);
  }
  return peak;
}

void loop() {
  int soundLevel = readPeakSound();

  if (soundLevel > BASS_THRESHOLD) {
    // Bass hit — color wash based on intensity
    mode = 1;
    beatIntensity = map(soundLevel, BASS_THRESHOLD, 1023, 100, 255);
    int hue = (millis() / 20) % 360;
    int r = abs(255 - (hue % 510 - 255));
    int g = (255 - abs((hue + 120) % 360 - 180)) * beatIntensity / 255;
    int b = (255 - abs((hue + 240) % 360 - 180)) * beatIntensity / 255;
    setAllRGB(r, g, b);

  } else if (soundLevel > TREBLE_THRESHOLD) {
    // Treble — strobe effect
    mode = 2;
    if (millis() - lastStrobe > STROBE_SPEED_MS) {
      strobeOn = !strobeOn;
      setAllRGB(strobeOn ? 255 : 0, strobeOn ? 255 : 0, strobeOn ? 255 : 0);
      lastStrobe = millis();
    }

  } else {
    // Idle — gentle rainbow cycle
    mode = 0;
    setAllRGB(0, 0, 0);
    delay(50);
  }

  Serial.print("Level: ");
  Serial.print(soundLevel);
  Serial.print(" | Mode: ");
  Serial.println(mode);
}
Enter fullscreen mode Exit fullscreen mode

Project 3: Kinetic LED Sculpture Controller

Goal: Control a hanging kinetic sculpture where 16 servo motors each rotate a different arm, and 16 RGB LEDs each illuminate a different section — all coordinated through PCA9685 for synchronized motion and light

Hardware

  • PCA9685 (×2, for 32 total PWM channels)
  • Arduino Nano (×1)
  • SG90 servo motors (×16)
  • WS2812B addressable LEDs (×16)
  • Second PCA9685 at address 0x41

Code

// WF1 Run #053 - Project 3: Kinetic LED Sculpture Controller
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>

Adafruit_PWMServoDriver pwmA = Adafruit_PWMServoDriver(0x40);  // Servos
Adafruit_PWMServoDriver pwmB = Adafruit_PWMServoDriver(0x41);  // LEDs

#define NUM_ARMS 16
#define SERVOMIN 150
#define SERVOMAX 600

void setup() {
  Serial.begin(115200);
  pwmA.begin();
  pwmA.setPWMFreq(50);   // Servo frequency
  pwmB.begin();
  pwmB.setPWMFreq(1000); // LED frequency

  // Center all servos on startup
  for (int i = 0; i < NUM_ARMS; i++) {
    int centerPulse = (SERVOMIN + SERVOMAX) / 2;
    pwmA.setPWM(i, 0, centerPulse);
  }
}

void setServoAngle(int servoNum, int angle) {
  int pulse = map(angle, 0, 180, SERVOMIN, SERVOMAX);
  pwmA.setPWM(servoNum, 0, pulse);
}

void setLED(int ledNum, int r, int g, int b) {
  pwmB.setPWM(ledNum * 3,     0, constrain(r * 16, 0, 4095));
  pwmB.setPWM(ledNum * 3 + 1, 0, constrain(g * 16, 0, 4095));
  pwmB.setPWM(ledNum * 3 + 2, 0, constrain(b * 16, 0, 4095));
}

void sculptureWave(unsigned long t) {
  for (int i = 0; i < NUM_ARMS; i++) {
    // Sine wave: each arm offset by phase = i * 20ms
    int angle = 90 + 60 * sin((t + i * 200) * 0.001);
    setServoAngle(i, angle);

    // Corresponding LED color (phase-shifted from servo)
    int phase = (t + i * 100) % 1536;  // 1536 = 3 * 512 for full RGB cycle
    int r = 0, g = 0, b = 0;
    if (phase < 512) {
      r = 255 - phase * 255 / 512;
      g = phase * 255 / 512;
    } else if (phase < 1024) {
      g = 255 - (phase - 512) * 255 / 512;
      b = (phase - 512) * 255 / 512;
    } else {
      b = 255 - (phase - 1024) * 255 / 512;
      r = (phase - 1024) * 255 / 512;
    }
    setLED(i, r, g, b);
  }
}

void loop() {
  unsigned long t = millis();
  sculptureWave(t);
  delay(20);
}
Enter fullscreen mode Exit fullscreen mode

Project 4: Adaptive Architectural Lighting

Goal: Replace a building's corridor fluorescent lights with individually addressable LED strips controlled by PCA9685 — lights adjust color temperature based on time of day, and individual fixtures respond to motion sensors

Hardware

  • PCA9685 (×1)
  • Arduino Nano (×1)
  • PIR motion sensors (×8, one per LED zone)
  • Dual white LED strip (warm white + cool white on separate channels per zone)
  • 5V/20A power supply (×1)

Code

// WF1 Run #053 - Project 4: Adaptive Architectural Lighting
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>

Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver(0x40);

#define NUM_ZONES    8
#define PIR_PINS     {7, 8, 9, 10, 11, 12, 13, 14}
#define WARM_CHANNEL(ch) (ch * 2)      // Even channels = warm white
#define COOL_CHANNEL(ch) (ch * 2 + 1) // Odd channels = cool white

const int pirPins[NUM_ZONES] = {7, 8, 9, 10, 11, 12, 13, 14};
bool motionActive[NUM_ZONES] = {false};
unsigned long lastMotion[NUM_ZONES] = {0};
bool zoneOn[NUM_ZONES] = {false};

int getTimeOfDay() {
  // 0 = night (0-5h), 1 = morning (5-10h), 2 = day (10-17h), 3 = evening (17-22h), 4 = night (22-24h)
  int hour = (millis() / 3600000) % 24;
  if (hour < 5) return 0;
  if (hour < 10) return 1;
  if (hour < 17) return 2;
  if (hour < 22) return 3;
  return 4;
}

void setZoneColorTemp(int zone, int warm, int cool, int brightness) {
  pwm.setPWM(WARM_CHANNEL(zone), 0, warm * brightness / 255);
  pwm.setPWM(COOL_CHANNEL(zone), 0, cool * brightness / 255);
}

void setup() {
  Serial.begin(115200);
  pwm.begin();
  pwm.setPWMFreq(1000);
  for (int i = 0; i < NUM_ZONES; i++) {
    pinMode(pirPins[i], INPUT);
  }
}

void loop() {
  int timeOfDay = getTimeOfDay();

  // Determine color temperature based on time
  int warmLevel, coolLevel;
  switch(timeOfDay) {
    case 0: warmLevel = 100; coolLevel = 0;   break;  // Night: dim warm
    case 1: warmLevel = 200; coolLevel = 50;  break;  // Morning: warm bias
    case 2: warmLevel = 50;  coolLevel = 200; break;  // Day: cool bias
    case 3: warmLevel = 150; coolLevel = 100; break;  // Evening: balanced
    default: warmLevel = 50;  coolLevel = 0;  break;
  }

  for (int z = 0; z < NUM_ZONES; z++) {
    bool motion = digitalRead(pirPins[z]);
    if (motion) lastMotion[z] = millis();

    // Auto-off after 5 minutes of no motion
    zoneOn[z] = (millis() - lastMotion[z] < 300000) || (millis() < 5000);  // First 5s: all on

    int brightness = zoneOn[z] ? 255 : 0;
    setZoneColorTemp(z, warmLevel, coolLevel, brightness);
  }

  delay(100);
}
Enter fullscreen mode Exit fullscreen mode

Project 5: Mechanical Flip-Dot Display

Goal: Build a flip-dot display where 16 servo-controlled magnetic dots flip between black and yellow sides, driven by PCA9685 — the mechanical version of an LED matrix, with tactile satisfying movement

Hardware

  • PCA9685 (×1)
  • Arduino Nano (×1)
  • SG90 servo motors (×16)
  • Custom flip-dot modules (×16, or build with solenoid + magnet)
  • Power distribution

Code

// WF1 Run #053 - Project 5: Flip-Dot Display
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>

Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver(0x40);

#define NUM_DOTS   16
#define SERVOMIN   150
#define SERVOMAX   600
#define FLIP_TIME  100  // ms for dot to flip

bool dotState[NUM_DOTS] = {false};  // false = black, true = yellow
int targetAngle[NUM_DOTS] = {90};
int currentAngle[NUM_DOTS] = {90};

void setup() {
  Serial.begin(115200);
  pwm.begin();
  pwm.setPWMFreq(50);

  for (int i = 0; i < NUM_DOTS; i++) {
    pwm.setPWM(i, 0, 90);  // Center position
    dotState[i] = false;
  }
}

void flipDot(int dotIndex, bool targetState) {
  if (dotState[dotIndex] == targetState) return;  // Already in position
  dotState[dotIndex] = targetState;
  targetAngle[dotIndex] = targetState ? 0 : 180;   // 0 = yellow side, 180 = black side
}

void updateAllDots() {
  for (int i = 0; i < NUM_DOTS; i++) {
    if (currentAngle[i] < targetAngle[i]) {
      currentAngle[i] = min(currentAngle[i] + 2, targetAngle[i]);
    } else if (currentAngle[i] > targetAngle[i]) {
      currentAngle[i] = max(currentAngle[i] - 2, targetAngle[i]);
    }

    int pulse = map(currentAngle[i], 0, 180, SERVOMIN, SERVOMAX);
    pwm.setPWM(i, 0, pulse);
  }
}

void displayMessage(const char* msg) {
  // Simple scrolling message: each letter = 4 dots across
  int scrollPos = (millis() / 300) % (NUM_DOTS * 2);
  for (int i = 0; i < NUM_DOTS; i++) {
    bool dotOn = ((i + scrollPos) % 3 == 0);  // Pattern
    flipDot(i, dotOn);
  }
}

void loop() {
  displayMessage("");
  updateAllDots();
  delay(20);
}
Enter fullscreen mode Exit fullscreen mode

Troubleshooting

Problem Cause Fix
PCA9685 not found on I²C scan Wrong address or SDA/SCL wiring Verify address pins; run I²C scanner; check pull-up resistors
LEDs flicker at low brightness PWM frequency too low Set PCA9685 frequency to 1000Hz or higher for LEDs
Servos jitter continuously Servo signal wire too long or noisy Add 100µF capacitor across servo power; keep signal wires short
PCA9685 gets hot LEDs drawing too much current from board Never power LEDs from PCA9685 V+ pin; use separate power supply
Multiple PCA9685 boards conflict Two boards with same address Set unique addresses using A0-A5 pins; do not exceed 62 boards
Only half the LEDs work Channel mapping error PCA9685 has 16 channels; WS2812B uses 3 per RGB pixel; map correctly

Start Here

Affiliate disclosure: As an Amazon Associate, I earn from qualifying purchases.

The right parts make the difference:

PCA9685 16-channel PWM controller — the standard controller for large LED and servo projects. 16 channels, 12-bit resolution, stackable up to 992 channels, I²C interface.

Arduino Nano CH340 — compact breadboard-compatible microcontroller. Powers PCA9685 over I²C for gallery installations and stage lighting.

WS2812B LED strip 5 meters — individually addressable RGB LEDs. Pair with PCA9685 for multi-zone LED art and architectural lighting.

5V 10A power supply — essential for any LED installation with more than 50 WS2812B LEDs. Never power large LED strips from Arduino.

SG90 servo motor pack of 10 — the standard micro servo for kinetic sculptures and flip-dot displays.


Next Step: Interactive Light Design for Your Space

If this guide showed you the potential of multi-channel PWM control — but you need help designing the electronics and interaction logic for your specific installation space — I can help you design that.

I offer a personalized interactive device design guide at Fiverr:

👉 https://www.fiverr.com/phd_hfchang/generate-an-arduino-interactive-prototypef

What you get:

  • Complete electronics design for your LED array or servo array
  • PCA9685 configuration for your specific channel count
  • Interaction logic design (motion sensors, audio input, camera input)
  • Power distribution and safety calculations
  • Testing methodology with pass/fail criteria for each output

Tags: Arduino, PCA9685, PWM, LED art, interactive installation, 16-channel, servo control, WS2812B, gallery lighting, DMX alternative

Top comments (0)