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.
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.
One conversion, and the two hops after it
# 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.
# 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
# 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.
# 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
# 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.
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
- 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
homewrites 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
tof 1.0.
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.