Part III — Hardware·Chapter 9

I2C and the
PCA9685

I2C is the protocol that connects microcontrollers to the world of smart peripherals. The PCA9685 is your first serious peripheral: a 16-channel 12-bit PWM controller with a rich register map. Understanding its register map, the prescaler arithmetic, and Embassy's async I2C API gives you the foundation for every future I2C device you will encounter.
§ 9.1
I2C — The Protocol That Connects Chips

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.
Figure 9.1 — I2C bus topology and transaction format.
§ 9.2
PCA9685 Internals — The Register Map

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.

pca9685_registers.rs — register map and arithmetic
// 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
}
§ 9.3
The Driver Implementation
pca9685.rs — layered driver with Embassy async I2C
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°
§ 9.4
Exercises
Exercise 9.1 — Servo Sweep with Angle Display

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.

Exercise 9.2 — I2C Bus Scan

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.