Beam Bots PCA9685 servo control
BB.Servo.PCA9685
BB integration for driving RC servos via PCA9685 16-channel PWM controller over I2C.
This library provides a controller and actuator module for controlling RC servos connected to a PCA9685 board.
Installation
Add bb_servo_pca9685 to your list of dependencies in mix.exs:
def deps do
[
{:bb_servo_pca9685, "~> 0.10.0"}
]
end
Requirements
- PCA9685 PWM controller connected via I2C
- BB framework (
~> 0.25)
Usage
Define a controller and joints with servo actuators in your robot DSL:
defmodule MyRobot do
use BB
controllers do
# Define the PCA9685 controller at robot level
controller :pca9685, {BB.Servo.PCA9685.Controller, bus: "i2c-1", address: 0x40}
end
topology do
link :base do
joint :shoulder do
type :revolute
limit lower: ~u(-45 degree),
upper: ~u(45 degree),
effort: ~u(1 newton_meter),
velocity: ~u(60 degree_per_second)
actuator :shoulder_servo, {BB.Servo.PCA9685.Actuator, channel: 0, controller: :pca9685}
sensor :shoulder_feedback,
{BB.Sensor.OpenLoopPositionEstimator, actuator: :shoulder_servo}
link :upper_arm do
joint :elbow do
type :revolute
limit lower: ~u(-90 degree),
upper: ~u(90 degree),
effort: ~u(1 newton_meter),
velocity: ~u(60 degree_per_second)
actuator :elbow_servo, {BB.Servo.PCA9685.Actuator, channel: 1, controller: :pca9685}
sensor :elbow_feedback,
{BB.Sensor.OpenLoopPositionEstimator, actuator: :elbow_servo}
link :forearm
end
end
end
end
end
end
Component names are unique across the whole robot, so each servo and estimator
needs its own name rather than a per-joint :servo.
The actuator automatically derives its configuration from the joint limits - no need to specify servo rotation range or speed separately.
Sending Commands
Use the BB.Actuator module to send commands to servos. The robot must be armed
first — a disarmed robot will not move, and as of bb 0.23 the framework refuses
the command before it reaches the driver.
BB.Actuator.set_position/4 takes either the actuator's unique name or its full
path through the topology, and a :delivery option choosing between two
transports. Both arrive at the driver's handle_command/2, which can't tell them
apart.
Default Delivery (published and acknowledged)
The command is published to [:actuator | path], which is what makes logging,
replay and multi-subscriber patterns possible, and delivered to the actuator by
a call, so the caller learns whether the joint is actually moving:
case BB.Actuator.set_position(MyRobot, :shoulder_servo, 0.5) do
:ok -> :moving
{:error, reason} -> handle_error(reason)
end
# By full path, with a correlation ID for feedback tracking
:ok = BB.Actuator.set_position(MyRobot, [:base, :shoulder, :shoulder_servo], 0.5,
command_id: make_ref())
Direct Delivery (for time-critical control)
Casts to the actuator and publishes nothing, for control paths that can't afford
the round trip. It always returns :ok, so a refusal reaches the log and
[:bb, :actuator, :rejected] telemetry and nowhere else — don't write an error
branch that can never run:
BB.Actuator.set_position(MyRobot, :shoulder_servo, 0.5, delivery: :direct)
Components
Controller
BB.Servo.PCA9685.Controller manages communication with the PCA9685 board.
Define one controller per physical PCA9685 device.
Options:
| Option | Type | Default | Description |
|---|---|---|---|
bus | string | required | I2C bus name (e.g. "i2c-1") |
address | integer | required | I2C address of the PCA9685 (e.g. 0x40) |
pwm_freq | integer | 50 | PWM frequency in Hz |
oe_pin | integer | nil | Optional output-enable GPIO pin |
Actuator
BB.Servo.PCA9685.Actuator controls a single servo on one of the 16 channels.
Options:
| Option | Type | Default | Description |
|---|---|---|---|
channel | 0-15 | required | PCA9685 channel number |
controller | atom | required | Name of the controller in robot registry |
min_pulse | integer | 500 | Minimum PWM pulse width (microseconds) |
max_pulse | integer | 2500 | Maximum PWM pulse width (microseconds) |
To reverse a servo relative to its joint, configure the actuator's joint transmission rather than passing an actuator option:
actuator :shoulder_servo, {BB.Servo.PCA9685.Actuator, channel: 0, controller: :pca9685} do
transmission do
reversed? true
end
end
Behaviour:
- Maps joint position limits directly to PWM range
- Clamps commanded positions to joint limits
- Publishes
BB.Message.Actuator.BeginMotionafter each command - Calculates expected arrival time based on joint velocity limit
Sensor
Use BB.Sensor.OpenLoopPositionEstimator from the BB core library for position
feedback. It subscribes to actuator BeginMotion messages, interpolates position
during movement, and publishes it as BB.Message.Sensor.JointState.
sensor :shoulder_feedback, {BB.Sensor.OpenLoopPositionEstimator, actuator: :shoulder_servo}
Give every servo-driven joint one. BB.Robot.State is written from
JointState messages and from nothing else — commanding a joint doesn't move it
in state — so a joint without an estimator stays at its initial configuration
forever, and forward kinematics, the URDF visualisers and inverse kinematics all
keep working from a robot that never moved. BB warns at compile time about a
joint nothing reports on.
How It Works
Architecture
Controller (GenServer)
|
v wraps
PCA9685.Device (I2C communication)
^
| used by
Actuator (GenServer) --publishes--> BeginMotion --> Sensor (GenServer)
|
v publishes
JointState
Multiple actuators share a single controller. Each actuator controls one of the 16 available channels.
Position Mapping
The actuator maps the joint's position limits to the servo's PWM range:
Joint lower limit -> min_pulse (500 microseconds)
Joint upper limit -> max_pulse (2500 microseconds)
Joint centre -> mid_pulse (1500 microseconds)
For a joint with limits -45 degrees to +45 degrees:
-45 degreesmaps to 500 microseconds0 degreesmaps to 1500 microseconds+45 degreesmaps to 2500 microseconds
Position Feedback
Since RC servos don't provide position feedback, the open-loop position estimator estimates position based on commanded targets and expected arrival times:
- Actuator sends command and publishes
BeginMotionwith expected arrival time - Sensor receives
BeginMotionand interpolates position during movement - After arrival time, sensor reports the target position
- Sensor publishes the estimate as
JointState, which is what writesBB.Robot.State
That last step is why the estimator is part of the wiring rather than an extra: it is the only thing that tells the rest of the framework where an RC servo is.
Motion Lifecycle
When a position command is processed:
- Actuator clamps position to joint limits
- Converts angle to PWM pulse width
- Sends command to controller via
BB.Process.call - Controller writes PWM to the PCA9685 over I2C
- Publishes
BB.Message.Actuator.BeginMotionwith:initial_position- where the servo wastarget_position- where it's goingexpected_arrival- when it should arrive (monotonic milliseconds)command_id- correlation ID (if provided)command_type-:position
Documentation
Full documentation is available at HexDocs.