Phase 4: Pack from Python
In this phase you write palletizer.py, a Python script that uses the two anchor poses from Phase 3 to drive the arm through a packing routine for each of the four bottom-layer cells.
Set up the companion project
Clone the workshop’s companion repository and work from it for the rest of this phase:
git clone https://github.com/viam-devrel/mini-palletizer.git
cd mini-palletizer
The shell commands in this tutorial are designed to use uv. The project ships with a pyproject.toml, so uv run resolves and installs the Viam Python SDK (software development kit) for you the first time you run any script in the directory. If you are not using uv, install viam-sdk yourself and use python3 instead.
helpers.py is provided for you as part of the companion project. You set five variables in this file for use in your procedural code.
First, open the machine’s CONNECT tab in the Viam app, select Python SDK, toggle Include API key, and copy the machine address and the API key and key ID pair it shows you. Paste these values into MACHINE_ADDRESS, API_KEY_ID, and API_KEY in helpers.py.
Next, set the x, y, and z of the two constants STAGING_POSE and PALLET_ORIGIN to the two gripper poses you captured by hand in Phase 3. palletizer.py reads both from helpers.py, so this is where the numbers you recorded become the code’s picking and stacking targets.
helpers.py sets ARM and GRIPPER to arm-1 and gripper-1, the names you gave these components in Phase 2. If you named yours differently, change these two values to match. MOTION should remain set to “builtin”.
What the helpers give you
You will import helpers.py to handle connection code and grid math. It gives you:
helpers.connect(), anasyncfunction that returns a connectedRobotClient.- The component and service names, as plain strings:
helpers.GRIPPER, which you pass both to the motion service and tofrom_robot;helpers.MOTION, the motion service; andhelpers.ARM, for arm-level calls you do not need in this workshop. down_pose(x, y, z), which returns aPoseat that position with the tool pointing straight down.helpers.grid(origin, pitch, cube), which expands one origin corner into the eight target poses of a two-layer, four-cell pallet (explained in the next section).helpers.STAGING_POSEandhelpers.PALLET_ORIGIN, the two anchor poses you captured by hand in Phase 3.
palletizer.py imports these names and composes them into motion calls.
Build palletizer.py
Create a file in the same directory called palletizer.py. Build the file up one method at a time. Each piece below is small enough to test on its own before you move to the next.
The class and connection
Start with the imports, the constants needed in this phase, and a Palletizer class that holds a motion client and a gripper handle:
import asyncio
import sys
from viam.components.gripper import Gripper
from viam.services.motion import MotionClient
from viam.proto.common import Pose, PoseInFrame
import helpers
from helpers import down_pose
PITCH = 30 # mm, center-to-center spacing between adjacent pallet cells
CUBE = 16 # mm, cube side length, and the z offset between layers
APPROACH = 40 # mm, hover height above a pose before descending
GRASP_DEPTH = 9 # mm, how far below the cube's top face the fingertips descend before closing
GRIP_PERCENTAGE = 20 # percentage, the gripper width that holds your cubes; start wide, calibrate down
class Palletizer:
def __init__(self, robot):
self.robot = robot
self.motion = MotionClient.from_robot(robot, helpers.MOTION)
self.gripper = Gripper.from_robot(robot, helpers.GRIPPER)
self.placed = []
PITCH and CUBE are the constants for the pallet grid. APPROACH and GRASP_DEPTH are new: APPROACH is how high above a target pose the gripper hovers before descending, and GRASP_DEPTH is how far below a cube’s top face the fingertips go before the jaws close on it. Your anchor poses put the fingertips level with a cube’s top face, so every grasp and release descends GRASP_DEPTH below the taught height.
GRASP_DEPTH and GRIP_PERCENTAGE are calibration values that depend on your cubes and your arm, and you test and adjust both later in this phase. GRIP_PERCENTAGE starts deliberately wide: you narrow it until the jaws hold a cube, because gripping too tightly can overload the gripper servo.
self.robot accepts a connection to your Viam machine, provided by helpers.py. self.motion and self.gripper hold client objects for those aspects of your machine.
self.placed tracks which grid cells already hold a cube.
Command line plumbing
Below the Palletizer class, add a main function you’ll use to give your robot instructions from the command line.
STEPS = {
# Expose Palletizer methods as commands
}
async def main(verb):
robot = await helpers.connect()
palletizer = Palletizer(robot)
try:
step = STEPS.get(verb)
if step is None:
print(f"Unknown step '{verb}'. Steps: {', '.join(STEPS)}")
return
await step(palletizer)
except BaseException:
await robot.stop_all()
raise
finally:
await robot.close()
if __name__ == "__main__":
verb = sys.argv[1] if len(sys.argv) > 1 else "pack"
asyncio.run(main(verb))
The except block is a safety net for later, once the arm is moving. If a step raises an error, or you press Ctrl+C to interrupt it, robot.stop_all() tells every component on the machine to stop before the script disconnects, because closing the connection is not guaranteed to halt a motion already in progress. raise then passes the error along, so you still see what went wrong. BaseException catches Ctrl+C as well as ordinary errors.
At this point, you can run the program to ensure your connection is correctly configured:
uv run palletizer.py
You should see the “Unknown step” message configured in main. If the script raises a connection error, recheck the machine address and API key in helpers.py against the CONNECT tab.
move_gripper
Every arm motion in this workshop follows the same pattern: give the motion service a destination pose and let it plan a path to the destination. Add this method to the Palletizer class:
async def move_gripper(self, pose: Pose):
destination = PoseInFrame(reference_frame="world", pose=pose)
await self.motion.move(
component_name=helpers.GRIPPER,
destination=destination,
world_state=None,
)
The motion service moves the gripper, specifically the point between its fingertips, to the pose you provide, working out the arm joint motions needed to get it there. This is the same gripper frame you read your anchor poses in, so the taught numbers carry over unchanged. The planner also accounts for the gripper’s shape. world_state=None because this phase has no obstacles to avoid yet; Phase 5 adds them.
Add a small test method to the class: scratch space you rewrite each time you want to try out a piece as you build it. Start it off with a pose returned from the down_pose helper:
async def test(self):
"""Scratch space for testing individual pieces as you build them."""
await self.move_gripper(down_pose(160, 0, 60))
Add the method in STEPS to expose it in your command line plumbing:
STEPS = {
"test": Palletizer.test
}
And test it by providing “test” as an argument:
uv run palletizer.py test
Checkpoint
grip_percentage
Viam’s gripper component API provides several commands as part of the gripper module. You can test Open and Grab from your gripper’s Control card in the Viam app.
Packing a pallet tightly requires more precise gripper control. The module also enables the do_command method, which is used to communicate commands to a component outside of standard API functions. We can use set_position to open or close the gripper to a specific percentage.
Add a method to your class to accept a percentage, from 0 (fully closed) to 100 (fully open):
async def grip_percentage(self, percentage: float):
await self.gripper.do_command({
"command": "set_position",
"percentage": percentage
})
To test, replace the body of test with a call to grip_percentage and run the program again:
async def test(self):
"""Scratch space for testing individual pieces as you build them."""
await self.grip_percentage(22)
uv run palletizer.py test
The SO-101’s gripper servo can overload if commanded to grip a solid object more tightly than the space allows. Hold one of your cubes between the gripper jaws and adjust the value you provide to grip_percentage in small increments to determine the precise percentage needed to grip one of your cubes. You can also use do_command with the same JSON syntax directly from the gripper’s test card in the Viam app.
Once you have determined the appropriate percentage, adjust the GRIP_PERCENTAGE constant in your program.
pick
pick reads the fixed staging pose, then uses the move_gripper and grip_percentage methods to hover above it, descend onto the cube, close the gripper to the percentage you determined in the last step, and lift back clear. Add it to the Palletizer class:
async def pick(self):
"""Pick the cube waiting on the staging spot and lift it clear."""
staging = helpers.STAGING_POSE
hover = down_pose(staging.x, staging.y, staging.z + APPROACH)
grasp = down_pose(staging.x, staging.y, staging.z - GRASP_DEPTH)
await self.move_gripper(hover)
await self.grip_percentage(GRIP_PERCENTAGE * 3) # open the gripper wider than the cube
await self.move_gripper(grasp)
await self.grip_percentage(GRIP_PERCENTAGE) # close the gripper on the cube
await asyncio.sleep(.5)
await self.move_gripper(hover)
The staging spot is a single fixed pose, and you hand-feed one cube to it before every call to pick. Note the grasp target is staging.z - GRASP_DEPTH: your staging pose puts the fingertips level with the cube’s top face, so the grasp lowers them GRASP_DEPTH millimeters down the cube’s sides before the jaws close.
Add “pick” to your list of command line arguments in STEPS:
STEPS = {
"test": Palletizer.test,
"pick": Palletizer.pick
}
Place a cube on the staging spot, then run the program with the “pick” argument:
uv run palletizer.py pick
Checkpoint
helpers.STAGING_POSE. If the jaws close too high on the cube, or the cube slips as it lifts, increase GRASP_DEPTH a millimeter at a time; if the grip is loose, lower GRIP_PERCENTAGE by one.place
place takes a grid cell index and sets the held cube down at that cell. Add it to the Palletizer class:
async def place(self, seq: int):
"""Place the held cube into bottom-layer grid cell `seq`."""
target = helpers.grid(helpers.PALLET_ORIGIN, PITCH, CUBE)[seq]
hover = down_pose(target.x, target.y, target.z + APPROACH)
await self.move_gripper(hover)
await self.move_gripper(down_pose(target.x, target.y, target.z - GRASP_DEPTH))
await self.grip_percentage(GRIP_PERCENTAGE + 2) # release the cube
await self.move_gripper(hover)
self.placed.append(target)
helpers.grid returns all eight target poses, bottom layer followed by top layer; seq indexes into that list. The hover-then-descend pattern mirrors pick: transit above the cell first, then lower straight down, so the cube does not drag across neighboring cells on its way in. The descent also mirrors pick’s depth: the cube was gripped GRASP_DEPTH below its top face, so releasing it at that same depth sets it down instead of dropping it. self.placed records each filled cell’s pose; Phase 5 uses it.
Checkpoint
place takes a seq argument, so there is no standalone step for it in STEPS; you verify it as the first cycle of pack, in the next section. When you run pack, the first cube is lowered into grid cell 0 and released. The cube should land inside the marked cell, not on top of an edge or a neighboring cell. If it lands off-center, recheck the pallet origin pose you captured in Phase 3, or confirm PITCH and CUBE match your measured cube spacing.Pack the bottom layer
With pick and place working individually, chain them into a loop that packs all four bottom-layer cells, pausing between cycles so you can hand-feed the next cube. Add this last method to the Palletizer class:
async def pack(self):
"""Pack the bottom layer: one cube per grid cell, cells 0 through 3."""
for seq in range(4):
input(f"Place a cube on the staging spot, then press Enter (cell {seq})... ")
await self.pick()
await self.place(seq)
print(f"packed {len(self.placed)} cubes")
With the class complete, add “pack” to the command-line plumbing:
STEPS = {
"test": Palletizer.test,
"pick": Palletizer.pick,
"pack": Palletizer.pack,
}
Run it
Now run the full bottom-layer pack:
uv run palletizer.py pack
The script prompts you before each cycle. Hand-feed a cube to the staging spot, press Enter, and watch the arm pick it up and set it into the next grid cell. After the first cycle, confirm the cube landed inside grid cell 0, not on top of an edge or a neighboring cell, before you continue to the remaining three.
Checkpoint
pack prints packed 4 cubes and the bottom layer of the pallet is full: four cubes, one per cell, with no gaps or overlaps. This is milestone one.Milestone one
You now drive the arm through a static pack from your own code: connect, read back the taught anchor poses, and run a pick-and-place cycle for each bottom-layer cell, with no obstacle avoidance yet. That is a complete, working result for this workshop. Phase 5 adds the second layer and teaches the motion service about the cubes already on the pallet, so it plans around them instead of through them.
Was this page helpful?
Glad to hear it! If you have any other feedback please let us know:
We're sorry about that. To help us improve, please tell us what we can do better:
Thank you!
