mirror of
https://github.com/Klipper3d/klipper.git
synced 2025-12-31 20:00:27 -07:00
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:
parent
5eb07966b5
commit
6da0043990
3 changed files with 255 additions and 0 deletions
19
config/example-pca9685.cfg
Normal file
19
config/example-pca9685.cfg
Normal 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
|
||||
|
|
@ -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
210
klippy/extras/pca9685.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue