Fixing the Eyes
Currently, my little robot has no way to actually look for a target. Our little raspberry pi camera just flaccidly flops across the top of the build. Now that is a problem. If the shoe or whatever target is even slightly off center, the whole chassis has to turn, kind of like Michael Keaton in the old Batman Movies.
@propway If Keaton couldn’t turn his head, then its only right that I can’t either! #batman #thebatman #theflash #dc #dcuniverse #cosplay #flash #superman #supergirl ♬ Batman (1989): Main Theme - Dominik Hauser
Enter the Servos. This little $25 camera attachment will let Claude turn its little camera a glorious 180 degrees of motion. This will be more than enough for our purposes.
A Long Terrible Side Adventure
Now at this point in the build, I had experienced very little heart ache. I would even go as far as to say that most things worked as they expected. This was about to end in a stunning way. I hooked up the servos pretty easily.
You see, the L298N requires both a 5V pin and a ground on the raspberry pi to work. And so does the Servos. And fortunately, there are two 5V pins and like five or so grounds on the raspberry pi, so one would imagine that the I could simply plug in the Servos to the spare 5V pin, and we would be cooking. But one would be wrong.
I did that and at first, everything seemed fine. I had Claude write some code to test out the camera controls, but once I tried integrating them into the actual vision_loop.py script I noticed that the left wheels started spinning whenever the camera was working. Further, when I ran test_motors.py, the car refused to go backward. Eventually Claude was able to identify the culprit. Apparently, the IN1 pin would carry a high current even when the raspberry pi did not signal one.
Claude would tell me very confidently that the L298N was dead. But the solution was to simply clean a little piece of duct tap that had attached itself to the bottom of the chip. At least, I think that was the solution. I unplugged everything and plugged it back in and it suddenly worked again!
Hooray!
But now the servos was unresponsive. Claude then confidently told me that the raspberry pi recognized that there was a device plugged into it but would drop the connection whenever a signal was sent to it, so I needed to buy a new servos. Clearly whatever short had temporarily fried my L298N had permanently killed my Servos.
So I ordered a new one.
Same issue.
Then I tried switching the Servos to be on the 5V pin that L298N was on and vice versa. Against all logic, that fixed everything. Long story short, this thing SUCKS.
What Them Wires Do
The Servos has 4 wires that plug into the Raspberry Pi. This one is remarkably simpler than the L298N.
- VCC — Power (5V from your Pi's 5V pin)
- GND — Ground
- SDA — I2C data line (GPIO 2 on Pi)
- SCL — I2C clock line (GPIO 3 on Pi)
Basically, Servos uses something called the I2C protocol to allow the chip on the Servos to communicate with the raspberry pi. You can read more about it here if this is interesting to you.
Wiring Table
At this point, it is worth just keeping track of exactly what is plugged into what.
| Pi physical pin | What's on it | Detail |
|---|---|---|
| 2 (5V) | Servo board V+ | moved here 2026-07-13 (fresh contact) |
| 3 (GPIO2) | Servo board SDA | I2C data |
| 4 (5V) | L298N +5V logic | the worn pin — OK for the L298N's tiny draw; watch it |
| 5 (GPIO3) | Servo board SCL | I2C clock |
| 6 (GND) | Servo board GND | moved here 2026-07-13 |
| 11 (GPIO17) | L298N IN2 | right side backward |
| 13 (GPIO27) | L298N IN4 | left side backward |
| 14 (GND) | L298N GND | fresh ground, assigned at the rebuild |
| 15 (GPIO22) | L298N IN3 | left side forward |
| 16 (GPIO23) | L298N IN1 | right side forward — remapped, was pin 12 |
| 18 (GPIO24) | L298N ENB | left enable |
| 22 (GPIO25) | L298N ENA | right enable |
Now for the Code
Actually controlling the Servos is pretty simple. First we are going to create a couple of constants at the top of the script.
PAN_CHANNEL = 1
TILT_CHANNEL = 0
# Calibrated on hardware 2026-07-09: for both axes, 0=left/down, 180=right/up,
# 90=forward. See handoff.md before changing these.
PAN_FORWARD = 90
TILT_FORWARD = 90
# Default resting tilt: 30° below level, so the camera looks down at the
# floor ahead (where a shoe would be) instead of dead level.
TILT_DEFAULT = 60
The PAN_CHANNEL and TILT_CHANNEL simply specify which motor needs to be adjusted on the servos. We also need some defaults. The Servos can pan 180 degrees, so PAN_FORWARD is set to 90 degrees which is the middle. The same is true of the TILT_FORWARD . However, I noticed that too much ceiling was being captured by the camera, so I actually had it rest at 60 degrees instead.
We will create a class called PanTilt to control the Servos. On initialization, we set two values, one to control the angle of the tilt and one for the angle of the pan:
self.pan_angle = PAN_FORWARD
self.tilt_angle = TILT_DEFAULT
Our PanTilt class has only a few methods that are worth covering. Starting with the set_pan method, we basically pass one argument into it, the angle, which we use to update self.pan_angle :
def set_pan(self, angle: float):
angle = max(0, min(180, angle))
if self.use_gpio and self.kit:
self.kit.servo[PAN_CHANNEL].angle = angle
self.pan_angle = angle
logger.info(f"[SERVO] pan -> {angle}° (channel {PAN_CHANNEL})")
set_tilt does the same but for the tilt angle:
def set_tilt(self, angle: float):
angle = max(0, min(180, angle))
if self.use_gpio and self.kit:
self.kit.servo[TILT_CHANNEL].angle = angle
self.tilt_angle = angle
logger.info(f"[SERVO] tilt -> {angle}° (channel {TILT_CHANNEL})")
Next we have the center method which just puts everything back to default:
def center(self):
"""Return pan to forward and tilt to the default 10°-down resting angle."""
self.set_pan(PAN_FORWARD)
self.set_tilt(TILT_DEFAULT)
After the days I spent debugging issues with both the servos and the L298N, I wanted to ensure that both devices were NEVER drawing power at the same time, so I wrote in a little function to cut all power the Servos, which will be called before its time to use the L298N:
def relax(self):
"""
Stop sending pulses to both servos so they draw no holding current
(the gear train holds the lightweight camera on its own — confirmed
on this hardware, the mount doesn't droop unpowered). Any later
set_pan/set_tilt/center re-energizes them automatically.
"""
if self.use_gpio and self.kit:
self.kit.servo[PAN_CHANNEL].angle = None
self.kit.servo[TILT_CHANNEL].angle = None
logger.info("[SERVO] relaxed -> pulses off, no holding current")
Here's the full code:
import logging
logger = logging.getLogger(__name__)
PAN_CHANNEL = 1
TILT_CHANNEL = 0
# Calibrated on hardware 2026-07-09: for both axes, 0=left/down, 180=right/up,
# 90=forward. See handoff.md before changing these.
PAN_FORWARD = 90
TILT_FORWARD = 90
# Default resting tilt: 30° below level, so the camera looks down at the
# floor ahead (where a shoe would be) instead of dead level.
TILT_DEFAULT = 60
class PanTilt:
"""
Controls the Arducam pan-tilt mount (PCA9685, I2C 0x40) that the camera
is mounted on. Simulation mode auto-fallback if the servo board isn't
reachable, mirroring MotorController's use_gpio pattern.
Per handoff.md's hard rule, callers are responsible for never moving
these servos while the drive motors are active, and for settling
(>=500ms) after a servo move before touching the motors, and vice versa
— this class only knows about the servos.
"""
def __init__(self, use_gpio: bool = True):
self.use_gpio = use_gpio
self.kit = None
if use_gpio:
try:
from adafruit_servokit import ServoKit
self.kit = ServoKit(channels=16)
except ImportError:
logger.warning("adafruit-circuitpython-servokit not available; running pan-tilt in simulation mode")
self.use_gpio = False
except Exception as e:
logger.warning(f"Could not connect to pan-tilt servo board ({e}); running pan-tilt in simulation mode")
self.use_gpio = False
else:
logger.info("Running pan-tilt in simulation mode")
self.pan_angle = PAN_FORWARD
self.tilt_angle = TILT_DEFAULT
def set_pan(self, angle: float):
angle = max(0, min(180, angle))
if self.use_gpio and self.kit:
self.kit.servo[PAN_CHANNEL].angle = angle
self.pan_angle = angle
logger.info(f"[SERVO] pan -> {angle}° (channel {PAN_CHANNEL})")
def set_tilt(self, angle: float):
angle = max(0, min(180, angle))
if self.use_gpio and self.kit:
self.kit.servo[TILT_CHANNEL].angle = angle
self.tilt_angle = angle
logger.info(f"[SERVO] tilt -> {angle}° (channel {TILT_CHANNEL})")
def center(self):
"""Return pan to forward and tilt to the default 10°-down resting angle."""
self.set_pan(PAN_FORWARD)
self.set_tilt(TILT_DEFAULT)
def relax(self):
"""
Stop sending pulses to both servos so they draw no holding current
(the gear train holds the lightweight camera on its own — confirmed
on this hardware, the mount doesn't droop unpowered). Any later
set_pan/set_tilt/center re-energizes them automatically.
"""
if self.use_gpio and self.kit:
self.kit.servo[PAN_CHANNEL].angle = None
self.kit.servo[TILT_CHANNEL].angle = None
logger.info("[SERVO] relaxed -> pulses off, no holding current")
def cleanup(self):
"""No GPIO handle to release — the PCA9685 holds its last position in hardware."""
pass
Next, we update vision_loop to handle the car being able to turn its head when it looks.