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
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);
}
}
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
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);
}
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
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);
}
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);
}
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);
}
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);
}
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)