This tutorial shows you how to define a PCA9685 controller and servo-controlled joints in your BB robot.
Prerequisites
- Completed Getting Started
- PCA9685 connected via I2C
- At least one servo connected to channel 0
Defining a Robot with PCA9685 Servos
Create a robot module with a controller and servo-controlled joints:
defmodule MyRobot do
use BB
controllers do
controller :pca9685, {BB.Servo.PCA9685.Controller, bus: "i2c-1", address: 0x40}
end
commands do
command :arm do
handler BB.Command.Arm
allowed_states [:disarmed]
end
command :disarm do
handler BB.Command.Disarm
allowed_states [:idle]
end
end
topology do
link :base do
joint :pan do
type :revolute
limit lower: ~u(-90 degree),
upper: ~u(90 degree),
effort: ~u(1 newton_meter),
velocity: ~u(60 degree_per_second)
actuator :pan_servo, {BB.Servo.PCA9685.Actuator, channel: 0, controller: :pca9685}
sensor :pan_feedback,
{BB.Sensor.OpenLoopPositionEstimator, actuator: :pan_servo}
link :head
end
end
end
endThree sections do the work. controllers holds robot-level components — one
entry per physical PCA9685 board. topology describes the physical structure as
a tree of links and joints, with each servo attached as an actuator inside its
joint. commands declares the arm and disarm commands; a robot starts
:disarmed and won't move until armed, so a robot without them can't be
commanded at all.
The sensor entry is the one part that isn't about hardware. An RC servo
reports nothing back, and BB.Robot.State is written from
BB.Message.Sensor.JointState messages and from nothing else, so without
BB.Sensor.OpenLoopPositionEstimator the joint reads as stuck at its initial
position no matter how much the servo moves. BB warns at compile time about a
joint nothing reports on.
Position Feedback covers how the estimate is produced.
Component names must be unique across the whole robot — BB registers every
process under its name. That's why the actuator is :pan_servo rather than
:servo: the moment you add a second joint, a second :servo fails to compile.
Understanding the Configuration
Controller Options
The controller manages the I2C connection to the PCA9685:
controller :pca9685, {BB.Servo.PCA9685.Controller,
bus: "i2c-1", # Required: I2C bus name
address: 0x40, # Required: I2C address; 0x40 is common for an unmodified board
pwm_freq: 50, # Optional: PWM frequency in Hz (default: 50)
oe_pin: 25 # Optional: GPIO pin for output enable
}bus- The I2C bus device name (usually"i2c-1"on Raspberry Pi)address- The required I2C address of the PCA9685 (often0x40)pwm_freq- PWM frequency, 50 Hz is standard for servosoe_pin- Optional GPIO pin connected to the PCA9685's OE (Output Enable) pin
Joint Limits
The limit entity defines the physical constraints of your joint:
effort- Maximum force or torque (required)velocity- Maximum rotation speed (required, used for timing calculations)lower- Minimum position (maps to servo's minimum pulse)upper- Maximum position (maps to servo's maximum pulse)acceleration- Maximum acceleration; when omitted, motion timing assumes a rectangular velocity profile
These values are used by the actuator to:
- Map positions to PWM pulse widths
- Clamp commanded positions to safe values
- Calculate expected movement duration
An RC servo won't report or obey a torque limit, but effort is required on
every joint, so give it a figure from the servo's datasheet.
Actuator Options
The actuator controls a single servo channel:
actuator :pan_servo, {BB.Servo.PCA9685.Actuator,
channel: 0, # Required: PCA9685 channel (0-15)
controller: :pca9685, # Required: name of the controller
min_pulse: 500, # Optional: minimum pulse width in µs (default: 500)
max_pulse: 2500 # Optional: maximum pulse width in µs (default: 2500)
}Most servos work well with the defaults. Adjust min_pulse and max_pulse if
your servo has different endpoints.
Starting the Robot
Start your robot in your application supervision tree:
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
MyRobot
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
endOr start it manually in IEx:
iex> MyRobot.start_link()
{:ok, #PID<0.123.0>}To try the robot without any hardware attached, start it in simulation:
iex> MyRobot.start_link(simulation: :kinematic)
{:ok, #PID<0.123.0>}Controllers default to simulation: :omit, so the real PCA9685 controller does
not start and no I2C traffic happens. Actuators are swapped for
BB.Sim.Actuator, and the open-loop position estimator from
Position Feedback works unchanged.
Arming the Robot
A robot starts :disarmed and will not move. Arming is a command, not a flag —
run it and wait for the result:
iex> {:ok, command} = MyRobot.arm()
{:ok, #PID<0.234.0>}
iex> BB.Command.await(command)
{:ok, :armed, [next_state: :idle]}Each command you declare in the commands section becomes a function on the
robot module. Disarming works the same way, and pulls the PCA9685's OE pin high
if you've wired one:
iex> {:ok, command} = MyRobot.disarm()
iex> BB.Command.await(command)
{:ok, :disarmed, [next_state: :disarmed]}Drive safety state through these commands rather than calling BB.Safety
directly — going through the command system is what runs a robot's prearm
checks.
Commanding the Servo
With the robot armed, send position commands by actuator name.
set_position/4 publishes the command for observers, waits for the actuator to
take it, and answers :ok or {:error, reason} — so a refusal is something you
find out about rather than assume away:
# Move to centre (0 degrees)
:ok = BB.Actuator.set_position(MyRobot, :pan_servo, 0.0)
# Move to -45 degrees (in radians)
case BB.Actuator.set_position(MyRobot, :pan_servo, -0.785) do
:ok -> :moving
{:error, reason} -> Logger.error(Exception.message(reason))
end
# Using the unit sigil for degrees
import BB.Unit
:ok = BB.Actuator.set_position(MyRobot, :pan_servo, BB.Robot.Units.to_radians(~u(-45 degree)))Note: The DSL takes
~usigil values, but the runtime command functions take plain numbers in SI base units — radians here. Convert withBB.Robot.Units.to_radians/1.
For a control path that can't afford the round trip, delivery: :direct casts
to the actuator and publishes nothing. It always returns :ok, so a refusal
reaches only the log and telemetry:
BB.Actuator.set_position(MyRobot, :pan_servo, -0.785, delivery: :direct)You command joints in joint-space. BB applies the joint's transmission and hands this driver motor-space values, so the driver never does joint-to-motor maths.
Both take either the actuator's unique name or its full path through the
topology ([:base, :pan, :pan_servo] here), and both arrive at the driver's
handle_command/2 — which delivery you chose isn't something the driver can
see.
Position Clamping
The actuator automatically clamps positions to the joint limits:
# Joint limits are -90° to +90°
# This command will be clamped to +90° (π/2 radians)
BB.Actuator.set_position(MyRobot, :pan_servo, 3.14) # Requested: 180°, actual: 90°Reversing Direction
If your servo rotates in the opposite direction to the joint, reverse the actuator's joint transmission:
actuator :pan_servo, {BB.Servo.PCA9685.Actuator,
channel: 0,
controller: :pca9685
} do
transmission do
reversed? true
end
endThe driver continues to map motor-space limits to PWM pulse widths; the transmission reverses the mapping between joint space and motor space.
Example: Pan-Tilt Head
Here's a complete example with two servos for a pan-tilt mechanism:
defmodule PanTiltRobot do
use BB
controllers do
controller :pca9685, {BB.Servo.PCA9685.Controller, bus: "i2c-1", address: 0x40}
end
commands do
command :arm do
handler BB.Command.Arm
allowed_states [:disarmed]
end
command :disarm do
handler BB.Command.Disarm
allowed_states [:idle]
end
end
topology do
link :base do
joint :pan do
type :revolute
limit lower: ~u(-90 degree),
upper: ~u(90 degree),
effort: ~u(1 newton_meter),
velocity: ~u(90 degree_per_second)
actuator :pan_servo, {BB.Servo.PCA9685.Actuator, channel: 0, controller: :pca9685}
sensor :pan_feedback,
{BB.Sensor.OpenLoopPositionEstimator, actuator: :pan_servo}
link :pan_platform do
joint :tilt do
type :revolute
limit lower: ~u(-45 degree),
upper: ~u(45 degree),
effort: ~u(1 newton_meter),
velocity: ~u(60 degree_per_second)
actuator :tilt_servo, {BB.Servo.PCA9685.Actuator, channel: 1, controller: :pca9685}
sensor :tilt_feedback,
{BB.Sensor.OpenLoopPositionEstimator, actuator: :tilt_servo}
link :camera_mount
end
end
end
end
end
endNote that each servo and estimator has its own name. Naming both :servo is the
most common way to get a compile error here — names are global, not scoped to
their joint.
Command both servos:
{:ok, command} = PanTiltRobot.arm()
{:ok, :armed, _} = BB.Command.await(command)
# Look left and up
:ok = BB.Actuator.set_position(PanTiltRobot, :pan_servo, -0.785) # -45°
:ok = BB.Actuator.set_position(PanTiltRobot, :tilt_servo, 0.524) # +30°Example: Hexapod Leg (6 Servos)
The PCA9685's 16 channels make it ideal for multi-servo robots:
defmodule HexapodLeg do
use BB
controllers do
controller :pca9685, {BB.Servo.PCA9685.Controller, bus: "i2c-1", address: 0x40}
end
commands do
command :arm do
handler BB.Command.Arm
allowed_states [:disarmed]
end
command :disarm do
handler BB.Command.Disarm
allowed_states [:idle]
end
end
topology do
link :body do
# Leg 1
joint :leg1_coxa do
type :revolute
limit lower: ~u(-45 degree),
upper: ~u(45 degree),
effort: ~u(1 newton_meter),
velocity: ~u(90 degree_per_second)
actuator :leg1_coxa_servo, {BB.Servo.PCA9685.Actuator, channel: 0, controller: :pca9685}
sensor :leg1_coxa_feedback,
{BB.Sensor.OpenLoopPositionEstimator, actuator: :leg1_coxa_servo}
link :leg1_coxa_link do
joint :leg1_femur do
type :revolute
limit lower: ~u(-90 degree),
upper: ~u(30 degree),
effort: ~u(1 newton_meter),
velocity: ~u(90 degree_per_second)
actuator :leg1_femur_servo,
{BB.Servo.PCA9685.Actuator, channel: 1, controller: :pca9685}
sensor :leg1_femur_feedback,
{BB.Sensor.OpenLoopPositionEstimator, actuator: :leg1_femur_servo}
link :leg1_femur_link do
joint :leg1_tibia do
type :revolute
limit lower: ~u(-120 degree),
upper: ~u(0 degree),
effort: ~u(1 newton_meter),
velocity: ~u(90 degree_per_second)
actuator :leg1_tibia_servo,
{BB.Servo.PCA9685.Actuator, channel: 2, controller: :pca9685}
sensor :leg1_tibia_feedback,
{BB.Sensor.OpenLoopPositionEstimator, actuator: :leg1_tibia_servo}
link :leg1_foot
end
end
end
end
end
# Leg 2 uses channels 3, 4, 5
# Leg 3 uses channels 6, 7, 8
# ... and so on
end
end
endWith sixteen channels to name, a <leg>_<segment>_servo convention keeps every
actuator name unique without much thought.
Multiple PCA9685 Boards
For robots with more than 16 servos, define multiple controllers:
defmodule BigRobot do
use BB
controllers do
# First board at its unmodified hardware address
controller :pca9685_a, {BB.Servo.PCA9685.Controller, bus: "i2c-1", address: 0x40}
# Second board with A0 jumper set
controller :pca9685_b, {BB.Servo.PCA9685.Controller, bus: "i2c-1", address: 0x41}
end
commands do
command :arm do
handler BB.Command.Arm
allowed_states [:disarmed]
end
command :disarm do
handler BB.Command.Disarm
allowed_states [:idle]
end
end
topology do
link :base do
# First 16 servos use :pca9685_a
joint :joint_0 do
type :revolute
limit lower: ~u(-90 degree),
upper: ~u(90 degree),
effort: ~u(1 newton_meter),
velocity: ~u(60 degree_per_second)
actuator :servo_0, {BB.Servo.PCA9685.Actuator, channel: 0, controller: :pca9685_a}
sensor :servo_0_feedback, {BB.Sensor.OpenLoopPositionEstimator, actuator: :servo_0}
link :link_0
end
# Servos 17+ use :pca9685_b
joint :joint_16 do
type :revolute
limit lower: ~u(-90 degree),
upper: ~u(90 degree),
effort: ~u(1 newton_meter),
velocity: ~u(60 degree_per_second)
actuator :servo_16, {BB.Servo.PCA9685.Actuator, channel: 0, controller: :pca9685_b}
sensor :servo_16_feedback, {BB.Sensor.OpenLoopPositionEstimator, actuator: :servo_16}
link :link_16
end
end
end
endBoth actuators sit on channel 0 — of different boards. It's the controller:
option that picks the board, and the component names that must differ.
Output Enable Control
If you've connected the PCA9685's OE pin to a GPIO, you can enable/disable all outputs:
controller :pca9685, {BB.Servo.PCA9685.Controller,
bus: "i2c-1",
address: 0x40,
oe_pin: 25 # GPIO 25 connected to OE
}Control outputs via the controller:
# Disable all servo outputs (servos go limp)
BB.Process.call(MyRobot, :pca9685, :output_disable)
# Re-enable outputs
BB.Process.call(MyRobot, :pca9685, :output_enable)Without an oe_pin configured, both calls return
{:error, %BB.Error.Hardware.NoOutputEnablePin{}}.
This is useful for:
- Emergency stops
- Allowing manual positioning of servos
- Reducing power consumption when idle
Disarming pulls OE high for you, and a clean controller shutdown does the same
in the device's terminate/2. That makes oe_pin the only kill that survives a
dead controller process — both the actuator's disarm and the controller's own
route through the live controller, so if it has crashed, the disarm fails and
the robot enters :error.
Next Steps
To get position feedback from your servos, see Position Feedback.