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.

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 pinWhat's on itDetail
2 (5V)Servo board V+moved here 2026-07-13 (fresh contact)
3 (GPIO2)Servo board SDAI2C data
4 (5V)L298N +5V logicthe worn pin — OK for the L298N's tiny draw; watch it
5 (GPIO3)Servo board SCLI2C clock
6 (GND)Servo board GNDmoved here 2026-07-13
11 (GPIO17)L298N IN2right side backward
13 (GPIO27)L298N IN4left side backward
14 (GND)L298N GNDfresh ground, assigned at the rebuild
15 (GPIO22)L298N IN3left side forward
16 (GPIO23)L298N IN1right side forward — remapped, was pin 12
18 (GPIO24)L298N ENBleft enable
22 (GPIO25)L298N ENAright 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.