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

Robotic Arm Assembly

Four joints, and no two of them travel the same distance

Her eye can find a mug on the desk and hold it in view. That is where she stops. Everything built so far senses the room or performs at it. The arm is the first part of her that changes it.

Four servos in series make the arm. A shoulder at the base carries every gram above it and takes an MG996R, rated near 9.4 kg-cm at 4.8 V and drawing something like 2.5 A if you stall it. The elbow takes the second MG996R. The wrist and the gripper carry much less and take MG90S units. All four are the device chapter 64 opened up, and all four hear exactly one thing: the width of a repeating pulse.

What is new is that no two of these joints are allowed to travel the same distance. The eye's two axes could share a convention because a gimbal is symmetric: zero is straight ahead, and the walls sit twenty five degrees away in pan and twenty in tilt. An arm has nothing so tidy. The printed shoulder bracket blocks the last fifteen degrees at each end of the servo's sweep. The elbow link runs into the upper arm at a hundred and fifty. The gripper's jaws touch each other at ninety and stand wide open at zero, so half of that servo's command range is a place this joint must never be sent. Four joints, four bands, four resting angles.

The obvious code scatters all of that. A channel number typed at each call site, a conversion copied per joint, a target written straight to the hardware. Three things then go wrong and only one of them announces itself. A joint driven past its stop grinds until a nylon tooth rounds off. A conversion that produces 1.22 gets refused by the library, loudly, somewhere in the middle of a motion. And a joint told to jump straight to a target leaves at the only speed a servo has, which on a loaded arm is a slam. So one rule holds this chapter together: each joint's output channel, its measured travel band and its home angle live in one config object, and one private method clamps in degrees, converts, writes and records the result, so a fifth joint is one line in a dictionary that inherits every protection the other four have.

◆ Note — the driver board, the pins it saves, and how to read the numbers below

Four more servos will not fit on this Pi. GPIO 10 carries the LED ring, 12 and 13 aim the eye, 17 holds the test servo from chapter 64, and 18, 19 and 21 belong to the I2S amplifier. The arm goes on a PCA9685 instead: a sixteen-channel PWM driver on the I2C bus, SDA on GPIO 2 and SCL on GPIO 3, with shoulder, elbow, wrist and gripper on channels 0 to 3. The board would be the right call even with pins to spare, because it builds all sixteen pulse trains from its own oscillator. A Pi that is transcribing speech can stretch a software-timed pulse by a few hundred microseconds; the PCA9685 holds every channel steady whatever the processor is doing. Its logic pin takes 3.3 V from the Pi, the V+ screw terminal takes the separate 5 V supply the eye servos already use, and every ground on the bench is tied together. The bands and timings below came off one arm built with one set of servos. Yours will differ.

The joint chain with its bands, and the four units one command passes through Left: a stick drawing of the arm, base to shoulder to elbow to wrist to gripper, listed with each joint's allowed band and home angle, and numbered in the order the homing routine moves them: gripper first, then wrist, elbow and shoulder last. Right: one command for the elbow's home angle passing through four units in turn, from 30 degrees, to a clamp inside the 0 to 150 band, to a servo value of -0.67, to a pulse of 1165 microseconds, to a count of 238 out of 4096 held by the driver board. THE CHAIN · BAND AND HOME PER JOINT 4 shoulder 15-165° home 90° 3 elbow 0-150° home 30° 2 wrist 0-180° home 90° 1 gripper 0- 90° home 0° leading digit: the order home() moves them shoulder elbow wrist gripper the shoulder carries every joint above it ONE COMMAND · THE ELBOW GOING HOME asked: 30.0 deg clamp into 0-150 value: -0.67 deg / 90 - 1 pulse: 1165 us 1000 + (v+1)/2 x 1000 count: 238 of 4096 the board holds that count until you change it
Figure 69.1 — Every joint is a different slice of one number line, and every command crosses the same four units on its way to a shaft angle. Only the slice changes from joint to joint.

One conversion, and the two hops after it

▣ Build · stage 1 — the map every joint shares
# labs/arm_math.py
SWEEP_DEG = 180.0        # shaft travel covered by one full command range


def joint_value(degrees: float) -> float:
    """0 deg -> -1.0, 90 deg -> 0.0, 180 deg -> +1.0, the same map chapter 64 built."""
    return degrees / (SWEEP_DEG / 2.0) - 1.0


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


if __name__ == "__main__":
    print(f"one command sweep is {SWEEP_DEG:.0f} deg: 0 -> {joint_value(0.0):+.2f}, "
          f"90 -> {joint_value(90.0):+.2f}, 180 -> {joint_value(180.0):+.2f}")
    bands = (("shoulder", 15.0, 165.0, 90.0), ("elbow", 0.0, 150.0, 30.0),
             ("wrist", 0.0, 180.0, 90.0), ("gripper", 0.0, 90.0, 0.0))
    for name, lo, hi, home in bands:
        print(f"{name:<9} band {lo:5.1f}-{hi:5.1f}  home {home:5.1f}"
              f"   value at band ends {joint_value(lo):+.2f} .. {joint_value(hi):+.2f}"
              f"   at home {joint_value(home):+.2f}")
$ uv run python -m labs.arm_math
one command sweep is 180 deg: 0 -> -1.00, 90 -> +0.00, 180 -> +1.00
shoulder  band  15.0-165.0  home  90.0   value at band ends -0.83 .. +0.83   at home +0.00
elbow     band   0.0-150.0  home  30.0   value at band ends -1.00 .. +0.67   at home -0.67
wrist     band   0.0-180.0  home  90.0   value at band ends -1.00 .. +1.00   at home +0.00
gripper   band   0.0- 90.0  home   0.0   value at band ends -1.00 .. +0.00   at home -1.00

One map serves all four joints, and that is the decision the rest of the chapter rests on. A servo's command range covers its entire sweep, near enough a hundred and eighty degrees of shaft, so degrees over ninety minus one is the whole conversion and it is chapter 64's arithmetic with a new name. What varies per joint is which slice of that map the joint may use, and nothing else. Read the two right-hand columns: the shoulder never emits anything outside plus or minus 0.83 because a printed bracket takes the rest, and the gripper spends its whole life in the bottom half, at -1.00 with the jaws open and 0.00 with them shut. Those are facts about printed parts, not about servos.

▣ Build · stage 2 — the last two units, the ones the board cares about
# labs/arm_math.py — added below joint_value
FRAME_US = 20000.0       # one 50 Hz frame
MIN_PULSE_US = 1000.0
MAX_PULSE_US = 2000.0
COUNTS = 4096            # the PCA9685 divides every frame into 12 bits


def value_to_pulse_us(value: float) -> float:
    """Undo the normalisation: a -1.0..+1.0 value becomes a duration."""
    value = max(-1.0, min(1.0, value))
    return MIN_PULSE_US + (value + 1.0) / 2.0 * (MAX_PULSE_US - MIN_PULSE_US)


def pulse_to_duty16(pulse_us: float) -> int:
    """The 16-bit number the driver library takes; the chip keeps the top 12 bits."""
    return int(pulse_us / FRAME_US * 65535)


if __name__ == "__main__":
    print(f"12 bits across a {FRAME_US / 1000:.0f} ms frame: one count is {FRAME_US / COUNTS:.2f} us")
    for value in (-1.00, -0.83, -0.67, 0.00, 0.67, 1.00):
        us = value_to_pulse_us(value)
        duty16 = pulse_to_duty16(us)
        count = duty16 >> 4
        print(f"value {value:+.2f} -> {us:7.1f} us -> duty16 {duty16:5d} -> count {count:4d}"
              f" -> {count * FRAME_US / COUNTS:7.1f} us on the wire")
$ uv run python -m labs.arm_math
12 bits across a 20 ms frame: one count is 4.88 us
value -1.00 ->  1000.0 us -> duty16  3276 -> count  204 ->   996.1 us on the wire
value -0.83 ->  1085.0 us -> duty16  3555 -> count  222 ->  1084.0 us on the wire
value -0.67 ->  1165.0 us -> duty16  3817 -> count  238 ->  1162.1 us on the wire
value +0.00 ->  1500.0 us -> duty16  4915 -> count  307 ->  1499.0 us on the wire
value +0.67 ->  1835.0 us -> duty16  6012 -> count  375 ->  1831.1 us on the wire
value +1.00 ->  2000.0 us -> duty16  6553 -> count  409 ->  1997.1 us on the wire

The library takes a sixteen-bit duty number so that one interface can cover boards of different resolutions, and this chip keeps the top twelve bits of it. Twelve bits over a twenty millisecond frame is 4.88 microseconds per count, so the board cannot hold every pulse you ask for. The worst case in that table is the bottom end: 1000 microseconds requested, 996.1 delivered, off by 3.9. Chapter 64 measured the deadband on this class of servo at around 10 microseconds of pulse, the window it treats as no error at all. The board's rounding lands inside that window, so the quantisation is not something the servo can act on, and there is no point carrying fractions of a count anywhere in the code.

One config per joint, one door to the board

▣ Build · stage 3 — four numbers that describe a joint
# labs/arm_config.py
from dataclasses import dataclass


@dataclass(frozen=True)
class JointConfig:
    """One joint's output channel and the travel measured on the built arm."""
    channel: int
    min_deg: float
    max_deg: float
    home_deg: float


JOINTS: dict[str, JointConfig] = {
    "shoulder": JointConfig(channel=0, min_deg=15.0, max_deg=165.0, home_deg=90.0),
    "elbow":    JointConfig(channel=1, min_deg=0.0,  max_deg=150.0, home_deg=30.0),
    "wrist":    JointConfig(channel=2, min_deg=0.0,  max_deg=180.0, home_deg=90.0),
    "gripper":  JointConfig(channel=3, min_deg=0.0,  max_deg=90.0,  home_deg=0.0),
}


def clamp(cfg: JointConfig, degrees: float) -> float:
    """Pin a request into the band this joint physically has."""
    return max(cfg.min_deg, min(cfg.max_deg, degrees))


if __name__ == "__main__":
    print(JOINTS["gripper"])
    for name, asked in (("elbow", 90.0), ("elbow", 200.0), ("elbow", -20.0),
                        ("shoulder", 250.0), ("shoulder", 5.0), ("gripper", 120.0)):
        cfg = JOINTS[name]
        reached = clamp(cfg, asked)
        note = "" if reached == asked else "   clamped at this joint's own stop"
        print(f"{name:<9} asked {asked:+7.1f} -> {reached:6.1f} deg{note}")
$ uv run python -m labs.arm_config
JointConfig(channel=3, min_deg=0.0, max_deg=90.0, home_deg=0.0)
elbow     asked   +90.0 ->   90.0 deg
elbow     asked  +200.0 ->  150.0 deg   clamped at this joint's own stop
elbow     asked   -20.0 ->    0.0 deg   clamped at this joint's own stop
shoulder  asked  +250.0 ->  165.0 deg   clamped at this joint's own stop
shoulder  asked    +5.0 ->   15.0 deg   clamped at this joint's own stop
gripper   asked  +120.0 ->   90.0 deg   clamped at this joint's own stop

The dataclass earns its place three ways. It names the four numbers, so nobody has to remember whether the tuple went channel-min-max-home or channel-home-min-max. It prints itself when something looks wrong, which is the line at the top of that output. And it is frozen, because those numbers are measurements of a physical arm: a routine that quietly widens a limit at runtime has invented travel the printed parts do not have. Note also what the clamp catches at the bottom of the band. A shoulder asked for 5 degrees comes back at 15, and that guard is as necessary as the one at 250, since the bracket is in the way at both ends.

▣ Build · stage 4 — the driver, the class, and going home in order
# labs/arm_driver.py
import board
import busio
from adafruit_pca9685 import PCA9685

from labs.arm_math import pulse_to_duty16, value_to_pulse_us


class ChannelDriver:
    """Owns the PCA9685 and speaks the same -1.0..+1.0 value the servos took before."""

    def __init__(self, frequency: int = 50) -> None:
        self._pca = PCA9685(busio.I2C(board.SCL, board.SDA))
        self._pca.frequency = frequency

    def write(self, channel: int, value: float) -> None:
        value = max(-1.0, min(1.0, value))
        self._pca.channels[channel].duty_cycle = pulse_to_duty16(value_to_pulse_us(value))

    def relax(self, channel: int) -> None:
        """No pulse at all, so the joint stops holding torque."""
        self._pca.channels[channel].duty_cycle = 0


class RecordingDriver:
    """Records writes instead of making them, so the logic runs on a laptop."""

    def __init__(self) -> None:
        self.writes = 0
        self.last: dict[int, float] = {}

    def write(self, channel: int, value: float) -> None:
        self.writes += 1
        self.last[channel] = max(-1.0, min(1.0, value))

    def relax(self, channel: int) -> None:
        self.last.pop(channel, None)
# labs/arm_control.py
import time

from labs.arm_config import JOINTS, JointConfig
from labs.arm_math import joint_value, lerp

STEPS = 40
STEP_DELAY = 0.015
HOME_ORDER = ("gripper", "wrist", "elbow", "shoulder")


class RoboticArm:
    def __init__(self, driver, configs: dict[str, JointConfig] = JOINTS) -> None:
        self._driver = driver
        self._configs = configs
        self._angles = {name: cfg.home_deg for name, cfg in configs.items()}

    def _set_joint(self, name: str, degrees: float) -> float:
        """The only place in the program that writes to a channel."""
        cfg = self._configs[name]
        deg = max(cfg.min_deg, min(cfg.max_deg, degrees))
        self._driver.write(cfg.channel, joint_value(deg))
        self._angles[name] = deg
        return deg

    def home(self) -> None:
        """One joint at a time, tip first, so the heavy one moves last and alone."""
        for name in HOME_ORDER:
            cfg = self._configs[name]
            deg = self._set_joint(name, cfg.home_deg)
            print(f"  {name:<8} ch{cfg.channel}  {deg:6.1f} deg  value {joint_value(deg):+.2f}")
            time.sleep(0.4)

    def pose(self) -> str:
        return "  ".join(f"{n}={self._angles[n]:6.1f}"
                         for n in ("shoulder", "elbow", "wrist", "gripper"))
$ uv run python -m labs.arm_dry_run
homing: gripper, wrist, elbow, shoulder
  gripper  ch3     0.0 deg  value -1.00
  wrist    ch2    90.0 deg  value +0.00
  elbow    ch1    30.0 deg  value -0.67
  shoulder ch0    90.0 deg  value +0.00
at home           shoulder=  90.0  elbow=  30.0  wrist=  90.0  gripper=   0.0

The driver arrives as a constructor argument, which is what lets that output come off a laptop with RecordingDriver and off the Pi with ChannelDriver without touching a line of the arm. More importantly it keeps the class ignorant of I2C: swap in a board with a different chip and nothing above write changes.

home is the one motion in the class that writes a target directly, and the reason is that nothing else can. A servo reports nothing back, so at power-up the code has no idea where the arm actually is, and there is no honest starting angle to interpolate from. Every other method leans on _angles, which is only true once homing has made it true. Moving one joint at a time with four tenths of a second between them is what keeps that first blind write survivable: the gripper and wrist fold in while they are still near the tip and cheap to swing, and the shoulder travels last, alone, with nothing else moving. On the first run of a newly built arm, keep a hand near the supply switch through that last step.

Three joints, one motion

▣ Build · stage 5 — reaching, gripping, and letting go
# labs/arm_control.py — continued inside RoboticArm

    def reach(self, shoulder_deg: float, elbow_deg: float,
              wrist_deg: float, steps: int = STEPS) -> None:
        """Walk all three arm joints to their targets together, in equal steps."""
        targets = {"shoulder": shoulder_deg, "elbow": elbow_deg, "wrist": wrist_deg}
        starts = {name: self._angles[name] for name in targets}
        for step in range(1, steps + 1):
            t = step / steps
            for name, target in targets.items():
                self._set_joint(name, lerp(starts[name], target, t))
            time.sleep(STEP_DELAY)

    def grab(self) -> None:
        self._set_joint("gripper", self._configs["gripper"].max_deg)

    def release(self) -> None:
        self._set_joint("gripper", self._configs["gripper"].home_deg)

    def relax(self) -> None:
        """Stop every pulse train so four servos stop holding against gravity."""
        for cfg in self._configs.values():
            self._driver.relax(cfg.channel)
# labs/arm_demo.py
import signal
import sys
import time

from labs.arm_control import RoboticArm
from labs.arm_driver import ChannelDriver


def main() -> None:
    arm = RoboticArm(ChannelDriver())

    def on_signal(signum: int, frame: object) -> None:
        arm.relax()
        sys.exit(0)

    signal.signal(signal.SIGINT, on_signal)
    signal.signal(signal.SIGTERM, on_signal)

    print("homing: " + ", ".join(("gripper", "wrist", "elbow", "shoulder")))
    arm.home()
    print(f"at home           {arm.pose()}")

    started = time.monotonic()
    arm.reach(45.0, 90.0, 90.0)
    print(f"reach(45, 90, 90) {arm.pose()}   {time.monotonic() - started:.2f} s")

    arm.grab()
    print(f"grab              {arm.pose()}")
    arm.reach(250.0, 90.0, 90.0)
    print(f"reach(250,90,90)  {arm.pose()}   shoulder clamped")
    arm.release()
    print(f"release           {arm.pose()}")
    arm.relax()


if __name__ == "__main__":
    main()
$ uv run python -m labs.arm_demo   # measured on the bench — yours will vary
homing: gripper, wrist, elbow, shoulder
  gripper  ch3     0.0 deg  value -1.00
  wrist    ch2    90.0 deg  value +0.00
  elbow    ch1    30.0 deg  value -0.67
  shoulder ch0    90.0 deg  value +0.00
at home           shoulder=  90.0  elbow=  30.0  wrist=  90.0  gripper=   0.0
reach(45, 90, 90) shoulder=  45.0  elbow=  90.0  wrist=  90.0  gripper=   0.0   0.64 s
grab              shoulder=  45.0  elbow=  90.0  wrist=  90.0  gripper=  90.0
reach(250,90,90)  shoulder= 165.0  elbow=  90.0  wrist=  90.0  gripper=  90.0   shoulder clamped
release           shoulder= 165.0  elbow=  90.0  wrist=  90.0  gripper=   0.0

The three joints are interleaved inside one step loop, not run one after another, and the difference is visible from across the room. Sequential moves draw a staircase through the workspace: the shoulder swings while the forearm is still folded, then the elbow opens into whatever is now in front of it, and a wrist that started low drags across the desk on the way. Stepping them together keeps the pose consistent all the way through, because every joint is the same fraction of its own journey at every step.

Nothing here eases. That is deliberate, and it is where the arm parts company with the eye. Smoothstep exists to make a gaze read as attention, and its peak rate is exactly 1.5 times its average, since the derivative of the curve tops out at t equal to a half. On two unloaded servos moving twenty five degrees that costs nothing. On three servos carrying an arm it means the middle of every motion draws half again the current of a flat profile, right when all three are moving fastest. A constant rate also gives you a number you can defend: this move is 45 degrees of shoulder over 40 steps, which is 1.125 degrees a step, and no step ever asks for more. Forty steps of 15 milliseconds is 0.60 seconds of sleeping, and the run above took 0.64, the extra coming from 120 I2C transactions on a bus clocked at 100 kHz.

Why this works: the map is shared, the limits are not

Two facts have to hold at once for this arm to be safe. No command may leave the servo's legal value range, or the library raises in the middle of a motion. And no command may leave a joint's physical band, or a printed part takes the load. Clamping first and converting second gives you both from one line of arithmetic. The clamped angle is inside [min_deg, max_deg], every band is inside the servo's own 0 to 180 sweep, and a linear map sends an interval inside its domain to an interval inside its range. There is no way to reach a channel that skips _set_joint, so callers never have to follow the rule; they have no way to break it.

There is a tempting variant of that map worth refusing on purpose. Normalise into the joint's own band, (degrees - min_deg) / (max_deg - min_deg) * 2 - 1, and each joint gets its full command range back. It reads cleanly and it quietly redefines what max_deg means: the elbow's 150 now goes out as +1.0, which is the far end of the servo's sweep, 180 on the shaft, thirty degrees past the link's stop. The same stretch sends the elbow's home angle out as -0.60 instead of -0.67 and parks the joint at 36 degrees while the code believes it is at 30. A map has to be anchored to something physical, and on a direct-drive joint the only physical thing available is the servo's own sweep.

The pattern generalises past servos. Anything that can damage a physical object gets one writer; the writer validates in the unit the limit was measured in; the config that holds those limits is immutable, because it describes an object that already exists. Chapter 73 keeps every one of those properties and only changes the numbers, replacing bands read off a drawing with bands driven and measured on the built arm.

⚠ Worked failure — the gripper that closes on nothing

The arm homes, reaches, and looks right. Then it starts failing at the last inch: the jaws shut on air, or shut so lightly the mug slides out on the way up. There is no exception and no warning, so the suspicion goes to the servos, the linkage, the gripper spring. It is none of those. Here is the stepping loop on its own, written the way most people write a loop, with the interpolation checked against three real moves:

# labs/arm_endpoint.py
STEPS = 40


def lerp(a: float, b: float, t: float) -> float:
    return a + (b - a) * t


def travel(start: float, target: float, steps: int = STEPS) -> float:
    angle = start
    for step in range(steps):           # BUG: 0..39, so t stops at 0.975
        angle = lerp(start, target, step / steps)
    return angle


if __name__ == "__main__":
    for name, start, target in (("elbow", 30.0, 90.0),
                                ("shoulder", 90.0, 45.0),
                                ("gripper", 0.0, 90.0)):
        end = travel(start, target)
        print(f"{name:<9} {start:5.1f} -> asked {target:5.1f}   loop ended at {end:6.2f}"
              f"   {abs(target - end):.2f} deg short")
$ uv run python -m labs.arm_endpoint
elbow      30.0 -> asked  90.0   loop ended at  88.50   1.50 deg short
shoulder   90.0 -> asked  45.0   loop ended at  46.12   1.12 deg short
gripper     0.0 -> asked  90.0   loop ended at  87.75   2.25 deg short

Every joint stops short, and the shortfall scales with how far the joint was asked to travel: 1.12 degrees over a 45 degree move, 1.50 over 60, 2.25 over 90. A constant fraction missing from every move points at the fraction itself. range(40) yields 0 through 39, so the last t the loop computes is 39 divided by 40, which is 0.975, and lerp is only asked for 97.5 percent of the journey. The fix is range(1, steps + 1), so the final t is 40 over 40 and the last write lands on the target exactly.

Two things about this bug are worth noticing. It cannot raise, because 88.5 is a perfectly legal elbow angle and the clamp has no opinion about it. And it lies in _angles: the arm records 88.5, so the next move starts from a true reading of a wrong position and the error never accumulates into something loud enough to find. The gripper is where it finally bites, since 2.25 degrees of jaw is about the width of the mug handle you were trying to hold. Whenever a loop interpolates toward an endpoint, check that it actually emits a t of 1.0.

Checkpoint, and four servos pulling at once

✓ Checkpoint — what you can now do
  • I can say why all four joints share one degrees-to-value map while each keeps its own band, and what the band's two ends describe physically.
  • Given a joint angle, I can follow it through degrees, value, microseconds and board counts, and name what rounds where.
  • I can explain why the PCA9685's 4.88 microseconds per count is not a precision problem for this servo.
  • I know why home writes directly instead of stepping, and why the gripper moves before the shoulder.
  • I can argue why the arm steps at a flat rate when the eye eases, in terms of peak rate and current.
  • Handed an arm that stops a degree or two short of every target, I can work from the proportional shortfall back to a loop that never reaches a t of 1.0.
⚡ Exercises — try first, then reveal
Exercise 1 — a pose library. Add a POSES dictionary of named angle sets and a go_to_pose(name) method that reaches one, then run two poses in a row and print where the arm ended up.

A pose is data, so the method does nothing but look it up and hand it to reach:

POSES = {
    "wave":  {"shoulder": 120.0, "elbow": 60.0,  "wrist": 90.0},
    "point": {"shoulder": 90.0,  "elbow": 150.0, "wrist": 0.0},
}


class PosedArm(RoboticArm):
    def go_to_pose(self, name: str) -> None:
        p = POSES[name]
        self.reach(p["shoulder"], p["elbow"], p["wrist"])
        print(f"pose {name!r}: {self.pose()}")
$ uv run python -m labs.arm_poses
pose 'point': shoulder=  90.0  elbow= 150.0  wrist=   0.0  gripper=   0.0
pose 'wave': shoulder= 120.0  elbow=  60.0  wrist=  90.0  gripper=   0.0

The elbow reaching exactly 150.0 is the interesting line: that is its max_deg, so the pose sits on the wall and the clamp lets it through untouched. A pose written with 160 in it would land in the same place and never say so, which is the argument for keeping poses beside the config that bounds them.

Exercise 2 — hand out the current pose safely. Add current_pose() that returns the joint angles, take a snapshot, move the arm, then print both the snapshot and the live values.

return dict(self._angles) is the whole method, and the copy is the point:

$ uv run python -m labs.arm_snapshot
snapshot: {'shoulder': 90.0, 'elbow': 150.0, 'wrist': 0.0, 'gripper': 0.0}
live now: {'shoulder': 120.0, 'elbow': 60.0, 'wrist': 90.0, 'gripper': 0.0}

Return self._angles itself and the snapshot changes under the caller, because both names point at one dictionary. Worse, a caller holding it can assign into it and desynchronise the arm's record from the arm, with no write to a channel anywhere in sight. A copy on the way out costs four floats and keeps _set_joint the only thing that can change what the arm believes.

Exercise 3 (stretch) — turn the step count into a speed limit. Decide the largest per-step jump you will allow any joint, then compute how many steps a full-travel move needs on each joint and how long it would take.
import math

from labs.arm_config import JOINTS
from labs.arm_control import STEP_DELAY

MAX_STEP_DEG = 2.0

for name, cfg in JOINTS.items():
    travel = cfg.max_deg - cfg.min_deg
    need = math.ceil(travel / MAX_STEP_DEG)
    print(f"{name:<9} travel {travel:5.1f} deg   40 steps = {travel / 40:4.2f} deg/step   "
          f"needs {need:3d} steps ({need * STEP_DELAY:.2f} s)")
$ uv run python -m labs.arm_speed
shoulder  travel 150.0 deg   40 steps = 3.75 deg/step   needs  75 steps (1.12 s)
elbow     travel 150.0 deg   40 steps = 3.75 deg/step   needs  75 steps (1.12 s)
wrist     travel 180.0 deg   40 steps = 4.50 deg/step   needs  90 steps (1.35 s)
gripper   travel  90.0 deg   40 steps = 2.25 deg/step   needs  45 steps (0.67 s)

A fixed step count is a fixed duration, so the per-step jump rises with the distance travelled: the same 40 steps that give a 45 degree move a gentle 1.125 degrees a step give a full wrist sweep 4.5. Deriving the count from the distance instead, with steps = ceil(distance / MAX_STEP_DEG), makes short moves quick and long moves slow, which is what a limit on speed means. Drive the arm both ways with a load in the gripper and the flat step budget is the one that stops lurching at the start of a long swing.

She can reach for something now, close on it, and put it down. The demo above did that gently on an unloaded arm, and the interesting question is what the same motion does to the rest of her: four servos accelerating together pull current in bursts, an MG996R stalls at something near 2.5 A, and the LED ring and the Pi share that supply. A rail that sags for a few milliseconds does not print a warning, it corrupts a write or reboots the board mid-sentence. Chapter 70 puts an ADC on the battery so the voltage stops being a guess.