Controlling servo in Elixir / Nerves

I really wanted to control a servo motor using my favorite programming language Elixir. Here is what I learned when I did experiments controlling a SG90-type digital servo motor from my Nerves-powered Raspberry Pi Zero W using PCA9685 PWM/Servo controller.

[日本語]

Servo motors

I am not gonna talk about what a servo motor is since I am not an expert and also there are tons of good resources on the Internet, such as Sparkfun's Hobby Servo Tutorial and Basic Servo Control for Beginners.

Long story short, there are two types of servo motors available for hobby use:

Standard servo

e.g. SG90

standard_servo

Continuous-rotation servo

e.g. FS90R

continuous_servo

Pulse width modulation (PWM)

Pulse width modulation (PWM) is commonly used for controlling a digital servo. We control a servo by changing the ratio of on-time to off-time of the waveform.

PWM can be used for smoothly dimming LEDs like this.

servo-driver-pwm-leds

PWM for servo motors

As far as I know, most hobby servos accept the 50Hz signal whose period is 20ms. What confused me was after that. Many sources say:

  • 1.0ms pulse width for full clockwise
  • 1.5ms pulse width for neutral position
  • 2.0ms pulse width for full counterclockwise

but my SG90 moves far less than 180-degree range with that setting. It is kind of mysterious how many degrees the servo rotates for what pulse width. It seems like this guy had the same question: What is the proper calculation of duty cycle range for the sg90 servo? | https://raspberrypi.stackexchange.com

I am not sure whether those tutorials are wrong or different servos have different specifications. Anyway, I cannot find any solid information on detailed specifications of SG90-type servo. Please let me know if you have any information!

Here I put together my researches and observations.

pulse widthduty cyclememo
400µs2%my SG90's lower limit
500µs2.5%looks close to 0 degree to me
1500µs7.5%typically a neutral position (90 degrees)
2500µs12.5%looks close to 180 degrees to me; maybe like 170
2800µs14%my SG90's upper limit

Some documents say SG90 rated pulse ranges is 500..2400µs, but my SG90 can rotate between 400..2800µs.

Servo tester

Probably experts would use some machines to see the actual waveforms, but I do not want to dig that deep. I tried this inexpensive (~10 USD) servo tester and it was super useful in my understanding the characteristics of my SG90. It generates a signal changing the pulse width between 800..2200µs.

PWM controller

As a hardware noob, I asked Elixir/Nerves community questions about how to control a servo from Nerves-powered Pi. A few people including Frank Hunleth co-author of Nerves project kindly gave me valuable advice. They recommend using a PWM-controlled servo is via an "I2C->PWM" board rather than using the Raspberry Pi's builtin PWM. Frank added that Linux PWM support is poor based on his past experience.

So I decided to use PCA9685: 16-channel 12-bit PWM controller, which I control from my Raspberry Pi Zero W that is powered by Nerves IoT framework via I2C. Having 16 channels means we can control 16 different servo motors simultaneously, which is cool despite being overkill for my experiments.

Libraries

I could not found any Elixir libraries for controlling a servo other than jimsynz/pca9685.ex, which was unfortunately outdated and unmaintained.

So I decided to write one on my own, reading PCA9685 data sheet and adopting ideas from existing libraries written in various programming languages. My servo kit is working pretty well so far.

For communicating between Raspberry Pi and PCA9685, I use elixir-circuits/circuits_i2c library, which is a reliable and established I2C library in Elixir community.

Demo

hardware

firmware

software

  • ServoKit - Use PCA9685 PWM/Servo Controller in Elixir

servo-demo

Here is a throw-away Elixir script I wrote, which I ran from the Interactive Elixir Shell on my Raspberry Pi.

# Start a pwm control process
ServoKit.start_link()

# Define a function that changes duty cycle and delays a little
set_pwm_duty_cycle = fn x, ch ->
  ServoKit.set_pwm_duty_cycle(x, ch: ch)
  Process.sleep(50)
end

# Iterate changing duty cycle with 0.5 step betweem 2.5 and 12.5 for channel 15
list1 = 2.5  |> Stream.iterate(&(&1 + 0.5)) |> Enum.take(21)
list2 = 12.5 |> Stream.iterate(&(&1 - 0.5)) |> Enum.take(21)
0..99 |> Enum.each(fn _ ->
  list1 |> Enum.each(&set_pwm_duty_cycle.(&1, 15))
  list2 |> Enum.each(&set_pwm_duty_cycle.(&1, 15))
end)

That's it!