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}
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.
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 via pubsub, set_position!/4 fires and forgets, and
set_position_sync/5 waits for the actuator to acknowledge:
# Move to centre (0 degrees)
BB.Actuator.set_position(MyRobot, :pan_servo, 0.0)
# Move to -45 degrees (in radians)
BB.Actuator.set_position!(MyRobot, :pan_servo, -0.785)
# Wait for acknowledgement
{:ok, :accepted} = BB.Actuator.set_position_sync(MyRobot, :pan_servo, -0.785)
# Using the unit sigil for degrees
import BB.Unit
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.
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.
All three take either the actuator's unique name or its full path through the
topology ([:base, :pan, :pan_servo] here), and all three arrive at the
driver's handle_command/2 — which transport 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}
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}
link :camera_mount
end
end
end
end
end
endNote that each servo 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
BB.Actuator.set_position!(PanTiltRobot, :pan_servo, -0.785) # -45°
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}
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}
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 actuator 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.