I2C and the
PCA9685
I2C (Inter-Integrated Circuit, pronounced "I squared C") is a two-wire serial protocol invented by Philips in 1982 that has become the dominant interface for connecting microcontrollers to peripheral chips — sensors, displays, motor controllers, ADCs, DACs, real-time clocks, EEPROMs, and more. Your PCA9685 PWM controller connects over I2C. The protocol is elegant: two wires (SDA = data, SCL = clock), up to 127 devices on the same bus, each addressed by a 7-bit address, master-initiated transactions.
Your bench wiring: GPIO4 = SDA (data), GPIO5 = SCL (clock). The PCA9685 I2C address: 0x40 by default (all address pins A0-A5 tied low). Important: I2C requires pull-up resistors on both SDA and SCL lines. The RP2350's internal pull-ups (nominally 50kΩ) work at 100kHz but the PCA9685 breakout board usually includes 10kΩ pull-ups already — check your board before adding external ones.
I2C BUS STRUCTURE ───────────────────────────────────────────────────────── VDD(3.3V) │ │ 10k 10k ← pull-up resistors (usually on breakout board) │ │ SDA ──┼─────┼──── PCA9685.SDA │ ↑ SCL ──┴─────────── PCA9685.SCL │ RP2350 GPIO4=SDA, GPIO5=SCL I2C TRANSACTION (write to register): START | ADDR(7b) + W(1b) | ACK | REG(8b) | ACK | DATA(8b) | ACK | STOP I2C TRANSACTION (read from register): START | ADDR + W | ACK | REG | ACK | RESTART | ADDR + R | ACK | DATA | NACK | STOP Open-drain bus: devices can only pull LOW. Pull-up resistors bring the line HIGH when no device pulls. This allows multiple masters and safe bus sharing.
The PCA9685 is a 16-channel, 12-bit PWM controller with I2C interface, specifically designed for controlling servos and LEDs. It has an onboard oscillator (default 25MHz), a programmable prescaler that divides the oscillator to set the PWM frequency, and 16 channels each with its own 12-bit ON and OFF counters. The chip can be powered separately from the I2C bus — the LED outputs can drive 3.3V or 5V loads independently of the I2C voltage level.
// Key register addresses (from PCA9685 datasheet Table 4) const MODE1: u8 = 0x00; // Mode register 1: sleep, AI, EXTCLK, ALLCALL const MODE2: u8 = 0x01; // Mode register 2: INVRT, OCH, OUTDRV, OUTNE const PRE_SCALE: u8 = 0xFE; // Prescaler — sets PWM frequency const LED0_ON_L: u8 = 0x06; // Channel 0 ON time low byte (4 regs per channel) // LED1_ON_L = 0x0A, LED2_ON_L = 0x0E, ... LED15_ON_L = 0x42 // ALL_LED_ON_L = 0xFA (control all channels simultaneously) // PWM FREQUENCY FORMULA // osc_clock = 25_000_000 (Hz) — internal oscillator // pwm_freq = osc_clock / (4096 * (prescale + 1)) // prescale = round(osc_clock / (4096 * pwm_freq)) - 1 // // For 50Hz (standard servo frequency): // prescale = round(25_000_000 / (4096 * 50)) - 1 // = round(25_000_000 / 204_800) - 1 // = round(122.07) - 1 // = 122 - 1 = 121 const SERVO_PRESCALE: u8 = 121; // for 50Hz PWM // SERVO PULSE CALCULATION // At 50Hz: period = 20ms = 20,000µs // One PWM step = 20,000µs / 4096 = ~4.88µs per count // Servo pulse range: 1000µs (0°) to 2000µs (180°) // Count for 1000µs = 1000 / 4.88 = 205 counts // Count for 2000µs = 2000 / 4.88 = 410 counts fn angle_to_count(angle_deg: u8) -> u16 { let pulse_us = 1000 + (angle_deg as u32 * 1000 / 180) as u32; (pulse_us * 4096 / 20000) as u16 }
use embassy_rp::i2c::{I2c, Config}; use embassy_time::Timer; pub struct Pca9685<'d> { i2c: I2c<'d, I2C0, embassy_rp::i2c::Async>, addr: u8, } impl<'d> Pca9685<'d> { pub async fn new(mut i2c: I2c<'d,I2C0,embassy_rp::i2c::Async>, addr: u8) -> Result<Self, embassy_rp::i2c::Error> { let mut pca = Self { i2c, addr }; // Reset: MODE1 = 0x10 (SLEEP bit set — required to change prescaler) pca.write_reg(MODE1, 0x10).await?; // Set prescaler — must be done while SLEEP=1 pca.write_reg(PRE_SCALE, SERVO_PRESCALE).await?; // Wake up: clear SLEEP bit, set auto-increment (AI) pca.write_reg(MODE1, 0x20).await?; // 500µs stabilisation after SLEEP clear Timer::after_micros(500).await; Ok(pca) } async fn write_reg(&mut self, reg: u8, val: u8) -> Result<(), embassy_rp::i2c::Error> { self.i2c.write_async(self.addr, &[reg, val]).await } // Set one PWM channel: on_time=0 (start of cycle), off_time=count pub async fn set_channel(&mut self, ch: u8, count: u16) -> Result<(), embassy_rp::i2c::Error> { let base = LED0_ON_L + ch * 4; let on = 0u16; // pulse starts at counter=0 let off = count; // pulse ends at counter=count self.i2c.write_async(self.addr, &[ base, (on & 0xFF) as u8, (on >> 8) as u8, (off & 0xFF) as u8, (off >> 8) as u8, ]).await } pub async fn set_servo_angle(&mut self, ch: u8, angle: u8) -> Result<(), embassy_rp::i2c::Error> { self.set_channel(ch, angle_to_count(angle)).await } } // Usage in a task: // let i2c = I2c::new_async(p.I2C0, p.PIN_5, p.PIN_4, irqs, Config::default()); // let mut pca = Pca9685::new(i2c, 0x40).await.unwrap(); // pca.set_servo_angle(0, 90).await.unwrap(); // Channel 0 to 90°
Sweep servo 0° to 180° displaying angle on TM1637
Write a task that sweeps PCA9685 channel 0 from 0° to 180° in steps of 5°, pausing 50ms at each position. Display the current angle on the TM1637. The servo should sweep smoothly. Verify the sweep completes in approximately 1.8 seconds (36 steps × 50ms). Then add a second channel that sweeps in the opposite direction simultaneously — the two servos should move to complementary angles. This demonstrates driving multiple PCA9685 channels independently.
Detect all I2C devices on the bus
Write a bus scanner: try to write one byte to every 7-bit I2C address from 0x08 to 0x77, and report which addresses respond with ACK. Format the output as a hex table in defmt. Your PCA9685 should appear at 0x40. If you have other I2C devices available (SSD1306 OLED = typically 0x3C or 0x3D, ADS1115 ADC = 0x48), add them to the bus and verify they appear in the scan. Bus scanning is the first tool you use when a device is not responding.