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

Eye Mechanism

Two servos and the twitch that gives her away

Her eye is a lens in a printed cradle. The cradle pivots up and down on a pin, and the yoke holding the cradle turns left and right on a second pin, so two servos cover everything the eye can do: pan and tilt. Wire them up, command a look twenty degrees to the left, and the eye is there before you finish the sentence. A hobby servo has one speed, its own, and it uses all of it. The travel takes well under a tenth of a second and arrives as a twitch.

That twitch undoes the rest of the build. Her voice can be right, the model can answer well, the shell can be printed clean, and a person walking past still reads the object on the desk as a prop, because nothing alive moves that way. Eyes accelerate, cross, and settle. They also never fully stop: a person holding a stare is still drifting a degree here and back.

There is a second problem hiding under the first, and it costs hardware instead of atmosphere. The obvious way to move the eye is to write pan.value = 0.8 wherever a move is needed. Now every caller speaks in servo units instead of degrees, the two axes have different travel and nothing records which is which, and no line of code stands between a request for forty degrees and a socket that physically allows twenty five. The servo drives its horn into the printed wall and stalls there, drawing its full stall current, getting hot enough to matter within seconds.

Both problems have the same fix, so this chapter builds one class around one rule: public methods take degrees, and a single private method clamps them to that axis's limit, converts, and writes; nothing else in the program ever touches a servo. Chapter 64 established what that conversion is: a servo obeys pulse width, gpiozero exposes it as a value from -1.0 to 1.0, and anything outside that range is refused. Here the same arithmetic gets a limit per axis and a caller who never sees it.

◆ Note — what is on the bench, and how to read the numbers below

A Raspberry Pi, two nine-gram servos in the printed eye gimbal, signal wires on GPIO 12 (pan) and GPIO 13 (tilt). Both are hardware PWM capable, and both stay clear of GPIO 18, 19 and 21, which on this build belong to the I2S amplifier that drives her speaker. The servos take 5 V from the same separate supply the arm uses, with the grounds tied to the Pi's; powering a servo from a Pi header pin is how people reboot their Pi by moving an eye. The angle limits below, twenty five degrees of pan and twenty of tilt, are what this gimbal allows before the cradle touches the yoke. Yours will differ by a few degrees in either direction, and you measure them by hand in chapter 73. Every captured run in this chapter came off one bench with one pair of servos.

Two limited axes, and the step spacing that makes a move look alive Left: the eye sits in a gimbal with two travel limits, twenty five degrees each side of centre for pan and twenty for tilt, with the socket walls marked at both ends of each axis. Right: the same twenty five degree move drawn as eight steps. The linear row spaces its steps evenly, so the motion begins and ends at full speed. The eased row bunches steps at both ends and spreads them through the middle, so the eye leaves slowly, crosses quickly, and settles. TWO AXES, TWO LIMITS pan -25° +25° tilt +20° -20° dashed ends: the socket wall, in degrees ONE MOVE · 0° TO +25° · EIGHT STEPS linear equal steps: leaves and arrives at full speed eased crowded at both ends: leaves slowly, settles slowly +25°
Figure 65.1 — Same start, same target, same number of steps. The only difference is where the steps are placed in between, and that placement is the whole difference between a machine twitching and an eye looking at you.

The two numbers between an angle and a servo

▣ Build · stage 1 — a move, in steps, with no hardware attached
# labs/eye_control.py

def lerp(a: float, b: float, t: float) -> float:
    """The point fraction t of the way from a to b."""
    return a + (b - a) * t


def ease(t: float) -> float:
    """Smoothstep: still at t=0, fastest at t=0.5, still again at t=1."""
    return t * t * (3.0 - 2.0 * t)


if __name__ == "__main__":
    steps = 8
    linear = eased = 0.0
    for step in range(1, steps + 1):
        t = step / steps
        now_linear = lerp(0.0, 25.0, t)
        now_eased = lerp(0.0, 25.0, ease(t))
        print(f"step {step}  linear {now_linear:6.2f} (+{now_linear - linear:4.2f})"
              f"   eased {now_eased:6.2f} (+{now_eased - eased:4.2f})")
        linear, eased = now_linear, now_eased
$ uv run python -m labs.eye_control
step 1  linear   3.12 (+3.12)   eased   1.07 (+1.07)
step 2  linear   6.25 (+3.12)   eased   3.91 (+2.83)
step 3  linear   9.38 (+3.12)   eased   7.91 (+4.00)
step 4  linear  12.50 (+3.12)   eased  12.50 (+4.59)
step 5  linear  15.62 (+3.12)   eased  17.09 (+4.59)
step 6  linear  18.75 (+3.12)   eased  21.09 (+4.00)
step 7  linear  21.88 (+3.12)   eased  23.93 (+2.83)
step 8  linear  25.00 (+3.12)   eased  25.00 (+1.07)

Read the numbers in the brackets, because they are the speed. The linear column moves 3.12 degrees every step, from a standing start, all the way to a dead stop: full speed at both ends. The eased column opens with 1.07, builds to 4.59 across the middle, and closes with 1.07 again. That is what a settle is, expressed as arithmetic. Both columns reach exactly 25.00 in the same eight steps and the same total time; only the spacing differs. ease is a curve applied to the fraction, not to the angle, so the same three lines smooth a pan, a tilt, or later a brightness, without knowing what it is smoothing.

▣ Build · stage 2 — the clamp goes in front of the division
PAN_LIMIT = 25.0     # degrees each side of centre, measured on this gimbal
TILT_LIMIT = 20.0


def to_value(degrees: float, limit: float) -> float:
    """Clamp to the axis limit, then map onto the servo's -1.0..1.0 scale."""
    degrees = max(-limit, min(limit, degrees))
    return degrees / limit


if __name__ == "__main__":
    for asked in (10.0, 25.0, 40.0, -60.0):
        value = to_value(asked, PAN_LIMIT)
        print(f"asked {asked:+6.1f} deg  ->  value {value:+.2f}"
              f"  ->  reaches {value * PAN_LIMIT:+6.1f} deg")
$ uv run python -m labs.eye_control
asked  +10.0 deg  ->  value +0.40  ->  reaches  +10.0 deg
asked  +25.0 deg  ->  value +1.00  ->  reaches  +25.0 deg
asked  +40.0 deg  ->  value +1.00  ->  reaches  +25.0 deg
asked  -60.0 deg  ->  value -1.00  ->  reaches  -25.0 deg

The clamp comes first and it is written in degrees, because degrees are the unit the physical limit was measured in. Someone with a protractor found the cradle touching the yoke at twenty five, and that sentence goes into the code unchanged. Clamping afterwards, on the servo value, would work too, but then the number in the source no longer matches the number on the bench, and the day this gimbal is reprinted with more clearance nobody will know where to edit. There is a second payoff: once the angle is inside the limit, the division cannot produce anything outside -1.0 to 1.0, so the servo can no longer be handed a value it would refuse. One convention changed on the way in, and it is deliberate: these angles are measured from centre, so zero means straight ahead and the sign says which way. A servo's own scale runs from one end of its sweep to the other, which suits an arm joint and suits an eye badly, because an eye's limits are symmetric around the direction it usually points.

One method the servos hear from

▣ Build · stage 3 — a controller that remembers where it is looking
import time

from gpiozero import Servo


class EyeController:
    PAN_LIMIT = 25.0
    TILT_LIMIT = 20.0
    EASE_STEPS = 30
    EASE_DELAY = 0.012        # 30 steps x 12 ms, so a move takes 0.36 s

    def __init__(self, pan_pin: int = 12, tilt_pin: int = 13) -> None:
        self.pan = Servo(pan_pin)
        self.tilt = Servo(tilt_pin)
        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:
        """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 look_at(self, pan_deg: float, tilt_deg: float, steps: int = 0) -> None:
        """Ease from wherever the eye is now to the target angles."""
        steps = steps or self.EASE_STEPS
        from_pan, from_tilt = self._pan_deg, self._tilt_deg
        for step in range(1, steps + 1):
            t = ease(step / steps)
            self._apply(lerp(from_pan, pan_deg, t), lerp(from_tilt, tilt_deg, t))
            time.sleep(self.EASE_DELAY)

    def hold(self, pan_deg: float, tilt_deg: float) -> None:
        """Go there now, no easing: for callers running their own frame loop."""
        self._apply(pan_deg, tilt_deg)

    def where(self) -> str:
        return f"pan={self._pan_deg:+6.1f}  tilt={self._tilt_deg:+6.1f}"
$ uv run python -m labs.eye_control   # measured on the bench — yours will vary
start             pan=  +0.0  tilt=  +0.0
look_at(20, -10)  pan= +20.0  tilt= -10.0
look_at(40, 0)    pan= +25.0  tilt=  +0.0   clamped at the socket wall

look_at decides angles; _apply decides what the hardware is allowed to see. Notice that the forty degree request does not raise, log a warning, or do nothing: the eye glides as far as the gimbal goes and holds there, which is the behaviour you want when the caller is a tracker that has just seen someone stand up out of view. The two _pan_deg and _tilt_deg fields are updated only inside _apply, and only after the clamp, so the stored position is always a position the eye can actually be in. That memory is what lets the next move start from the truth instead of from an assumption about where the last one ended.

▣ Build · stage 4 — the motions that are not eased, and the ones that are
    def blink(self) -> None:
        """The one motion that should snap: shutter down, pause, back."""
        pan, tilt = self._pan_deg, self._tilt_deg
        self._apply(pan, -self.TILT_LIMIT)
        time.sleep(0.07)
        self._apply(pan, tilt)

    def track(self, waypoints: list[tuple[float, float]], dwell: float = 0.4) -> None:
        """Look at each (pan, tilt) in turn, pausing on each one."""
        for pan, tilt in waypoints:
            self.look_at(pan, tilt)
            print(f"  reached  {self.where()}")
            time.sleep(dwell)

    def detach(self) -> None:
        """Stop the pulse train so a parked servo stops buzzing."""
        self.pan.detach()
        self.tilt.detach()
$ uv run python -m labs.eye_control   # measured on the bench — yours will vary
before blink  pan= +12.0  tilt=  +8.0
after blink   pan= +12.0  tilt=  +8.0
  reached  pan= +20.0  tilt=  +0.0
  reached  pan= -20.0  tilt=  +0.0
  reached  pan=  +0.0  tilt= +15.0
  reached  pan=  +0.0  tilt=  +0.0

A blink is the exception that proves the easing rule. Eyelids do not accelerate gently, and a smoothed blink reads as a slow sad closing of the eye, so blink writes twice through the same clamped door and takes seventy milliseconds over the whole thing. It also saves the current angles first and restores them, so blinking never becomes a permanent look downwards. track gets its continuity for free: because every leg starts from the stored position, four waypoints read as one wandering look instead of four teleports. And detach matters more than it sounds. A parked servo that keeps receiving pulses hunts around its target and buzzes, which is audible on a quiet desk and noticeable in her microphone.

What her eye does while she is thinking

Volume 6 closed with three questions no amount of Python could answer, and one of them was this: what the eye should be doing during the two or three seconds between your question and her reply. Everything built so far moves the eye when told. Nothing moves it when nobody is telling it anything, and a perfectly still eye during a pause is exactly the tell that gives the prop away.

Two constraints decide the design. Motion has to continue while no call is in flight, so something must be producing frames on its own clock. And no easing loop may run inside a bus handler: look_at sleeps for 0.36 seconds, and a handler that sleeps is a conversation that has stopped. So the frames go in a thread that owns the servos, and the handlers do nothing but set a word.

▣ Build · stage 5 — fifty frames a second, and a mode that says what they mean
import math
import threading

from labs.event_bus import bus


class Gaze(threading.Thread):
    FRAME = 0.02                       # 50 frames a second
    MODES = {                          # mode: (degrees of drift, rate multiplier)
        "idle": (2.0, 1.0),
        "thinking": (6.0, 1.8),
    }

    def __init__(self, eye: EyeController) -> None:
        super().__init__(daemon=True)
        self.eye = eye
        self.mode = "idle"
        self._stop = threading.Event()

    def wander(self, elapsed: float, amplitude: float, rate: float) -> tuple[float, float]:
        """Two sines whose periods do not divide each other, so it never looks looped."""
        pan = amplitude * math.sin(2 * math.pi * 0.13 * rate * elapsed)
        tilt = 0.6 * amplitude * math.sin(2 * math.pi * 0.19 * rate * elapsed)
        return pan, tilt

    def run(self) -> None:
        started = time.monotonic()
        while not self._stop.wait(self.FRAME):
            if self.mode == "attending":
                continue                          # a handler owns the eye right now
            amplitude, rate = self.MODES[self.mode]
            self.eye.hold(*self.wander(time.monotonic() - started, amplitude, rate))

    def stop(self) -> None:
        self._stop.set()
        self.join()


def attach_to_bus(gaze: Gaze) -> None:
    def on_speech(data) -> None:
        gaze.mode = "thinking"
        print(f"[gaze] thinking: {data['text']!r}")

    def on_reply(data) -> None:
        gaze.mode = "attending"
        gaze.eye.look_at(0.0, 4.0, steps=15)      # 0.18 s: settle on whoever spoke
        gaze.eye.blink()
        print(f"[gaze] attending: {data['reply']!r}")

    bus.subscribe("speech_detected", on_speech)
    bus.subscribe("response_ready", on_reply)


if __name__ == "__main__":
    eye = EyeController()
    gaze = Gaze(eye)
    attach_to_bus(gaze)
    gaze.start()

    for _ in range(3):
        time.sleep(1.0)
        print(f"idle       {eye.where()}")
    bus.publish("speech_detected", {"text": "GLaDOS, what is the kettle doing?"})
    for _ in range(3):
        time.sleep(1.0)
        print(f"thinking   {eye.where()}")
    bus.publish("response_ready", {"reply": "Boiling. You are welcome."})
    print(f"attending  {eye.where()}")

    gaze.stop()
    eye.detach()
$ uv run python -m labs.eye_control   # measured on the bench — yours will vary
idle       pan=  +1.5  tilt=  +1.1
idle       pan=  +2.0  tilt=  +0.8
idle       pan=  +1.3  tilt=  -0.5
[gaze] thinking: 'GLaDOS, what is the kettle doing?'
thinking   pan=  -2.3  tilt=  +2.7
thinking   pan=  +5.3  tilt=  -3.5
thinking   pan=  +3.4  tilt=  +1.2
[gaze] attending: 'Boiling. You are welcome.'
attending  pan=  +0.0  tilt=  +4.0

The two drift frequencies are chosen so their pattern does not repeat for a hundred seconds, which is long past the point where a watcher would spot a loop. Idle drift stays inside two degrees and is easy to miss until you cover the eye and notice the desk has gone dead. Thinking widens to six degrees and speeds up by 80 percent, and it is the honest signal: she is looking away because she is busy, and the moment the reply lands she looks back at you and blinks. The handler can afford the settle because it is fifteen steps, 0.18 seconds, which is shorter than the gap before her first syllable comes out of the speaker.

One more thing is holding that together, and it is not obvious from the code: two threads must never write the servos at once. The "attending" branch is what prevents it. When a handler takes the eye, the frame loop sees a mode it has no behaviour for and skips its write, so the mode string is doing the work a lock would normally do. That trick holds only because exactly one writer can ever claim the eye at a time, and every claim goes through the same door.

Why this works: one door, and the unit the limit was measured in

The safety property here is stronger than "the code checks its inputs", and the difference is worth being precise about. look_at eases toward whatever it was given, including an illegal forty degrees. Every intermediate angle it computes goes into _apply, which clamps before it converts, so no individual step of a bad move can reach the hardware either. The guarantee is not that callers behave. It is that misbehaviour is unreachable, because there is no path to a servo that skips the clamp.

That is the pattern to carry into the rest of her body. Anything that can damage a physical thing gets exactly one writer, the writer validates in the unit the limit was measured in, and the public methods above it are free to be as adventurous as they like: a blink, a scripted tour of waypoints, a drift loop, and later a face tracker that has no idea what a servo value is. Chapter 69 builds the arm on the same plan with four joints instead of two, and chapter 66 does it again for brightness, where the wall is at 1.0 instead of at a printed piece of plastic.

The one thing the pattern does not do for you is get the limits right. Two axes means two numbers, and they are only twenty percent apart on this gimbal, which is close enough to look interchangeable and far enough apart to break something.

⚠ Worked failure — everything under twenty degrees works perfectly

The tilt line of _apply was written first, then copied down for pan, and one constant survived the copy:

    def _apply(self, pan_deg: float, tilt_deg: float) -> None:
        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.TILT_LIMIT     # BUG: pan, divided by the tilt limit
        self.tilt.value = tilt_deg / self.TILT_LIMIT
        self._pan_deg, self._tilt_deg = pan_deg, tilt_deg
$ uv run python -m labs.eye_control
look_at(18, 0)  pan= +18.0  tilt=  +0.0
Traceback (most recent call last):
  File "/home/you/GladOS/labs/eye_control.py", line 118, in <module>
    eye.look_at(21.0, 0.0)
  File "/home/you/GladOS/labs/eye_control.py", line 71, in look_at
    self._apply(lerp(from_pan, pan_deg, t), lerp(from_tilt, tilt_deg, t))
  File "/home/you/GladOS/labs/eye_control.py", line 58, in _apply
    self.pan.value = pan_deg / self.TILT_LIMIT
    ^^^^^^^^^^^^^^
gpiozero.exc.OutputDeviceBadValue: Servo value must be between -1 and 1, or None

Start from the two facts in the output. Eighteen degrees is fine, twenty one is fatal, and the eye stopped moving partway through the second command instead of at its first step. Both point at a threshold somewhere near twenty, and twenty is not the pan limit anywhere in the file. Search for the number: it is TILT_LIMIT, on a line that is meant to be about pan. The eased move survived twenty six of its thirty steps, reaching 19.9 degrees, and died on the step that asked for 20.4, because 20.4 divided by 20 is 1.02 and gpiozero refuses it.

The tempting repair is the wrong one. Lowering PAN_LIMIT to 20.0 makes the exception disappear, and it would look like a sensible safety decision in a diff, but the denominator is still wrong: pan would now be scaled by a constant that happens to match by accident, and the eye would quietly lose five degrees of travel it physically has. The exception was doing its job. Fix the line it names.

Checkpoint, and an eye that is still dark

✓ Checkpoint — what you can now do
  • Given the step deltas of a move, I can tell an eased profile from a linear one and say which end of the move each column is describing.
  • I can explain why the clamp is written in degrees and placed before the division, and what breaks the day the gimbal is reprinted with more clearance.
  • I can trace a look_at(40, 0) through every step it takes and say why no step of it ever reaches a servo as an illegal value.
  • I know why blink is the one motion in the class that does not ease, and why it reads the current angles before it moves.
  • I can say what the "attending" mode prevents, and why a mode string is enough to prevent it here.
  • Handed a crash at twenty one degrees on an axis whose limit is twenty five, I can work from the threshold in the output back to the wrong constant.
⚡ Exercises — try first, then reveal
Exercise 1 — print the motion profile, then break it. Make look_at print its angle every fifth step. Run a move from 0 to 20 degrees, then change one line so the start position is read inside the loop instead of before it, and run the same move again.

Reading the start inside the loop means each step interpolates from where the eye already is toward a gap that keeps shrinking. The profile inverts: the broken version is past 13.7 degrees by step ten, where the correct one is at 5.2, and it then creeps through the remaining six degrees. On the bench it looks like a lunge followed by a crawl. The reason this bug survives casual testing is in the last step: ease(1.0) is exactly 1.0, so the final write lands on the target either way, and a check that prints only the end position reports success.

Exercise 2 — let a caller ask before it commits. Add reachable(pan_deg, tilt_deg) returning whether both angles are inside their limits, and print it for (20, -10), (40, 0) and (-25, 20).

return abs(pan_deg) <= self.PAN_LIMIT and abs(tilt_deg) <= self.TILT_LIMIT gives True, False, True: the third case sits exactly on both walls and is legal. This reports intent, and it changes nothing about safety, since the clamp protects the hardware whether anyone asks or not. What it buys you is the difference between "she looked at it" and "she looked as far as she could", which is the answer a tracker needs before it decides whether to turn her whole head.

Exercise 3 (stretch) — a mode that searches. Add a "searching" mode that sweeps slowly across the full pan range and pauses at three points, and prove a mode set from another thread takes effect inside one frame.

A sweep is a triangle wave over elapsed time driven through hold, with the pause implemented as a range of elapsed values that map to the same angle rather than a sleep, since sleeping in the frame loop delays every other mode too. To measure the switch, print time.monotonic() when the handler assigns the mode and again in the first frame that reads the new value. The gap should be under twenty milliseconds, which is your frame period, and that number is the real latency of her attention.

The eye now moves the way an eye moves, and it keeps moving while she thinks, which settles the question Volume 6 left open. It is also completely dark. A gimbal drifting in an unlit socket is a mechanism; the same drift behind a lit lens is a face, and the light is what tells a person across the room whether she is listening, working, or annoyed. Chapter 66 puts twenty four addressable pixels behind that lens, on a single data wire, with brightness routed through one clamped door for the same reason the angles were.