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.
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.
The two numbers between an angle and a servo
# 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.
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
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.
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.
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.
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
- 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
blinkis 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.
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.