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

Hardware Calibration

Two numbers per servo, and neither one is on the bag

Her body is assembled. Every servo is bolted into the part it drives, every wire goes where the pin map says it goes, and the numbers that tell those servos how far they may travel are still guesses typed into source files. The eye carries twenty five degrees of pan and twenty of tilt because someone held a protractor against the gimbal and rounded. The arm carries four bands read off the printed parts. Chapter 64 drove a servo from a 0.5 millisecond pulse to a 2.5 millisecond one and got a hum at both ends, on a unit that had no business reaching either.

Nothing about those numbers is knowable in advance. A hobby servo's packaging promises that a 1.0 millisecond pulse means one end of travel and 2.0 means the other, and the promise is made about a design, not about the unit in your hand. Manufacturing tolerance moves both ends. The printed mount moves them again, usually inward, because a cradle hits a yoke long before the servo's own gear stops arrive. So there are exactly two numbers that matter per servo, the pulse width at each real physical limit, and both of them are properties of one servo in one mount on one day.

Chapter 42 set the discipline for this: drive the part, measure what it does, write the reading down next to the specification it was checked against. What is new here is where the reading goes. A measurement pasted into eye_control.py, a second copy in the arm's config and a third in a test script is the same fact in three places, and three places disagree the first time a servo is replaced. So the rule for this chapter: the two pulse widths that mark a servo's real ends of travel are measurements of a physical object, so they belong in a file the code reads at startup, and every motion script gets its limits from that file instead of from a constant somebody typed.

◆ Note — the bench, the instruments, and how to read the numbers

A Raspberry Pi 4B with her body fully assembled, the eye servos on GPIO 12 and 13, the bench sweep rig still wired to GPIO 17, and the arm's four joints on the PCA9685 that hangs off I2C on GPIO 2 and 3. Two instruments are in play and neither is a programming tool. A paper protractor taped under the moving part reads the travel in degrees. A multimeter in series with the servo supply, or the current display on a bench supply, reads the draw: a joint sitting quietly under 0.1 A is holding, and a joint that jumps past half an amp with nothing moving is stalled against something. Every number below came off one build. Yours will differ, and the whole point of the chapter is that they are supposed to.

Three different answers to the question of where a servo's travel ends A pulse width axis runs from 500 to 2500 microseconds. Three bars sit above it. The gpiozero default band runs from 1000 to 2000 microseconds. This particular servo's own gear stops, measured with nothing attached, run from 760 to 2240. The same servo bolted into the eye gimbal reaches only 1250 to 1700, because the printed cradle touches the yoke first. A caption notes that gpiozero puts a value of minus one at the left end of whichever band you wrote down. ONE SERVO · THREE ANSWERS FOR WHERE THE TRAVEL ENDS 1000 2000 printed on the bag 760 2240 this unit, nothing attached 1250 1700 this unit, in the gimbal value -1.0 lands on the left end of whichever band you wrote down 500 1000 1500 2000 2500 pulse width, microseconds
Figure 73.1 — The same servo, three bands, and only the narrow one is true once the part is bolted in. Calibration is the act of finding the two ends of that narrow band and refusing to command anything outside it.

The stepper, before any pin is claimed

▣ Build · stage 1 — one keypress, one clamped number
# labs/calibrate_math.py
COARSE_US = 50           # a press you can see the horn answer
FINE_US = 5              # a press you dial the last hair in with
PW_MIN_US = 500          # nothing outside these two is ever an anchor
PW_MAX_US = 2500

STEPS = {"+": COARSE_US, "-": -COARSE_US, "f+": FINE_US, "f-": -FINE_US}


def step_pulse(pulse_us: int, command: str) -> int:
    """Apply one keypress to a pulse width, clamped to a range no servo is hurt by."""
    if command not in STEPS:
        raise ValueError(f"unknown command: {command!r}")
    return max(PW_MIN_US, min(PW_MAX_US, pulse_us + STEPS[command]))


if __name__ == "__main__":
    pulse = 1000
    for command in ("+", "+", "f+", "f-", "f-"):
        pulse = step_pulse(pulse, command)
        print(f"{command:>2} -> {pulse:5d} us  ({pulse / 1000:.3f} ms)")
    floor = 1000
    for _ in range(20):
        floor = step_pulse(floor, "-")
    print(f"twenty coarse steps down from 1000 us -> {floor} us")
$ uv run python -m labs.calibrate_math
 + ->  1050 us  (1.050 ms)
 + ->  1100 us  (1.100 ms)
f+ ->  1105 us  (1.105 ms)
f- ->  1100 us  (1.100 ms)
f- ->  1095 us  (1.095 ms)
twenty coarse steps down from 1000 us -> 500 us

The interesting logic in a calibration tool is the clamp, and the clamp is the one part that never needs a servo. Written as a function from a number and a command to a number, it runs on a laptop, and you can watch the floor hold at 500 after twenty presses that would otherwise have walked the anchor down to zero. Those bounds are not the answer to anything; they are the widest range this class of servo tolerates without damage, and the session that follows exists to come well inside them.

Microseconds as integers, not seconds as floats, for two reasons. The board in chapter 69 already speaks in microseconds, so one vocabulary covers the whole body. And the step sizes mean something in that unit: chapter 64 put this servo family's deadband near ten microseconds of pulse, the window the control circuit treats as no error at all, so a single fine press may produce no motion and two of them always will. A coarse press of 50 microseconds is roughly five and a half degrees of shaft, which is a movement you can see from across the bench.

▣ Build · stage 2 — the pins come from the map, not from the tool
# labs/calibrate_config.py
import json
import sys
from pathlib import Path

HARDWARE_PATH = Path("configs/hardware.json")


def load_servo_pins(path: Path = HARDWARE_PATH) -> dict[str, int]:
    """Servo name -> BCM pin, straight out of the map chapter 71 validated."""
    if not path.exists():
        print(f"no {path}: build and check the pin map first, then wire what it says.")
        sys.exit(1)
    config = json.loads(path.read_text())
    return {name: servo["bcm_pin"] for name, servo in config.get("servos", {}).items()}


if __name__ == "__main__":
    pins = load_servo_pins()
    print(f"{len(pins)} servos to calibrate, pins from {HARDWARE_PATH}")
    for name, pin in pins.items():
        print(f"  {name:<11}GPIO {pin}")
$ uv run python -m labs.calibrate_config
3 servos to calibrate, pins from configs/hardware.json
  eye_pan    GPIO 12
  eye_tilt   GPIO 13
  bench_test GPIO 17

A calibration tool that knows its own pin numbers is a fourth place for a wiring fact to live. This one asks, and it asks the file that was already checked for collisions, so moving the tilt servo to a different header pin is one edit and the calibrator follows. The .get("servos", {}) is there so a map with no header servos at all yields an empty dictionary instead of a KeyError: the arm's joints hang off a driver board and carry channel numbers, not GPIO pins, and a machine wired that way should get a polite "nothing to do" from this tool.

One end at a time, with the horn in front of you

▣ Build · stage 3 — nudge an anchor until the part just touches its stop
# labs/calibrate.py
from gpiozero import Servo

from labs.calibrate_config import load_servo_pins
from labs.calibrate_math import step_pulse

DEFAULT_MIN_US = 1000
DEFAULT_MAX_US = 2000
MIN_SPAN_US = 200        # two anchors closer than this describe no useful travel
PROMPT = "  [{pulse:4d} us] +/-/f+/f-/done/skip: "


def drive(pin: int, low_us: int, high_us: int, end: str) -> Servo:
    """Build a servo with these two anchors and send it to one of them."""
    servo = Servo(pin, min_pulse_width=low_us / 1e6, max_pulse_width=high_us / 1e6)
    servo.value = -1.0 if end == "min" else 1.0
    return servo


def find_end(name: str, pin: int, end: str) -> int:
    """Move one anchor until the part just reaches the stop that anchor belongs to."""
    pulse = DEFAULT_MIN_US if end == "min" else DEFAULT_MAX_US
    other = DEFAULT_MAX_US if end == "min" else DEFAULT_MIN_US
    servo = drive(pin, min(pulse, other), max(pulse, other), end)
    print(f"  {name} driven to its {end} end; nudge until it just touches the stop.")
    try:
        while True:
            command = input(PROMPT.format(pulse=pulse)).strip().lower()
            if command == "done":
                return pulse
            if command == "skip":
                return DEFAULT_MIN_US if end == "min" else DEFAULT_MAX_US
            try:
                candidate = step_pulse(pulse, command)
            except ValueError as exc:
                print(f"  {exc}")
                continue
            if abs(candidate - other) < MIN_SPAN_US:
                print(f"  refused: that leaves under {MIN_SPAN_US} us between the anchors.")
                continue
            pulse = candidate
            servo.close()
            servo = drive(pin, min(pulse, other), max(pulse, other), end)
    finally:
        servo.close()
# labs/calibrate_one.py — one servo, one end, for getting the feel of it
import sys

from labs.calibrate import find_end
from labs.calibrate_config import load_servo_pins

if __name__ == "__main__":
    name, end = sys.argv[1], sys.argv[2]
    print(f"{name} {end} anchor: {find_end(name, load_servo_pins()[name], end)} us")
$ uv run python -m labs.calibrate_one eye_pan min   # on the bench — yours will vary
  eye_pan driven to its min end; nudge until it just touches the stop.
  [1000 us] +/-/f+/f-/done/skip: +
  [1050 us] +/-/f+/f-/done/skip: +
  [1100 us] +/-/f+/f-/done/skip: +
  [1150 us] +/-/f+/f-/done/skip: +
  [1200 us] +/-/f+/f-/done/skip: +
  [1250 us] +/-/f+/f-/done/skip: done
eye_pan min anchor: 1250 us

Read the first line of that session as a diagnosis. At the default anchor of 1000 microseconds the servo is already hard against the printed yoke, buzzing, and the supply reads 0.58 A with nothing moving. Each press walks the anchor up, and the horn follows because the servo is still commanded to value = -1.0, which is whatever the current minimum happens to be. Somewhere around 1250 the buzz stops and the cradle sits against the yoke without pressing on it. That is the number, and no calculation could have produced it.

The rebuild in the middle of the loop is the part that looks wasteful and is not optional. gpiozero fixes the pulse mapping when a Servo is constructed, and exposes min_pulse_width as a read-only property, so there is no way to slide an anchor on a live object. Previewing a new anchor means building a new servo, which is a general shape in device libraries: a parameter that the driver turned into precomputed state at construction is a parameter you replace the object to change. Closing the old one first matters as much as building the new one, for a reason the failure below makes loud.

▣ Build · stage 4 — a session that keeps what it already knew
# labs/calibrate.py — continued
import json
from datetime import datetime
from pathlib import Path

from labs.calibrate_config import HARDWARE_PATH

CAL_PATH = Path("configs/calibration.json")
DRAFT_PATH = Path("configs/calibration.draft.json")


def calibrate_servo(name: str, pin: int) -> dict:
    print(f"\n--- {name} on GPIO {pin} (BCM) ---")
    min_us = find_end(name, pin, "min")
    max_us = find_end(name, pin, "max")
    travel = float(input("  protractor, total travel between those two ends, deg: "))
    return {
        "bcm_pin": pin,
        "min_pulse_us": min_us,
        "max_pulse_us": max_us,
        "travel_deg": travel,
        "us_per_deg": round((max_us - min_us) / travel, 2),
        "measured_at": datetime.now().isoformat(timespec="seconds"),
    }


def default_entry(pin: int) -> dict:
    return {"bcm_pin": pin, "min_pulse_us": DEFAULT_MIN_US, "max_pulse_us": DEFAULT_MAX_US,
            "travel_deg": None, "us_per_deg": None,
            "note": "gpiozero defaults, not calibrated"}


def save(records: dict, path: Path) -> None:
    """Write through a temporary file so a reader never sees half a config."""
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".tmp")
    tmp.write_text(json.dumps(records, indent=2) + "\n")
    tmp.replace(path)


def main() -> None:
    pins = load_servo_pins()
    records = json.loads(CAL_PATH.read_text()) if CAL_PATH.exists() else {}
    print(f"{len(pins)} servos, pins from {HARDWARE_PATH}; "
          f"{len(records)} already in {CAL_PATH}")
    for name, pin in pins.items():
        if input(f"calibrate '{name}' on GPIO {pin}? [y/N]: ").strip().lower() == "y":
            records[name] = calibrate_servo(name, pin)
            save(records, DRAFT_PATH)
            print(f"  draft saved: {DRAFT_PATH} ({len(records)} servos)")
        elif name in records:
            kept = records[name]
            print(f"  {name} kept: {kept['min_pulse_us']}-{kept['max_pulse_us']} us,"
                  f" measured {kept.get('measured_at', 'never')}")
        else:
            records[name] = default_entry(pin)
            print(f"  {name} has no entry; recording the gpiozero defaults, uncalibrated.")
    save(records, CAL_PATH)
    print(f"\nwrote {CAL_PATH}")


if __name__ == "__main__":
    main()
$ uv run python -m labs.calibrate   # on the bench — yours will vary
3 servos, pins from configs/hardware.json; 1 already in configs/calibration.json
calibrate 'eye_pan' on GPIO 12? [y/N]: n
  eye_pan kept: 1250-1700 us, measured 2026-08-21T20:14:07
calibrate 'eye_tilt' on GPIO 13? [y/N]: y

--- eye_tilt on GPIO 13 (BCM) ---
  eye_tilt driven to its min end; nudge until it just touches the stop.
  [1000 us] +/-/f+/f-/done/skip: +
  [1050 us] +/-/f+/f-/done/skip: +
  [1100 us] +/-/f+/f-/done/skip: +
  [1150 us] +/-/f+/f-/done/skip: +
  [1200 us] +/-/f+/f-/done/skip: +
  [1250 us] +/-/f+/f-/done/skip: +
  [1300 us] +/-/f+/f-/done/skip: +
  [1350 us] +/-/f+/f-/done/skip: done
  eye_tilt driven to its max end; nudge until it just touches the stop.
  [2000 us] +/-/f+/f-/done/skip: -
  [1950 us] +/-/f+/f-/done/skip: -
  [1900 us] +/-/f+/f-/done/skip: -
  [1850 us] +/-/f+/f-/done/skip: -
  [1800 us] +/-/f+/f-/done/skip: -
  [1750 us] +/-/f+/f-/done/skip: -
  [1700 us] +/-/f+/f-/done/skip: -
  [1650 us] +/-/f+/f-/done/skip: done
  protractor, total travel between those two ends, deg: 34
  draft saved: configs/calibration.draft.json (2 servos)
calibrate 'bench_test' on GPIO 17? [y/N]: n
  bench_test has no entry; recording the gpiozero defaults, uncalibrated.

wrote configs/calibration.json
{
  "eye_pan": {
    "bcm_pin": 12,
    "min_pulse_us": 1250,
    "max_pulse_us": 1700,
    "travel_deg": 51.0,
    "us_per_deg": 8.82,
    "measured_at": "2026-08-21T20:14:07"
  },
  "eye_tilt": {
    "bcm_pin": 13,
    "min_pulse_us": 1350,
    "max_pulse_us": 1650,
    "travel_deg": 34.0,
    "us_per_deg": 8.82,
    "measured_at": "2026-08-22T09:31:55"
  },
  "bench_test": {
    "bcm_pin": 17,
    "min_pulse_us": 1000,
    "max_pulse_us": 2000,
    "travel_deg": null,
    "us_per_deg": null,
    "note": "gpiozero defaults, not calibrated"
  }
}

The session starts by loading what is already on disk, so answering no keeps a measurement instead of overwriting it with a default. Calibration is not something you sit down and finish; it happens a servo at a time, on the evenings you have the body open, and a tool that demands all three in one sitting gets run once and then avoided. The draft written after each servo is the same idea at a smaller scale, and both writes go through a temporary file that is renamed into place, so a tool interrupted halfway leaves either the old file or the new one.

us_per_deg is a derived field, and it is the cheapest error check in the file. Both eye servos land on 8.82 microseconds per degree, and the servo measured free on the bench in chapter 42 worked out to 8.81, because this is one servo family with one gear ratio. A fourth entry reporting 4.4 or 17.6 is not a remarkable servo; it is a protractor read from the wrong side of centre, or a travel figure entered as half the sweep. The uncalibrated entry carries nulls and a note instead of plausible-looking defaults, so nothing downstream can mistake a guess for a measurement.

The file every moving thing reads at startup

▣ Build · stage 5 — limits that arrive as data, and an audit of the old guesses
# labs/calibrated.py
import json
from pathlib import Path

from gpiozero import Servo

from labs.calibrate_config import load_servo_pins

CAL_PATH = Path("configs/calibration.json")


def load_calibration(path: Path = CAL_PATH) -> dict[str, dict]:
    if not path.exists():
        raise FileNotFoundError(f"{path}: calibrate before running anything that moves")
    return json.loads(path.read_text())


def make_servo(name: str, pins: dict[str, int], cal: dict[str, dict]) -> Servo:
    """A servo whose -1.0 and +1.0 land on the stops somebody measured."""
    entry = cal[name]
    return Servo(pins[name],
                 min_pulse_width=entry["min_pulse_us"] / 1e6,
                 max_pulse_width=entry["max_pulse_us"] / 1e6)


def axis_limit_deg(entry: dict) -> float:
    """Half the measured travel: the eye's angles are signed from centre."""
    return entry["travel_deg"] / 2.0


DECLARED = {"eye_pan": 25.0, "eye_tilt": 20.0}   # the limits set by hand in chapter 65


def audit(cal: dict[str, dict], declared: dict[str, float]) -> None:
    print(f"{'servo':<10}{'band (us)':<14}{'travel':>7}{'measured':>10}{'declared':>10}   verdict")
    for name, limit in declared.items():
        entry = cal[name]
        band = f"{entry['min_pulse_us']}-{entry['max_pulse_us']}"
        measured = axis_limit_deg(entry)
        verdict = ("inside the stop" if measured >= limit
                   else f"{limit - measured:.1f} deg past the stop")
        print(f"{name:<10}{band:<14}{entry['travel_deg']:>7.1f}"
              f"{measured:>10.1f}{limit:>10.1f}   {verdict}")


if __name__ == "__main__":
    audit(load_calibration(), DECLARED)
$ uv run python -m labs.calibrated
servo     band (us)      travel  measured  declared   verdict
eye_pan   1250-1700        51.0      25.5      25.0   inside the stop
eye_tilt  1350-1650        34.0      17.0      20.0   3.0 deg past the stop
# labs/eye_control.py — the two class constants become constructor arguments
class EyeController:
    EASE_STEPS = 30
    EASE_DELAY = 0.012

    def __init__(self, pan: Servo, tilt: Servo,
                 pan_limit: float, tilt_limit: float) -> None:
        self.pan, self.tilt = pan, tilt
        self.pan_limit, self.tilt_limit = pan_limit, tilt_limit
        self._pan_deg = 0.0
        self._tilt_deg = 0.0
        self._apply(0.0, 0.0)

    def _apply(self, pan_deg: float, tilt_deg: float) -> None:
        """Still the only place in the program that writes to a servo."""
        pan_deg = max(-self.pan_limit, min(self.pan_limit, pan_deg))
        tilt_deg = max(-self.tilt_limit, min(self.tilt_limit, tilt_deg))
        self.pan.value = pan_deg / self.pan_limit
        self.tilt.value = tilt_deg / self.tilt_limit
        self._pan_deg, self._tilt_deg = pan_deg, tilt_deg


def calibrated_eye() -> EyeController:
    """Every number in the eye's motion path, sourced from the two config files."""
    pins, cal = load_servo_pins(), load_calibration()
    return EyeController(pan=make_servo("eye_pan", pins, cal),
                         tilt=make_servo("eye_tilt", pins, cal),
                         pan_limit=axis_limit_deg(cal["eye_pan"]),
                         tilt_limit=axis_limit_deg(cal["eye_tilt"]))
$ uv run python -m labs.eye_calibrated   # on the bench — yours will vary
eye built from configs/calibration.json: pan +-25.5 deg, tilt +-17.0 deg
look_at(24, -12)   pan= +24.0  tilt= -12.0
look_at(40, -25)   pan= +25.5  tilt= -17.0   both clamped at a measured stop
blink()            pan= +25.5  tilt= -17.0   shutter to -17.0, quiet at 0.08 A

The audit is the payoff, and it does not flatter the guesses. Pan was set by hand at 25 degrees against a real 25.5, so half a degree of travel was going unused and nothing was ever in danger. Tilt was set at 20 against a real 17. Three degrees does not sound like much until you notice what drives to the tilt limit: the blink drops the shutter to -tilt_limit and holds it there, which under the old constant meant three degrees of press against the yoke, several times a minute, for as long as she is awake. The supply reading in the last line is the confirmation. A blink that ends at 0.08 A is a blink that ends against nothing.

Promoting two class constants to constructor arguments is a small edit with a specific intent. A class constant says "this is true of every eye"; a constructor argument says "this is true of the eye you handed me". The clamp, the conversion and the single writing method are all exactly as chapter 65 left them, because the design was never the problem. The numbers were.

Why this works: two anchors and a straight line between them

gpiozero's servo interface holds two pulse widths and interpolates linearly between them. A value of -1.0 emits the minimum anchor, +1.0 emits the maximum, 0.0 emits the midpoint, and everything in between is a proportion of the span. That is the entire mechanism, and it means calibration is not a correction applied to a command. It is the act of choosing where the ends of the number line sit. Once the two anchors are the real physical stops, a value of -1.0 is by construction a reachable position, and so is every value between. The library then cannot be asked for a position the mount will not allow, because the library has no way to express one.

Two consequences follow that are easy to miss. The first is that a value of 0.0 is the midpoint of the two anchors and nothing more. On the tilt axis the midpoint sits at 1500 microseconds and the eye does look straight ahead there, but only because that mount happens to be symmetric; pan's midpoint is 1475, and an asymmetric bracket can put the true straight-ahead pulse tens of microseconds away from it. Where the mount is lopsided you record the centre as a third measurement instead of assuming it. The second is that every degree figure in the code is downstream of the anchors. Change an anchor without changing the travel figure it was measured with, and the eye keeps obeying, keeps reporting, and keeps being wrong by a fixed proportion.

The arm gets the same treatment with different plumbing. Its joints hang off the driver board, which takes microseconds directly, so there is no gpiozero object to rebuild; you drive a joint to each stop, read the protractor, and convert the reachable travel into the degree band the joint's config carries. What the config looks like, how it is frozen, and the single method that clamps and writes all stay exactly as chapter 69 built them. The bands stop being numbers read off a drawing and start being numbers read off the assembled machine, and no code above the writing method notices the difference. That is the property a measured contract buys: the file absorbs the change, and the software that consumes it holds still.

⚠ Worked failure — the first keypress that takes the pin down with it

The obvious way to swap anchors is the one chapter 64 already taught: stop the pulse train, then build the replacement. detach() is the method that stops pulses, so it goes in the loop, and the tool looks finished:

            pulse = step_pulse(pulse, command)
            servo.detach()          # stops the pulses, and that is all it does
            servo = drive(pin, min(pulse, other), max(pulse, other), end)
$ uv run python -m labs.calibrate_one eye_pan min
  eye_pan driven to its min end; nudge until it just touches the stop.
  [1000 us] +/-/f+/f-/done/skip: +
Traceback (most recent call last):
  File "/home/pi/glados/labs/calibrate_one.py", line 9, in <module>
    print(f"{name} {end} anchor: {find_end(name, load_servo_pins()[name], end)} us")
                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pi/glados/labs/calibrate.py", line 43, in find_end
    servo = drive(pin, min(pulse, other), max(pulse, other), end)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/pi/glados/labs/calibrate.py", line 15, in drive
    servo = Servo(pin, min_pulse_width=low_us / 1e6, max_pulse_width=high_us / 1e6)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
gpiozero.exc.GPIOPinInUse: pin GPIO12 is already in use by <gpiozero.Servo object on pin GPIO12, active_high=True, is_active=False>

The first press is enough. Nothing was mistyped, the pin number is right, and the servo it names is the one this program built four lines earlier. Read the exception literally: the pin is in use by a Servo, and the only servo in the process is the one just detached. So detaching did not do what the name suggested.

Two different resources are in play and they have separate lifetimes. The pulse train is one: detach() stops it, the servo goes limp, and that is exactly what chapter 64 wanted at the end of a sweep. The pin reservation is the other. Every gpiozero device registers itself with the pin factory when it is constructed, and the factory refuses a second claim on the same pin, which is the same protection chapter 71 built by hand across subsystems and which works only inside one process. A detached servo still holds its reservation, so the constructor is right to refuse. The fix is close(), which stops the pulses and releases the pin, and which is safe to call twice, so the finally clause that closes on the way out costs nothing when the loop already closed. The general form of the lesson: releasing a device's output is not the same operation as releasing the device.

Checkpoint, and the run that signs off the body

✓ Checkpoint — what you can now do
  • I can explain what a servo's two calibrated anchors are anchors of, and why a value of -1.0 means something different after calibration than before it.
  • I can walk a servo to a physical stop with coarse and fine presses, and say what the supply current tells me about whether I have gone one press too far.
  • I know why the tool builds a new Servo for every keypress, and why close() is required where detach() looked sufficient.
  • I can read a calibration entry and decide from us_per_deg alone whether the protractor reading behind it is believable.
  • I can trace the eye's tilt limit from a measured pulse band to a blink that no longer presses on the yoke.
  • I can say what a midpoint pulse is, and why it is not automatically the direction the eye is looking when it looks straight ahead.
⚡ Exercises — try first, then reveal
Exercise 1 — catch a bad protractor reading. Compare every calibrated entry's microseconds per degree against the figure this servo family actually produces, and flag anything that is not close.

Suppose bench_test gets calibrated at 760 to 2240 microseconds, but the travel is entered as 84 degrees because it was read from centre instead of end to end:

from labs.calibrated import load_calibration

CLASS_US_PER_DEG = 8.8      # this family of nine-gram servos, from the bench

for name, entry in load_calibration().items():
    if entry.get("us_per_deg") is None:
        print(f"{name:<11}uncalibrated")
        continue
    ratio = entry["us_per_deg"] / CLASS_US_PER_DEG
    verdict = "plausible" if 0.8 <= ratio <= 1.25 else "re-read the protractor"
    print(f"{name:<11}{entry['us_per_deg']:5.2f} us/deg  {ratio:4.2f}x class  {verdict}")
$ uv run python -m labs.calibration_check
eye_pan     8.82 us/deg  1.00x class  plausible
eye_tilt    8.82 us/deg  1.00x class  plausible
bench_test 17.62 us/deg  2.00x class  re-read the protractor

A factor of exactly two is the signature of a half-sweep measured as a full one, and it is the mistake this check exists for. Nothing else in the file would have caught it: the pulse anchors are correct, and only the degrees are wrong.

Exercise 2 — measure the drift after a screw is retightened. Calibrate a servo, disturb the mount, calibrate again, and report the change in both anchors and in total travel.
def drift(old: dict, new: dict) -> str:
    d_min = new["min_pulse_us"] - old["min_pulse_us"]
    d_max = new["max_pulse_us"] - old["max_pulse_us"]
    d_deg = (d_max - d_min) / old["us_per_deg"]
    return f"min {d_min:+4d} us   max {d_max:+4d} us   travel {d_deg:+5.1f} deg"


old = {"min_pulse_us": 1250, "max_pulse_us": 1700, "us_per_deg": 8.82}
new = {"min_pulse_us": 1285, "max_pulse_us": 1690, "us_per_deg": 8.82}
print("eye_pan  " + drift(old, new))
$ uv run python -m labs.calibration_drift
eye_pan  min  +35 us   max  -10 us   travel  -5.1 deg

Five degrees of gaze disappeared because a bracket screw was tightened a quarter turn. Keep the old entry alongside the new one and the file starts answering a second question: not only where the stops are, but whether they are moving.

Exercise 3 — find the value that points a lopsided mount straight ahead. Given the two anchors and the pulse width at which the eye visibly looks forward, compute the gpiozero value that lands there.
def centre_value(entry: dict, centre_us: int) -> tuple[float, float]:
    """Invert the linear map: which value emits this pulse, and how far off midpoint is it?"""
    mid = (entry["min_pulse_us"] + entry["max_pulse_us"]) / 2
    half_span = (entry["max_pulse_us"] - entry["min_pulse_us"]) / 2
    return (centre_us - mid) / half_span, (centre_us - mid) / entry["us_per_deg"]


pan = {"min_pulse_us": 1250, "max_pulse_us": 1700, "us_per_deg": 8.82}
for pulse in (1520, 1475):
    value, offset = centre_value(pan, pulse)
    print(f"straight ahead at {pulse} us -> value {value:+.3f}  ({offset:+.1f} deg off midpoint)")
$ uv run python -m labs.calibration_centre
straight ahead at 1520 us -> value +0.200  (+5.1 deg off midpoint)
straight ahead at 1475 us -> value +0.000  (+0.0 deg off midpoint)

A non-zero answer means the mechanical centre is not the midpoint, and the honest repair is to store that pulse width as a third field and offset every command by it, not to fudge one of the anchors until the arithmetic comes out even. An anchor describes a stop; moving it to fix a centring error throws away travel at the end you moved.

Every moving part of her body now takes its limits from a file that records what somebody measured, on a date, with an instrument. The pieces have also been tested one at a time and only one at a time: an eye that behaves alone, an arm that homes alone, a speaker checked while nothing else was drawing current. Next comes the run that exercises all six subsystems in one sitting, isolates each check so a single failure cannot hide the other five, and ends with a dated verdict on whether this hardware is ready to carry the software stack.