From 6da00439909b13c5fd72067cb31fdd66aef3cd69 Mon Sep 17 00:00:00 2001 From: Maja Stanislawska Date: Tue, 5 Aug 2025 14:48:48 +0100 Subject: [PATCH] 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 --- config/example-pca9685.cfg | 19 ++++ docs/Config_Reference.md | 26 +++++ klippy/extras/pca9685.py | 210 +++++++++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 config/example-pca9685.cfg create mode 100644 klippy/extras/pca9685.py diff --git a/config/example-pca9685.cfg b/config/example-pca9685.cfg new file mode 100644 index 000000000..c5b080617 --- /dev/null +++ b/config/example-pca9685.cfg @@ -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 diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index a9b6db6ce..3418c731c 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -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 diff --git a/klippy/extras/pca9685.py b/klippy/extras/pca9685.py new file mode 100644 index 000000000..7acd8480a --- /dev/null +++ b/klippy/extras/pca9685.py @@ -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)