USB-C Power Delivery with ESP32: A Practical Guide
How to implement USB-C PD in your embedded projects - request any voltage from 5V to 20V using FUSB302 and ESP32

USB-C Power Delivery with ESP32: A Practical Guide
USB-C Power Delivery changed how I think about project power supplies.
Before: Every project needed a specific wall adapter. 5V here, 12V there, 19V for that motor driver. My bench looked like a cable graveyard.
After: One USB-C charger. One cable. I negotiate whatever voltage the project needs. Up to 100W from a phone charger.
This guide covers everything I learned implementing USB-C PD on Spark Analyzer - from the protocol basics to working code.
Why USB-C PD for Embedded Projects?
Traditional approaches to powering embedded projects have annoying limitations:
| Old Way | Problem |
|---|---|
| Barrel jack + wall adapter | Need the exact adapter, they disappear |
| USB (5V only) | Limited to 5V, 2.5W max from USB 2.0 |
| Direct battery | Fixed voltage, no regulation |
| Bench supply | Not portable, expensive |
USB-C PD solves this:
- Voltage flexibility: 5V, 9V, 12V, 15V, 20V (or programmable with PPS)
- Power capacity: Up to 100W (20V @ 5A)
- Universal availability: Same charger as your phone and laptop
- Reversible connector: No more upside-down plug attempts
- Standard protocol: Works with any PD-capable charger
Spark Analyzer uses USB-C PD to get 20V for stepper motors from a laptop charger. The same board also works fine with a phone charger at 5V for low-power operation.
Understanding the Protocol
USB-C PD communication happens over the CC (Configuration Channel) line. Your device (the "sink") talks to the charger (the "source") using a specific message protocol.
The Basic Handshake
- Attach: You plug in the cable. CC line resistors identify device capabilities.
- Source Capabilities: Charger advertises what it can provide (voltages, currents).
- Request: Your device asks for a specific voltage/current combination.
- Accept/Reject: Charger confirms or denies the request.
- PS_Ready: Charger signals that output voltage has stabilized.
- Power flows: You've got your requested voltage.
All of this happens in under 500ms.
What Your Charger Can Provide
A typical USB-C PD charger might advertise:
PDO 1: 5V @ 3A (15W) - Default, always available
PDO 2: 9V @ 2A (18W)
PDO 3: 12V @ 1.5A (18W)
PDO 4: 15V @ 1.2A (18W)
PDO 5: 20V @ 1A (20W)
PDO = Power Data Object. Each one is a voltage/current combination the charger supports.
Some chargers also support PPS (Programmable Power Supply), letting you request specific voltages in 20mV increments. This is great for battery charging or precise voltage control.
Hardware Requirements
The FUSB302 PD Controller
You could bit-bang the PD protocol, but trust me, you don't want to. The timing requirements are tight (responses needed within 15ms), and the message encoding is non-trivial.
The FUSB302 (specifically FUSB302MPX) handles all the hard parts:
- CC line monitoring and communication
- PD message encoding/decoding
- BMC (Biphase Mark Coding) physical layer
- VBUS voltage detection
You just talk to it over I2C.
Minimal Schematic
Here's what you need:
USB-C Connector
│
├── VBUS ────┬──── To voltage regulator/load
│ │
│ (Power path control optional)
│
├── GND ─────┴──── Ground
│
├── CC1 ──────────┬─── FUSB302 CC
│ │
├── CC2 ──────────┘ (FUSB302 handles orientation)
│
└── D+/D- ─────────── Not needed for power-only
FUSB302MPX
│
├── SDA ──────────── ESP32 I2C SDA (4.7kΩ pull-up)
├── SCL ──────────── ESP32 I2C SCL (4.7kΩ pull-up)
├── INT ──────────── ESP32 GPIO (interrupt)
└── VDD ──────────── 3.3V
Important notes:
- CC lines need 5.1kΩ pull-down resistors for sink identification (FUSB302 can provide these internally)
- I2C pull-ups should be 4.7kΩ
- INT line for faster response (polling works but is slower)
PCB Layout Tips
USB-C signal integrity matters less for power-only applications, but good habits help:
- CC traces: Keep short, matched length if possible
- VBUS traces: Size for expected current (1oz copper needs ~20mil width per amp)
- Decoupling: 100nF ceramic on FUSB302 VDD, as close as possible
- Ground plane: Solid plane under the entire circuit
Firmware Implementation
I'll show you a working implementation using ESP32-C3 and ESP-IDF, but the concepts apply to any platform.
Setting Up I2C
#include "driver/i2c.h"
#define FUSB302_ADDR 0x22
#define I2C_PORT I2C_NUM_0
void initI2C() {
i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = SDA_PIN,
.scl_io_num = SCL_PIN,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master.clk_speed = 400000 // 400kHz
};
i2c_param_config(I2C_PORT, &conf);
i2c_driver_install(I2C_PORT, conf.mode, 0, 0, 0);
}Basic FUSB302 Register Access
uint8_t fusb302_read(uint8_t reg) {
uint8_t data;
i2c_master_write_read_device(I2C_PORT, FUSB302_ADDR,
®, 1, &data, 1,
pdMS_TO_TICKS(100));
return data;
}
void fusb302_write(uint8_t reg, uint8_t data) {
uint8_t buf[2] = {reg, data};
i2c_master_write_to_device(I2C_PORT, FUSB302_ADDR,
buf, 2,
pdMS_TO_TICKS(100));
}
// Verify FUSB302 is connected
bool fusb302_detect() {
uint8_t device_id = fusb302_read(0x01); // Device ID register
return (device_id & 0xF0) == 0x90; // FUSB302 ID
}Initializing for PD Sink Mode
void fusb302_init_sink() {
// Reset the chip
fusb302_write(0x0C, 0x01); // RESET register
vTaskDelay(pdMS_TO_TICKS(10));
// Power on internal oscillator and enable receive
fusb302_write(0x0B, 0x0F); // POWER register
// Enable CC1 and CC2 with pull-downs (sink mode)
fusb302_write(0x02, 0x07); // SWITCHES0 - enable CC1, CC2, measure CC
// Enable auto-GoodCRC response
fusb302_write(0x03, 0x04); // SWITCHES1
// Enable interrupts for messages
fusb302_write(0x0E, 0x00); // MASK - unmask all interrupts
fusb302_write(0x0F, 0x00); // MASKA
fusb302_write(0x10, 0x01); // MASKB
// Reset PD logic
fusb302_write(0x0C, 0x02); // RESET - reset PD logic
}The PD State Machine
Here's a simplified state machine. Production code needs more error handling, but this shows the flow:
typedef enum {
PD_STATE_UNATTACHED,
PD_STATE_ATTACHED,
PD_STATE_WAIT_CAPS,
PD_STATE_REQUESTING,
PD_STATE_READY,
PD_STATE_ERROR
} pd_state_t;
pd_state_t pd_state = PD_STATE_UNATTACHED;
uint32_t source_caps[7]; // Up to 7 PDOs
uint8_t num_pdos = 0;
void pd_run() {
switch (pd_state) {
case PD_STATE_UNATTACHED:
if (check_attach()) {
pd_state = PD_STATE_ATTACHED;
printf("USB-C attached, waiting for Source Capabilities...\n");
}
break;
case PD_STATE_ATTACHED:
// Enable message reception
fusb302_write(0x03, 0x25); // Enable receive
pd_state = PD_STATE_WAIT_CAPS;
break;
case PD_STATE_WAIT_CAPS:
if (check_message_received()) {
parse_source_capabilities();
pd_state = PD_STATE_REQUESTING;
}
break;
case PD_STATE_REQUESTING:
// Request the voltage we want
if (request_voltage(20)) { // Request 20V
pd_state = PD_STATE_READY;
printf("20V negotiated successfully!\n");
} else {
printf("Request failed, falling back to 5V\n");
pd_state = PD_STATE_READY;
}
break;
case PD_STATE_READY:
// Power is available at requested voltage
// Monitor for disconnection or hard reset
if (!check_attach()) {
pd_state = PD_STATE_UNATTACHED;
}
break;
}
}Parsing Source Capabilities
When the charger sends its capabilities, you need to parse the PDOs:
typedef struct {
uint8_t type; // 0=Fixed, 1=Battery, 2=Variable, 3=PPS
uint16_t voltage; // mV
uint16_t current; // mA
} pdo_t;
pdo_t available_pdos[7];
void parse_source_capabilities() {
// Read message from FUSB302 FIFO
uint8_t header[2];
fusb302_read_fifo(header, 2);
num_pdos = (header[0] >> 4) & 0x07; // Number of data objects
for (int i = 0; i < num_pdos; i++) {
uint32_t pdo;
fusb302_read_fifo((uint8_t*)&pdo, 4);
available_pdos[i].type = (pdo >> 30) & 0x03;
if (available_pdos[i].type == 0) { // Fixed supply
available_pdos[i].voltage = ((pdo >> 10) & 0x3FF) * 50; // 50mV units
available_pdos[i].current = (pdo & 0x3FF) * 10; // 10mA units
printf("PDO %d: %dmV @ %dmA\n",
i+1, available_pdos[i].voltage, available_pdos[i].current);
}
}
}Requesting a Specific Voltage
bool request_voltage(uint16_t target_voltage_v) {
// Find matching PDO
int pdo_index = -1;
uint16_t target_mv = target_voltage_v * 1000;
for (int i = 0; i < num_pdos; i++) {
if (available_pdos[i].voltage == target_mv) {
pdo_index = i;
break;
}
}
if (pdo_index < 0) {
printf("Requested voltage not available\n");
return false;
}
// Build request message
uint32_t request = 0;
request |= (pdo_index + 1) << 28; // Object position (1-indexed)
request |= (available_pdos[pdo_index].current / 10) << 10; // Operating current
request |= (available_pdos[pdo_index].current / 10); // Max current
// Send request
send_pd_message(0x02, &request, 1); // Message type 0x02 = Request
// Wait for Accept
if (!wait_for_accept(500)) {
return false;
}
// Wait for PS_Ready (voltage has stabilized)
if (!wait_for_ps_ready(1000)) {
return false;
}
return true;
}Safety Considerations
USB-C PD can deliver serious power. 20V at 5A is 100W - enough to damage components or start fires if things go wrong.
Overvoltage Protection
Always add overvoltage protection on your input:
// Monitor VBUS voltage via ADC
#define VBUS_ADC_PIN 4
#define VBUS_DIVIDER_RATIO 11.0 // 100k/10k divider
float read_vbus_voltage() {
int raw = analogRead(VBUS_ADC_PIN);
return (raw * 3.3 / 4095.0) * VBUS_DIVIDER_RATIO;
}
// In main loop
float vbus = read_vbus_voltage();
if (vbus > 22.0) { // 10% over max expected
// EMERGENCY: Disable power path
digitalWrite(POWER_ENABLE_PIN, LOW);
printf("OVERVOLTAGE! VBUS = %.1fV\n", vbus);
}Better yet, use a hardware overvoltage protection circuit (TVS diode + fuse).
Overcurrent Protection
For the load side, current sensing helps prevent damage:
// Using INA228 or similar for current monitoring
if (current_ma > 3500) { // 3.5A limit
disable_output();
printf("OVERCURRENT! I = %dmA\n", current_ma);
}Fault Recovery
Things will go wrong. Have a recovery strategy:
void handle_pd_error() {
// Try soft reset first
send_pd_message(0x0D, NULL, 0); // Soft Reset
vTaskDelay(pdMS_TO_TICKS(100));
if (!check_attach()) {
// Hard reset - full reinitialization
fusb302_init_sink();
pd_state = PD_STATE_UNATTACHED;
}
}Testing Your Implementation
Equipment You'll Need
- USB-C PD charger (phone charger works for basic testing)
- Multimeter (must-have)
- USB-C breakout board (helpful for probing)
- Logic analyzer (nice-to-have for protocol debugging)
Step-by-Step Bringup
-
Verify I2C communication
Read FUSB302 Device ID register (0x01) Expected: 0x90 or 0x91 -
Check CC detection
With cable connected, read FUSB302 status registers Verify CC line activity -
Monitor Source Capabilities
Print received PDOs to serial Compare with charger specifications -
Request 5V (default)
Should succeed with any PD charger Measure VBUS with multimeter -
Request higher voltages
Try 9V, 12V, etc. Verify voltage switches correctly
Common Problems
"I2C not responding"
- Check I2C address (0x22 for FUSB302)
- Verify pull-up resistors present
- Check VDD power to FUSB302
"No Source Capabilities received"
- CC line resistors correct?
- Is the charger actually PD-capable?
- Check CC line routing
"Request always rejected"
- Are you requesting a voltage the charger supports?
- Check your request message format
- Verify timing (respond within 30ms)
Real-World Application: Spark Analyzer
Spark Analyzer uses this exact implementation to provide:
- Automatic voltage selection: Web interface lets you choose 5V, 9V, 12V, 15V, or 20V
- Real-time power monitoring: INA228 measures actual delivered power
- PPS support: Fine voltage control in 20mV steps for compatible chargers
- Safety shutdown: Automatic cutoff on overvoltage, overcurrent, or thermal events
The result is a versatile power source that works with any USB-C charger - from a phone brick for benchtop testing to a laptop charger for driving stepper motors.
What's Next
Once you've got basic PD working:
- Add PPS support for programmable voltage control
- Implement dual-role (source and sink) for more complex applications
- Add USB data if you need communication alongside power
- Build a power analyzer to see exactly what's happening during negotiation
The Spark Analyzer GitHub repo has the complete implementation including PPS support and the web interface.
Stuck on USB-C PD implementation? Share your issue in the contact form and I'll help troubleshoot.