Add PCA9685 PWM controller support

Implements support for the PCA9685 16-channel PWM controller over I2C, enabling [servo] and [output_pin] configurations for PWM and digital outputs. Supports configurable frequency (default 50Hz), I2C addressing, global MODE2 settings (invert_output, totem_pole), and per-pin software inversion. Designed primarily for applications like pick-and-place machines with servo-driven component feeders. Includes documentation and example configuration.

Signed-off-by: Maja Stanislawska <maja@makershop.ie>
This commit is contained in:
Maja Stanislawska 2025-08-05 14:48:48 +01:00
parent 5eb07966b5
commit 6da0043990
3 changed files with 255 additions and 0 deletions

View file

@ -0,0 +1,19 @@
[pca9685 pca9685_1]
i2c_address: 64 #0x40
i2c_mcu: mcu
i2c_bus: i2c1
frequency: 50
invert_output: False # Global hardware inversion (MODE2 INVRT)
totem_pole: True # Use totem-pole output (MODE2 OUTDRV)
[servo my_servo]
pin: pca9685_1:pwm0
maximum_servo_angle: 180
minimum_pulse_width: 0.001
maximum_pulse_width: 0.002
invert: False
[output_pin my_pin]
pin: pca9685_1:pwm15
pwm: True
invert: True

View file

@ -5210,6 +5210,32 @@ sensor_type:
## Board specific hardware support
## PCA9685 PWM Controller
The `[pca9685]` module enables a PCA9685 16-channel PWM controller over I2C, providing 16 virtual PWM pins for use with `[servo]` and `[output_pin]` modules.
This is useful for driving servos, LEDs, or other PWM devices like component feeders.
I2C is slow so those pins shouldn't be used to run anything time critical like driving stepper motors.
See the [example-pca9685.cfg](../config/example-pca9685.cfg)
file for an example.
```
[pca9685 my_expander]
i2c_address: 64
# default 64 (0x40), range: 64-127 (0x40-0x7F) depending on addr pins.
#i2c_mcu: mcu
#i2c_bus: i2c1
# See the "common I2C settings" section for a description of the
# above parameters.
#frequency: 50.0
# PWM frequency in Hz (default: 50.0, range: 1.0-1000.0).
# Use `50` for standard servos.
#invert_output: False
# Boolean (default: `False`). Sets MODE2 INVRT bit
# to invert all PWM outputs globally in hardware.
#totem_pole: True
# Boolean (default: `True`). Sets MODE2 OUTDRV bit for totem-pole output; if `False`, uses open-drain.
```
### [sx1509]
Configure an SX1509 I2C to GPIO expander. Due to the delay incurred by

210
klippy/extras/pca9685.py Normal file
View file

@ -0,0 +1,210 @@
# pca9685.py
# PCA9685 PWM controller support for Klipper, providing virtual PWM pins
# Inspired by extras/servo.py, extras/output_pin.py, and klippy/mcu.py
#
# Copyright (C) 2025 Maja Stanislawska (maja@makershop.ie)
#
# This file may be distributed under the terms of the GNU GPLv3 license.
from . import bus
import logging
MIN_SCHEDULE_TIME = 0.100
MAX_NOMINAL_DURATION = 3.0
class PCA9685Controller:
def __init__(self, config):
self._printer = config.get_printer()
self._reactor = self._printer.get_reactor()
self.name = config.get_name().split()[-1]
self._i2c_addr = config.getint(
'i2c_address', 0x40, minval=0x40, maxval=0x7F)
self._frequency = config.getfloat(
'frequency', 50.0, minval=1.0, maxval=1000.0)
self._pwm_max = 4096.0 #used in few places to clamp output
self._invert_output = config.getboolean('invert_output', False)
self._totem_pole = config.getboolean('totem_pole', True)
# Initialize I2C
self._i2c = bus.MCU_I2C_from_config(config, self._i2c_addr)
self._mcu = self._i2c.get_mcu()
self._printer.add_object(self.name, self)
self._ppins = self._printer.lookup_object("pins")
self._ppins.register_chip(self.name, self)
self._pins = {}
self._flush_callbacks = []
self._printer.register_event_handler(
"klippy:connect", self._handle_connect)
self._printer.register_event_handler(
"klippy:shutdown", self._handle_shutdown)
def _handle_connect(self):
self._init_pca9685()
# Set initial values for registered pins
for pin in self._pins.values():
pin._set_pwm_value(pin._start_value)
#inject yourself into tooolheads mcu list
self.toolhead = self._printer.lookup_object('toolhead')
self.toolhead.all_mcus.append(self)
def _handle_shutdown(self):
for pin in self._pins.values():
pin._set_pwm_value(pin._shutdown_value)
def min_schedule_time(self):
return MIN_SCHEDULE_TIME
def max_nominal_duration(self):
return MAX_NOMINAL_DURATION
def check_active(self, print_time, eventtime):
return
def _init_pca9685(self):
# Full reset: MODE1 = 0x01 (ALLCALL, no SLEEP, restart enabled)
self._i2c.i2c_write_wait_ack([0x00, 0x01])
self._reactor.pause(self._reactor.monotonic() + .01)
# Set frequency
prescale = round(25000000.0 / (4096.0 * self._frequency)) - 1
if prescale < 3 or prescale > 255:
raise self._printer.config_error(
f"PCA9685 {self.name}: Frequency out of range")
self._i2c.i2c_write_wait_ack([0x00, 0x10])
self._i2c.i2c_write_wait_ack([0xFE, prescale])
self._i2c.i2c_write_wait_ack([0x00, 0xA1])
# Set MODE2: INVRT and OUTDRV from config
mode2_value = 0
if self._invert_output:
mode2_value |= 0x10 # INVRT=1
if self._totem_pole:
mode2_value |= 0x04 # OUTDRV=1
self._i2c.i2c_write_wait_ack([0x01, mode2_value])
self._reactor.pause(self._reactor.monotonic() + .01)
logging.info(f"PCA9685 {self.name}: "
f"Initialized with frequency {self._frequency} Hz")
def _set_pwm(self, channel, value):
base_reg = 0x06 + (4 * channel)
# no need for fancy phase-shifting PWM
on_time = 0 # Standard for servos
off_time = int(min(max(value, 0), self._pwm_max))
on_time = 0
data = [ base_reg,
on_time & 0xFF, # LEDn_ON_L
(on_time >> 8) & 0x0F, # LEDn_ON_H
off_time & 0xFF, # LEDn_OFF_L
(off_time >> 8) & 0x0F # LEDn_OFF_H
]
self._i2c.i2c_write_wait_ack(data)
def _set_digital(self, channel, value):
base_reg = 0x06 + (4 * channel)
if value: # Fully on: LEDn_ON_H bit 4 (0x10)
self._i2c.i2c_write_wait_ack([base_reg, 0x00, 0x10, 0x00, 0x00])
else: # Fully off: LEDn_OFF_H bit 4 (0x10)
self._i2c.i2c_write_wait_ack([base_reg, 0x00, 0x00, 0x00, 0x10])
def print_time_to_clock(self, print_time):
return self._mcu.print_time_to_clock(print_time)
def register_flush_callback(self, callback):
self._flush_callbacks.append(callback)
def flush_moves(self, flush_time, clear_history_time):
clock = self.print_time_to_clock(flush_time)
if clock < 0:
return
for cb in self._flush_callbacks:
cb(flush_time, clock)
def setup_pin(self, pin_type, pin_params):
if pin_type != 'pwm':
raise self._printer.config_error(
f"PCA9685 {self.name}: Only PWM pins supported")
pin_name = pin_params['pin']
channel = int(pin_name.split('pwm')[-1])
if channel < 0 or channel > 15:
raise self._printer.config_error(
f"PCA9685 {self.name}: Invalid pin {pin_name}")
# Inject frequency and pwm_max into pin_params
pin_params['frequency'] = self._frequency
pin_params['pwm_max'] = self._pwm_max
pin = PCA9685PWMPin(self, pin_params)
self._pins[pin_name] = pin
return pin
class PCA9685PWMPin:
def __init__(self, mcu, pin_params):
self.name = pin_params['pin']
self._mcu = mcu
self._channel = int(self.name.split('pwm')[-1])
self._cycle_time = 1. / pin_params['frequency']
self._pwm_max = pin_params['pwm_max']
self._invert = pin_params['invert'] # Per-pin invert from config
self._last_value = 0.
self._start_value = self._shutdown_value = float(self._invert)
self._max_duration = 0.
self._hardware_pwm = False
self._last_time = 0
logging.info(f"PCA9685 {self._mcu.name}:{self.name}: "
f"Initialized pin={self._channel}, invert={self._invert}")
def get_mcu(self):
return self._mcu
def setup_max_duration(self, max_duration):
#not really used, just for compatibility
self._max_duration = max_duration
def setup_cycle_time(self, cycle_time, hardware_pwm=False):
expected_cycle_time = 1. / self._mcu._frequency
if abs(cycle_time - expected_cycle_time) > 0.000001:
logging.warning(f"PCA9685 {self._mcu.name}:pwm{self._channel}: "
f"Requested cycle_time {cycle_time} does not match "
f"controller frequency {self._mcu._frequency} Hz")
self._cycle_time = cycle_time
self._hardware_pwm = hardware_pwm
def setup_start_value(self, start_value, shutdown_value):
self._start_value = max(0., min(1., start_value))
self._shutdown_value = max(0., min(1., shutdown_value))
def _apply_start_value(self):
# Apply inversion to start value
value = 1. - self._start_value if self._invert else self._start_value
self._set_pwm_value(value)
def set_pwm(self, print_time, value, cycle_time=None):
if (cycle_time is not None and
abs(cycle_time - self._cycle_time) > 0.000001):
logging.warning(f"PCA9685 {self._mcu.name}:pwm{self._channel}: "
f"Cycle time change to {cycle_time} ignored, "
f"using {self._cycle_time}")
# Apply inversion
effective_value = 1. - value if self._invert else value
self._set_pwm_value(effective_value)
self._last_value = value
def set_digital(self, print_time, value):
# Invert digital value if needed
effective_value = not value if self._invert else bool(value)
self._mcu._set_digital(self._channel, effective_value)
self._last_value = 1.0 if value else 0.0
def _set_pwm_value(self, value):
# Convert Klipper's 0-1 value to PCA9685 counts
pulse_width = value * self._cycle_time
pwm_counts = int(pulse_width * self._mcu._frequency * self._pwm_max)
self._mcu._set_pwm(self._channel, pwm_counts)
def get_status(self, eventtime):
return {
'value': self._last_value,
# 'cycle_time': self._cycle_time,
# 'start_value': self._start_value,
# 'shutdown_value': self._shutdown_value,
# 'max_duration': self._max_duration,
# 'invert': self._invert
}
def load_config_prefix(config):
return PCA9685Controller(config)