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
end

Three 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 (often 0x40)
  • pwm_freq - PWM frequency, 50 Hz is standard for servos
  • oe_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:

  1. Map positions to PWM pulse widths
  2. Clamp commanded positions to safe values
  3. 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
end

Or 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 ~u sigil values, but the runtime command functions take plain numbers in SI base units — radians here. Convert with BB.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
end

The 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
end

Note 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
end

With 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
end

Both 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.