From 6da00439909b13c5fd72067cb31fdd66aef3cd69 Mon Sep 17 00:00:00 2001 From: Maja Stanislawska Date: Tue, 5 Aug 2025 14:48:48 +0100 Subject: [PATCH 1/4] 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) From d5037433a541a518dea431d8b230e9441dfee6a9 Mon Sep 17 00:00:00 2001 From: Maja Stanislawska Date: Tue, 5 Aug 2025 15:40:14 +0100 Subject: [PATCH 2/4] Fix trailing whitespace in PCA9685 module and docs Signed-off-by: Maja Stanislawska --- docs/Config_Reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 3418c731c..2340dcc6a 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -5213,7 +5213,7 @@ sensor_type: ## 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. +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) From d98ed081fffa3aab18ad954bed055adeab148a03 Mon Sep 17 00:00:00 2001 From: Maja Stanislawska Date: Tue, 5 Aug 2025 23:46:49 +0100 Subject: [PATCH 3/4] Python2 compatibility fix in PCA9685 Signed-off-by: Maja Stanislawska --- klippy/extras/pca9685.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/klippy/extras/pca9685.py b/klippy/extras/pca9685.py index 7acd8480a..f6535236b 100644 --- a/klippy/extras/pca9685.py +++ b/klippy/extras/pca9685.py @@ -67,7 +67,7 @@ class PCA9685Controller: 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") + "PCA9685 %s: Frequency out of range"%(self.name,)) self._i2c.i2c_write_wait_ack([0x00, 0x10]) self._i2c.i2c_write_wait_ack([0xFE, prescale]) self._i2c.i2c_write_wait_ack([0x00, 0xA1]) @@ -79,8 +79,8 @@ class PCA9685Controller: 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") + logging.info("PCA9685 %s: Initialized with frequency %s Hz"%( + self.name,self._frequency)) def _set_pwm(self, channel, value): base_reg = 0x06 + (4 * channel) @@ -119,12 +119,12 @@ class PCA9685Controller: 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") + "PCA9685 %s: Only PWM pins supported"%(self.name,)) 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}") + "PCA9685 %s: Invalid pin %s"%(self.name,pin_name)) # Inject frequency and pwm_max into pin_params pin_params['frequency'] = self._frequency pin_params['pwm_max'] = self._pwm_max @@ -145,8 +145,8 @@ class PCA9685PWMPin: 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}") + logging.info("PCA9685 %s:%s: Initialized pin=%s, invert=%s"%( + self._mcu.name,self.name,self._channel,self._invert)) def get_mcu(self): return self._mcu @@ -158,9 +158,10 @@ class PCA9685PWMPin: 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") + logging.warning("PCA9685 %s:%s: " + "Requested cycle_time %s does not match " + "controller frequency %s Hz"%( + self._mcu.name,self.name,cycle_time,self._mcu._frequency)) self._cycle_time = cycle_time self._hardware_pwm = hardware_pwm @@ -176,9 +177,9 @@ class PCA9685PWMPin: 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}") + logging.warning("PCA9685 %s:%s: " + "Cycle time change to %s ignored, using %s"%( + self._mcu.name,self.name,cycle_time,self._cycle_time)) # Apply inversion effective_value = 1. - value if self._invert else value self._set_pwm_value(effective_value) From 39d63c69090b10dc0553c868b2a3b8d2609f3903 Mon Sep 17 00:00:00 2001 From: Maja Stanislawska Date: Thu, 7 Aug 2025 16:24:27 +0100 Subject: [PATCH 4/4] Minor tweaks for PCA9685 module Signed-off-by: Maja Stanislawska --- klippy/extras/pca9685.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/klippy/extras/pca9685.py b/klippy/extras/pca9685.py index f6535236b..0525d41c5 100644 --- a/klippy/extras/pca9685.py +++ b/klippy/extras/pca9685.py @@ -15,6 +15,7 @@ MAX_NOMINAL_DURATION = 3.0 class PCA9685Controller: def __init__(self, config): self._printer = config.get_printer() + self.toolhead = None #set up in _handle_connect self._reactor = self._printer.get_reactor() self.name = config.get_name().split()[-1] self._i2c_addr = config.getint( @@ -28,8 +29,8 @@ class PCA9685Controller: 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) + _ppins = self._printer.lookup_object("pins") + _ppins.register_chip(self.name, self) self._pins = {} self._flush_callbacks = [] self._printer.register_event_handler( @@ -94,14 +95,14 @@ class PCA9685Controller: off_time & 0xFF, # LEDn_OFF_L (off_time >> 8) & 0x0F # LEDn_OFF_H ] - self._i2c.i2c_write_wait_ack(data) + self._i2c.i2c_write(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]) + self._i2c.i2c_write([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]) + self._i2c.i2c_write([base_reg, 0x00, 0x00, 0x00, 0x10]) def print_time_to_clock(self, print_time): return self._mcu.print_time_to_clock(print_time)