GLaDOS Vol 7 · The Body
ch 64 / 99
Chapter 64

Servo Basics

The part passed, and you still cannot command it

Chapter 42 put a servo on the bench, swept it from one stop to the other, and wrote down what happened: 168 degrees of travel, twelve short of the number printed on the bag. That record answers one question, "is this part good enough to use", and it was the right question to ask at the time. It says nothing about what to send the servo tomorrow morning, when her eye has to look left.

Volume 7 hangs six of these off her body. Two aim the eye, four run the arm, and all of them take orders from a Raspberry Pi that is also running the rest of her. Six is enough that guessing stops being cheap. A servo told to go somewhere it cannot reach does not raise an exception and does not return an error code. It leans on a plastic stop and grinds, and the first thing you notice is the smell.

Open a hobby servo and there are four things inside: a small DC motor, a gear train that trades speed for torque, a potentiometer geared to the output shaft, and a control circuit about the size of a fingernail. Three wires come out. Red carries power, brown or black carries ground, orange or yellow carries signal. There is no fourth wire, and nothing in that bundle carries a number of degrees.

What the signal wire carries is a pulse. The line goes high, stays high for somewhere between one and two thousandths of a second, and drops. Fifty times a second, forever, for as long as you want the servo to hold its position. The width of the high part is the whole command. So the rule for this volume: a servo receives nothing but the width of a repeating pulse, so every angle in your code is a conversion that has to happen, and be clamped, before the number reaches the pin.

◆ Note — the bench for this volume, and how to read its numbers

A Raspberry Pi 4B, a nine-gram SG90 servo with its signal wire on GPIO 17, and a separate 5 V supply for the servo with its ground tied to the Pi's. That last part is not optional: one of these draws under 10 mA idle and can pull most of an amp while it moves, and the Pi's own 5 V pin will brown the board out long before it gives you six of them. GPIO 17 is a BCM number, the label the chip uses, and it sits at physical position 11 on the 40-pin header; the two numbering schemes never line up, and the pinout command that ships with gpiozero prints the map for your exact board. Every hardware reading below came off one bench with one servo. Yours will land somewhere else.

The pulse, with no hardware in the room

▣ Build · stage 1 — an angle becomes a duration
# labs/servo_math.py
FRAME_MS = 20.0      # a 50 Hz servo expects one pulse every 20 ms
MIN_PULSE_MS = 1.0   # the pulse that means "one end of travel"
MAX_PULSE_MS = 2.0   # the pulse that means "the other end"


def clamp_angle(angle: float) -> float:
    """Pin any request into the 0-180 degree range the caller is allowed to ask for."""
    return max(0.0, min(180.0, angle))


def angle_to_pulse_ms(angle: float) -> float:
    """Map 0-180 degrees onto the standard 1.0-2.0 ms pulse width."""
    fraction = clamp_angle(angle) / 180.0
    return MIN_PULSE_MS + fraction * (MAX_PULSE_MS - MIN_PULSE_MS)


def duty_percent(pulse_ms: float, frame_ms: float = FRAME_MS) -> float:
    """The fraction of each frame the line spends high, as a percentage."""
    return pulse_ms / frame_ms * 100.0


if __name__ == "__main__":
    for deg in (0, 90, 180):
        pulse = angle_to_pulse_ms(deg)
        print(f"{deg:>3} deg -> {pulse:.2f} ms pulse  {duty_percent(pulse):>5.1f}% duty at 50 Hz")
$ uv run python -m labs.servo_math
  0 deg -> 1.00 ms pulse    5.0% duty at 50 Hz
 90 deg -> 1.50 ms pulse    7.5% duty at 50 Hz
180 deg -> 2.00 ms pulse   10.0% duty at 50 Hz

Two vocabularies get mixed together here, and the confusion is expensive. Duty cycle is the fraction of each period the line spends high, and it is the number most PWM explanations lead with, because for an LED or a motor driver the ratio really is the command: half the time at full voltage averages out to half brightness, and the frequency barely matters. A servo does not average anything. Its control circuit times the high part with a timer of its own, so the command is 1.5 milliseconds of absolute duration, and the 7.5 percent is arithmetic that falls out afterwards. Move the frame to 100 Hz and 7.5 percent duty becomes a 0.75 ms pulse, below anything the servo recognises. The frequency and the duty cycle describe the waveform. Only the width describes the angle.

Three commands as three pulse widths in the same 20 ms frame Three waveforms share one time axis running from 0 to 20 milliseconds. Each goes high at the start of the frame and drops again: after 1.00 ms for 0 degrees, after 1.50 ms for 90 degrees, after 2.00 ms for 180 degrees. The rest of each frame is idle low time, and the frame repeats fifty times a second. ONE 20 ms FRAME · THE HIGH TIME IS THE COMMAND 0 deg value -1.00 1.00 ms high 90 deg value +0.00 1.50 ms high 180 deg value +1.00 2.00 ms high 0 5 ms 10 ms 15 ms 20 ms
Figure 64.1 — The three commands differ by one millisecond in total, spread across a frame that is twenty milliseconds long. Ninety percent of every frame is idle in all three cases, so the ratio is nearly the same and the meaning is not.
▣ Build · stage 2 — the same command in a third set of units
# labs/servo_math.py — added below angle_to_pulse_ms


def angle_to_value(angle: float) -> float:
    """0 deg -> -1.0 (minimum), 90 deg -> 0.0 (centre), 180 deg -> +1.0 (maximum)."""
    return clamp_angle(angle) / 90.0 - 1.0


def value_to_pulse_ms(value: float,
                      min_ms: float = MIN_PULSE_MS,
                      max_ms: float = MAX_PULSE_MS) -> float:
    """Undo the normalisation: turn a -1.0..1.0 value back into a duration."""
    value = max(-1.0, min(1.0, value))
    return min_ms + (value + 1.0) / 2.0 * (max_ms - min_ms)


if __name__ == "__main__":
    for deg in (0, 45, 90, 135, 180):
        value = angle_to_value(deg)
        print(f"{deg:>3} deg  value {value:+.2f}  pulse via value {value_to_pulse_ms(value):.2f} ms"
              f"  via angle {angle_to_pulse_ms(deg):.2f} ms")
$ uv run python -m labs.servo_math
  0 deg  value -1.00  pulse via value 1.00 ms  via angle 1.00 ms
 45 deg  value -0.50  pulse via value 1.25 ms  via angle 1.25 ms
 90 deg  value +0.00  pulse via value 1.50 ms  via angle 1.50 ms
135 deg  value +0.50  pulse via value 1.75 ms  via angle 1.75 ms
180 deg  value +1.00  pulse via value 2.00 ms  via angle 2.00 ms

The library you will actually drive this with, gpiozero, takes neither degrees nor milliseconds. Its Servo takes a normalised value from -1.0 through 0.0 to +1.0 and generates the pulse train underneath. That looks like a third thing to learn and it is not: the two columns on the right agree on every row, because the value scale is the pulse range with its endpoints renamed. Degrees are for humans, the value is for the library, the pulse width is for the servo, and the arithmetic between them is four lines you can read.

Timing is why the library exists at all. Writing the pulse yourself means setting a pin high, sleeping 1.5 ms, setting it low, in a Python loop on a kernel that is entirely free to schedule something else in the middle of that sleep. A pulse meant to be 1.50 ms arrives as 1.7 ms because a transcription thread woke up, and the servo believes every pulse it is given. gpiozero pushes the pulse train down to a pin factory whose only job is keeping it steady, and swapping that factory for pigpio moves the timing off the Python interpreter altogether.

One function between degrees and the pin

▣ Build · stage 3 — the conversion, provable on a laptop
# labs/servo_test.py
import signal
import sys
import time

from gpiozero import Servo

from labs.servo_math import MAX_PULSE_MS, MIN_PULSE_MS, angle_to_pulse_ms, angle_to_value, clamp_angle

SERVO_PIN = 17       # BCM numbering; physical pin 11 on the header


def set_angle(servo: Servo, degrees: float) -> float:
    """Command the servo in degrees. Returns the angle actually applied."""
    degrees = clamp_angle(degrees)
    servo.value = angle_to_value(degrees)
    return degrees
# labs/servo_dry_run.py — the same code path, with no Pi in the room
from gpiozero import Device, Servo
from gpiozero.pins.mock import MockFactory, MockPWMPin

from labs.servo_math import angle_to_pulse_ms, angle_to_value
from labs.servo_test import set_angle

Device.pin_factory = MockFactory(pin_class=MockPWMPin)

if __name__ == "__main__":
    servo = Servo(17)
    for requested in (0, 45, 90, 135, 180, 220):
        applied = set_angle(servo, requested)
        note = "  (clamped)" if applied != requested else ""
        print(f"{requested:>3} deg -> value {angle_to_value(applied):+.2f}"
              f" -> {angle_to_pulse_ms(applied):.2f} ms{note}")
$ uv run python -m labs.servo_dry_run
  0 deg -> value -1.00 -> 1.00 ms
 45 deg -> value -0.50 -> 1.25 ms
 90 deg -> value +0.00 -> 1.50 ms
135 deg -> value +0.50 -> 1.75 ms
180 deg -> value +1.00 -> 2.00 ms
220 deg -> value +1.00 -> 2.00 ms  (clamped)

Assign servo.value = 90 because 90 degrees is what you meant, and gpiozero refuses it: OutputDeviceBadValue: Servo value must be between -1 and 1, or None. That refusal is a favour. A library that quietly accepted 90 and saturated at 1.0 would move the servo to a full extreme and never tell you the command was nonsense. Everyone writes that line once, and the cure is to make set_angle the only place in her code that ever touches servo.value.

Chapter 27 set the placement rule when the servo was still a print statement: the clamp belongs inside the function that applies the value, not at the call sites, because call sites multiply and any one of them can be a typo, a stale config, or a language model that decided 220 was a reasonable number. Here the clamp runs in degrees, before the conversion, so the saturation is visible in the units the caller was thinking in. The mock pin factory gives you the whole path without hardware: MockPWMPin is required specifically because a plain mock pin has no PWM to fake.

▣ Build · stage 4 — a sweep, and a servo that lets go afterwards
# labs/servo_test.py — continued
STEP = 0.05          # value increment per step; 0.05 of 2.0 is 25 us of pulse
STEP_DELAY = 0.02    # seconds between steps
CYCLES = 3


def sweep_once(servo: Servo) -> None:
    """One trip to the maximum and back, in fixed integer steps."""
    steps = int(2.0 / STEP)
    for i in range(steps + 1):
        servo.value = max(-1.0, min(1.0, -1.0 + i * STEP))
        time.sleep(STEP_DELAY)
    for i in range(steps + 1):
        servo.value = max(-1.0, min(1.0, 1.0 - i * STEP))
        time.sleep(STEP_DELAY)


def release(servo: Servo) -> None:
    """Stop sending pulses so the servo relaxes instead of holding and buzzing."""
    print("\nDetaching servo...")
    servo.detach()


def main() -> None:
    servo = Servo(SERVO_PIN)

    def on_signal(signum: int, frame: object) -> None:
        release(servo)
        sys.exit(0)

    signal.signal(signal.SIGINT, on_signal)
    signal.signal(signal.SIGTERM, on_signal)

    print(f"Servo sweep on GPIO {SERVO_PIN} (BCM), pulse "
          f"{MIN_PULSE_MS:.2f}-{MAX_PULSE_MS:.2f} ms, step={STEP}, delay={STEP_DELAY}s")
    for cycle in range(1, CYCLES + 1):
        print(f"Cycle {cycle}/{CYCLES}")
        sweep_once(servo)
    print("Sweep complete.")
    release(servo)


if __name__ == "__main__":
    main()
$ uv run python -m labs.servo_test   # measured on the bench — yours will vary
Servo sweep on GPIO 17 (BCM), pulse 1.00-2.00 ms, step=0.05, delay=0.02s
Cycle 1/3
Cycle 2/3
Cycle 3/3
Sweep complete.

Detaching servo...

The loop counts integers and computes the value from the count. Accumulating value += 0.05 forty times instead would drift a hair past 1.0 on the last step, and gpiozero would raise in the middle of a motion. Forty-one assignments in each direction makes 82 per cycle, 1.64 seconds of travel at a 20 ms delay, and the outer max/min catches anything a future edit to STEP might produce.

The signal handlers are the part people leave out and regret. Press Ctrl+C during a sweep without them and the process dies while the pulse train is still configured, so the servo goes on holding whatever position it was last told, warm and drawing current, until you pull the plug. Registering the same handler on SIGINT and SIGTERM means both an impatient keyboard and a service manager stopping the unit end at detach(). Note that servo is created inside main and closed over by the handler, not parked at module level: a module that grabs a GPIO pin on import cannot be imported by the dry run, or by a test, or by anything else that just wants the functions.

Why this works: a control loop sealed inside the case

That fingernail-sized circuit runs a loop you cannot see and cannot tune. Every frame it measures the width of the pulse you sent and reads the potentiometer, which is geared to the output shaft, so its voltage is a direct report of where the horn is pointing right now. It subtracts one from the other. The sign of the difference picks the motor's direction, the size of the difference sets how hard to drive, and once the difference is small enough the motor switches off. That is the entire algorithm. Everything a hobby servo does well or badly comes out of those four steps.

Start with what detach() does. It stops the pulses. With no pulse arriving there is no target to compare the potentiometer against, the drive stays off, and within a frame or two the servo goes limp: quiet, cool, and free to push by hand. Holding a position is not free, because the loop keeps making small corrections against gravity and friction even when nothing appears to be moving, and six servos all holding is a meaningful load on the supply. A body that relaxes when it is idle runs cooler and lasts longer.

"Small enough" has a name: the deadband, the window of error the circuit treats as zero. An SG90's data sheet quotes about 10 microseconds of it, measured in pulse width. Below that the servo does nothing at all, so a step of 0.005 in the value scale, which is 2.5 microseconds of pulse, is a command this servo will simply not act on. Hunting is what happens at the other edge of that window. The horn lands just outside the deadband, the circuit corrects, the correction overshoots because the gear train has backlash and the motor has inertia, the horn lands just outside on the other side, and the cycle repeats as a small fast twitch that never settles. Worn gears widen the backlash and make it worse, since the shaft can move a degree or two without the potentiometer noticing.

Buzzing at the ends of travel is the same loop given an impossible target. Command a pulse that maps to a potentiometer reading the horn physically cannot reach, because a gear stop or a printed mount gets there first, and the error never falls inside the deadband. The circuit responds the only way it knows: drive harder. The motor cannot turn, so it draws its stall current, the gear train carries the full torque with nowhere to send it, and the audible buzz is the drive switching at the frame rate against a shaft that will not move. Left there, cheap nylon gears round off their teeth in minutes.

⚠ Worked failure — the wider pulse range that made the eye servo hum

The servo measured back in chapter 42 travelled 168 degrees when it was driven from a 0.5 ms pulse to a 2.5 ms one, which is a good deal more than the 90 degrees the gpiozero defaults give you here. The obvious move is to copy those two numbers onto the eye servo and collect the extra travel for free. gpiozero takes them in seconds:

# labs/servo_test.py — the "get the full sweep back" edit
    servo = Servo(SERVO_PIN, min_pulse_width=0.5 / 1000, max_pulse_width=2.5 / 1000)
$ uv run python -m labs.servo_test   # eye servo, widened range — measured on the bench
Servo sweep on GPIO 17 (BCM), pulse 0.50-2.50 ms, step=0.05, delay=0.02s
Cycle 1/3
Cycle 2/3
Cycle 3/3
Sweep complete.

Detaching servo...

The program is perfectly happy. It always will be, and that is the first thing to take from this: nothing in the output can see the failure, because the failure is not in the program. It is on the bench. At both ends of every sweep the servo settles into a steady low hum for the moment it sits there. After three cycles the case is warm; after twenty it is too hot to hold. The bench supply's current display reads 0.09 A with the horn near centre and jumps to 0.62 A each time the sweep reaches an end and pauses.

Follow the loop from that symptom. A 0.62 A draw with nothing moving is a stalled motor, not a moving one, so the circuit is driving into something. It drives when the error sits outside the deadband, and the error is the gap between the commanded position and the potentiometer. Therefore the potentiometer is stuck short of the target, and it is stuck because the horn hit a gear stop that this unit reaches before 2.5 ms asks it to. Every servo has its own stops. Two pulse widths that worked on one part are a measurement of that part, not a fact about servos, and copying them across is how a build ends up with an eye that hums whenever it looks all the way left. Narrow the range from both ends until the hum is gone at both extremes, then store those numbers with the unit they came from.

Checkpoint, and the pair that has to move together

✓ Checkpoint — what you can now do
  • I can turn 135 degrees into a gpiozero value and a pulse width, and say which of the three numbers actually travels down the signal wire.
  • I can explain why 7.5 percent duty means centre at 50 Hz and means nothing usable at 100 Hz.
  • Handed a servo humming at both ends of its sweep, I can trace the stall current back to a potentiometer that cannot reach the commanded reading.
  • I know what detach() stops and what the servo does in the frame or two afterwards.
  • I can say why the sweep counts integer steps instead of adding 0.05 to a running float, and what the clamp still catches after that.
  • I can convert a value step into microseconds of pulse and decide whether this servo will act on it.
⚡ Exercises — try first, then reveal
Exercise 1 — find the smallest step this servo can hear. Convert value steps of 0.05, 0.01 and 0.005 into microseconds of pulse and compare each against a 10 microsecond deadband. Then run the sweep at the smallest one on real hardware and watch what the horn does.

The whole value range of 2.0 spans 1000 microseconds of pulse, so a step is step / 2.0 * 1000 microseconds:

from labs.servo_math import MAX_PULSE_MS, MIN_PULSE_MS

SPAN_US = (MAX_PULSE_MS - MIN_PULSE_MS) * 1000.0
DEADBAND_US = 10.0

for step in (0.05, 0.01, 0.005):
    micros = step / 2.0 * SPAN_US
    verdict = "moves" if micros >= DEADBAND_US else "below the deadband"
    print(f"step {step:<6} -> {micros:6.1f} us per step   {verdict}")
$ uv run python -m labs.servo_steps
step 0.05   ->   25.0 us per step   moves
step 0.01   ->    5.0 us per step   below the deadband
step 0.005  ->    2.5 us per step   below the deadband

On the bench the 0.005 sweep does not crawl smoothly, it lurches: several commands go by with no motion, then the accumulated error crosses the deadband and the horn jumps. Finer commands do not buy finer motion once you are under the servo's own resolution, and the fix for smoothness is a slower step, not a smaller one.

Exercise 2 — watch the duty cycle lie. Print, for 50 Hz, 100 Hz and 333 Hz, both the duty percentage of a 1.50 ms pulse and the pulse width that 7.5 percent duty would produce at that frequency.
from labs.servo_math import duty_percent

for freq in (50, 100, 333):
    frame_ms = 1000.0 / freq
    print(f"{freq:>4} Hz  frame {frame_ms:5.2f} ms  "
          f"1.50 ms pulse = {duty_percent(1.5, frame_ms):5.2f}% duty  "
          f"7.5% duty = {0.075 * frame_ms:.2f} ms pulse")
$ uv run python -m labs.servo_duty
  50 Hz  frame 20.00 ms  1.50 ms pulse =  7.50% duty  7.5% duty = 1.50 ms pulse
 100 Hz  frame 10.00 ms  1.50 ms pulse = 15.00% duty  7.5% duty = 0.75 ms pulse
 333 Hz  frame  3.00 ms  1.50 ms pulse = 49.95% duty  7.5% duty = 0.23 ms pulse

The left column is the command, unchanged: 1.50 ms is centre at every one of those frequencies, and a servo that accepts the faster frame will still centre. The right column is what happens to anyone who wrote down "7.5 percent" as the recipe. At 333 Hz it asks for 0.23 ms, far below the minimum, and the servo either slams to one end or gives up entirely.

Exercise 3 — drive a mirrored pair. Command two servos so that as one moves toward 180 degrees the other moves toward 0, printing the angle and pulse width of each. Mock pins keep it on your laptop.
from gpiozero import Device, Servo
from gpiozero.pins.mock import MockFactory, MockPWMPin

from labs.servo_math import angle_to_pulse_ms
from labs.servo_test import set_angle

Device.pin_factory = MockFactory(pin_class=MockPWMPin)

pan = Servo(17)
mirror = Servo(27)

for degrees in (0, 45, 90, 135, 180):
    a = set_angle(pan, degrees)
    b = set_angle(mirror, 180 - degrees)
    print(f"pan {a:5.1f} deg ({angle_to_pulse_ms(a):.2f} ms)   "
          f"mirror {b:5.1f} deg ({angle_to_pulse_ms(b):.2f} ms)")
$ uv run python -m labs.servo_mirror
pan   0.0 deg (1.00 ms)   mirror 180.0 deg (2.00 ms)
pan  45.0 deg (1.25 ms)   mirror 135.0 deg (1.75 ms)
pan  90.0 deg (1.50 ms)   mirror  90.0 deg (1.50 ms)
pan 135.0 deg (1.75 ms)   mirror  45.0 deg (1.25 ms)
pan 180.0 deg (2.00 ms)   mirror   0.0 deg (1.00 ms)

The mirroring is arithmetic on the angle, done before set_angle, so both servos still go through one clamp and one conversion. On real hardware give each servo its own GPIO pin and feed both from the same external supply with the grounds tied together. Two servos moving in step is the whole idea behind a pan-and-tilt mount.

One servo now takes orders in degrees, converts them where nothing else can reach, and lets go when the program ends. Her eye needs two of them working as one mechanism, and a target that arrives as a place to look instead of a pair of unrelated angles. Next comes the controller that owns both axes: it eases every move so the gaze reads as attention instead of a twitch, and it puts the clamp and the conversion behind a single private door that no gaze command can go around.