Current section

Files

Jump to
Raw

README.md

<!--
SPDX-FileCopyrightText: 2025 James Harton
SPDX-License-Identifier: Apache-2.0
-->
<img src="https://github.com/beam-bots/bb/blob/main/logos/beam_bots_logo.png?raw=true" alt="Beam Bots Logo" width="250" />
# Beam Bots PCA9685 servo control
[![CI](https://github.com/beam-bots/bb_servo_pca9685/actions/workflows/ci.yml/badge.svg)](https://github.com/beam-bots/bb_servo_pca9685/actions/workflows/ci.yml)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache--2.0-green.svg)](https://opensource.org/licenses/Apache-2.0)
[![Hex version badge](https://img.shields.io/hexpm/v/bb_servo_pca9685.svg)](https://hex.pm/packages/bb_servo_pca9685)
[![Hexdocs badge](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/bb_servo_pca9685)
[![REUSE status](https://api.reuse.software/badge/github.com/beam-bots/bb_servo_pca9685)](https://api.reuse.software/info/github.com/beam-bots/bb_servo_pca9685)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/beam-bots/bb_servo_pca9685)
# 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`:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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.BeginMotion` after 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`.
```elixir
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 degrees` maps to 500 microseconds
- `0 degrees` maps to 1500 microseconds
- `+45 degrees` maps 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:
1. Actuator sends command and publishes `BeginMotion` with expected arrival time
2. Sensor receives `BeginMotion` and interpolates position during movement
3. After arrival time, sensor reports the target position
4. Sensor publishes the estimate as `JointState`, which is what writes
`BB.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:
1. Actuator clamps position to joint limits
2. Converts angle to PWM pulse width
3. Sends command to controller via `BB.Process.call`
4. Controller writes PWM to the PCA9685 over I2C
5. Publishes `BB.Message.Actuator.BeginMotion` with:
- `initial_position` - where the servo was
- `target_position` - where it's going
- `expected_arrival` - when it should arrive (monotonic milliseconds)
- `command_id` - correlation ID (if provided)
- `command_type` - `:position`
## Documentation
Full documentation is available at [HexDocs](https://hexdocs.pm/bb_servo_pca9685).