From 4005c8eb1617155551be9ab224ed3cc94fcb0575 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Fri, 10 Jul 2026 23:29:56 -0400 Subject: [PATCH 01/29] feat(tutorials): add SO-ARM101 workshop phase template --- .../so-arm101-palletizing/_phase-template.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/tutorials/so-arm101-palletizing/_phase-template.md diff --git a/docs/tutorials/so-arm101-palletizing/_phase-template.md b/docs/tutorials/so-arm101-palletizing/_phase-template.md new file mode 100644 index 0000000000..afbf0192fa --- /dev/null +++ b/docs/tutorials/so-arm101-palletizing/_phase-template.md @@ -0,0 +1,28 @@ +--- +title: "Phase N: Title in under 70 characters" +linkTitle: "N. Short title" +type: "docs" +slug: "phase-slug" +weight: 0 # 10, 20, 30, ... in 10s for sidebar order +description: "One-sentence description of this phase." +workshop: "so-arm101-palletizing" +toc_hide: true +phase: 0 +phase_total: 6 +time_estimate: "NN minutes" +prev: "/tutorials/so-arm101-palletizing/previous-slug/" +next: "/tutorials/so-arm101-palletizing/next-slug/" +languages: ["python"] +draft: true # set to false when this phase is ready to publish +headless: true # REMOVE this line when you copy the template to a real phase page +--- + + + +## Section heading + +{{< checkpoint >}} +What the learner runs, and the expected result that means "continue". +{{< /checkpoint >}} + +{{< workshop-nav >}} From ebea3eda22f7b3d84e5db8d0da508d66eb1ca3a6 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Fri, 10 Jul 2026 23:34:49 -0400 Subject: [PATCH 02/29] feat(tutorials): add SO-ARM101 palletizing workshop overview --- .../tutorials/so-arm101-palletizing/_index.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/tutorials/so-arm101-palletizing/_index.md diff --git a/docs/tutorials/so-arm101-palletizing/_index.md b/docs/tutorials/so-arm101-palletizing/_index.md new file mode 100644 index 0000000000..2c85b282f6 --- /dev/null +++ b/docs/tutorials/so-arm101-palletizing/_index.md @@ -0,0 +1,80 @@ +--- +title: "Miniature Palletizing with the SO-ARM101" +linkTitle: "SO-ARM101 Palletizing Workshop" +type: "docs" +weight: 60 +description: "Build a desktop palletizing robot with an affordable SO-ARM101 arm, the physical twin of the simulated Viam 101 palletizer. Teach its poses by hand, then program a collision-free pick-and-stack in Python." +authors: [] +level: "Intermediate" +languages: ["python"] +viamresources: ["arm", "gripper", "motion", "frame_system"] +platformarea: ["mobility", "core"] +tags: ["tutorial", "workshop", "arm", "motion", "frame-system"] +workshop: "so-arm101-palletizing" +workshop_overview: true +time_estimate: "2 hours" +hardware: + - "SO-ARM101 (5-DOF + gripper)" + - "8 × ~20 mm cubes" + - "Raspberry Pi or laptop host" +companion_repo: "https://github.com/viam-devrel/so-arm101-palletizing" +no_list: true +draft: true +--- + +You will build a miniature palletizing cell: an SO-ARM101 arm picks cubes from a staging spot and stacks them on a small pallet, two layers of four. It's the same set of concepts as the simulated Viam 101 course, but running on a real arm you can hold in your hands. + +The workshop is structured as six sequential phases, each ending with checkpoints you can verify before moving on. Finishing Phase 4, where you drive the robot from your own Python code through a static, pre-planned pack, is a complete success. Phases 5 and 6 go further. + +## What you'll build + +You will configure an SO-ARM101 arm with its finger gripper, teach it a small set of anchor poses by hand using its freedrive (torque-off) capability, then write a Python script that reads those poses back and plans a collision-free pick-and-stack sequence. By the end, you will have packed a two-by-two-by-two stack of cubes onto a marked pallet area, all driven by code you wrote yourself. + + + +## Required hardware + +- **SO-ARM101**: the five degree-of-freedom (5-DOF) arm with a finger gripper, connected to its host over USB serial. +- **Eight cubes**, roughly 20 mm on a side. +- **A flat surface** with a marked pallet area about 60 by 60 mm, plus a staging spot for the cubes. +- **A host**, either a Raspberry Pi or a laptop, running `viam-agent` and `viam-server`. + +You do not need a camera or a 3D-printed jig for this workshop. + +## Phases + +Phases 1 through 4 are the core workshop. Phase 5 adds obstacle avoidance, and Phase 6 is optional. + +1. **[Platform mental model](/tutorials/so-arm101-palletizing/platform-mental-model/)** (~15 min) +2. **[Configure the SO-ARM101](/tutorials/so-arm101-palletizing/configure-the-arm/)** (~20 min) +3. **[Teach the cell by hand](/tutorials/so-arm101-palletizing/teach-the-cell/)** (~20 min) +4. **[Pack from Python](/tutorials/so-arm101-palletizing/pack-from-python/)** (~20 min, milestone one: a static pack from your own code) +5. **[Avoid placed cubes](/tutorials/so-arm101-palletizing/avoid-placed-cubes/)** (~22 min, milestone two: a collision-free full pack) +6. **[Wrap it in a module](/tutorials/so-arm101-palletizing/inline-module/)** (~15 min, optional) + +## How this relates to Viam 101 + +This workshop is the physical twin of the simulated palletizer in the Viam 101 course: the same frame system, motion, and WorldState ideas apply here. On real hardware, you also learn to map the physical world into the arm's frame and to reason about the reach of a 5-DOF arm. Because the cubes are rotationally symmetric, the arm's coupled wrist yaw does not matter for this task. + +## Companion code + +All supporting files for this workshop live in the [viam-devrel/so-arm101-palletizing](https://github.com/viam-devrel/so-arm101-palletizing) repository. `helpers.py` is provided for you; you build `palletizer.py` yourself as you work through the phases. + +## Prerequisites + +This is a self-serve workshop, so confirm each of the following before you start: + +- **A Viam account with an online machine.** Log in at [app.viam.com](https://app.viam.com), [create a machine](https://docs.viam.com/set-up-a-machine/first-machine/), and confirm the green **Live** indicator before you begin. +- **Python 3.10 or newer.** Install it with [uv](https://docs.astral.sh/uv/getting-started/installation/) (recommended) or from [python.org](https://www.python.org/downloads/). +- **An SO-ARM101 that's built, has its motors configured, and is calibrated.** Follow the first-time arm setup steps in the [SO-ARM101 module](https://app.viam.com/module/devrel/so101-arm) documentation: install the LeRobot software, configure the motors, build the arm, and calibrate it. + +### Validate your environment + +Before starting Phase 1, confirm your environment is ready: + +```sh +python3 --version # 3.10 or newer +uv run --with viam-sdk python -c "import viam; print(viam.__version__)" # prints a version +``` + +If either command fails, revisit the checklist above before continuing. From 964910c0c32d7b1d5b0bbcdd75bb67249b50a90a Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Fri, 10 Jul 2026 23:44:27 -0400 Subject: [PATCH 03/29] feat(tutorials): author SO-ARM101 workshop phase 1 (platform mental model) --- .../01-platform-mental-model.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md diff --git a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md new file mode 100644 index 0000000000..955d5907ca --- /dev/null +++ b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md @@ -0,0 +1,64 @@ +--- +title: "Phase 1: The platform and the miniature cell" +linkTitle: "1. Platform mental model" +type: "docs" +slug: "platform-mental-model" +weight: 10 +description: "How your laptop, the arm's host, and Viam fit together, and the three ideas this workshop is built on." +workshop: "so-arm101-palletizing" +toc_hide: true +phase: 1 +phase_total: 6 +time_estimate: "15 minutes" +next: "/tutorials/so-arm101-palletizing/configure-the-arm/" +languages: ["python"] +draft: true +--- + +Before you configure anything, this phase gives you a mental model of the cell you are about to build and the platform it runs on. You will not touch config or code yet, but every phase after this one leans on the ideas introduced here. + +## What you'll build + +You are building a miniature palletizing cell. An SO-ARM101 arm picks cubes, one at a time, from a staging spot and stacks them on a small pallet: two layers of four, eight cubes total. The arm and its gripper are the hardware; a set of hand-taught poses and, later, your own Python code, are what turn that hardware into a working pack sequence. + + + +## How your laptop, the arm's host, and Viam fit together + +A Viam machine has three parts working together, and it helps to know which one does what before you start configuring. + +The Viam cloud app, at [app.viam.com](https://app.viam.com), holds your machine's configuration: the arm and gripper you add, the poses you save, and any services you configure. It is the source of truth for what the machine should run, not something that talks to the arm directly. + +On the host, either a Raspberry Pi or a laptop, two programs do the work. `viam-agent` supervises `viam-server`: it installs it, keeps it running, and restarts it if it crashes. `viam-server` reads the configuration from the cloud app, starts the resources it describes, and exposes them over an API. The SO-ARM101 itself connects to this host over a USB serial cable; `viam-server` talks to the arm's motors through that connection. + +Your Python code runs from your own laptop, separate from the host. When your script connects to the machine, it reaches `viam-server` over the network, using the address and credentials from the machine's **CONNECT** tab. The cloud app helps your script find and authenticate to the machine, but the actual move commands travel straight to `viam-server` on the host. + +## Components and services + +Everything a Viam machine does is modeled as a resource, and resources split into two kinds. + +The arm and the gripper are **components**: each one wraps a piece of physical hardware and exposes an API for it. An arm component moves to a pose; a gripper component opens and closes. + +The frame system and the motion service are **services**: software that reasons about the components rather than being hardware itself. The frame system tracks where every object in the cell sits relative to the arm's base. The motion service uses that information to plan a path from where the arm is to where it needs to be, without colliding with anything along the way. + +## The three ideas this workshop is built on + +Three concepts carry the whole workshop. This section only previews them; you will work with each one directly in a later phase. + +The **frame system** answers "where is everything, relative to the arm?" Every pose you teach, and every obstacle you configure, is expressed as a position relative to the arm's base frame. Once something has a place in the frame system, the arm and the motion service can reason about it. + +**Motion planning** answers "how does the arm get there?" Given a target pose, the motion service works out a path of joint movements that reaches it. You will not write this logic yourself; you call the motion service and it does the planning. + +**WorldState** answers "what must the planner avoid?" It is the set of obstacles, expressed in the frame system, that the motion service treats as solid. In this workshop, WorldState grows as you pack: once a cube is sitting on the pallet, it becomes an obstacle the planner must route around when placing the next one. + +## Why a real arm changes the job + +The simulated Viam 101 course hands you the coordinates of every object in the cell: the pick station, the pallet, and each box already have a frame. On real hardware, nothing comes pre-measured. You have to map the physical world, the staging spot and the pallet grid in front of you, into the arm's frame yourself. You will do exactly that by hand in Phase 3, jogging the arm to each anchor point and recording where it is. + +The SO-ARM101 is a five degree-of-freedom (5-DOF) arm, one fewer than the six-axis arms used in many industrial cells. On a top-down grasp, that missing degree of freedom couples the wrist's yaw to the rest of the approach: you cannot set an arbitrary wrist rotation independently of the arm's position the way a 6-DOF arm can. In this workshop that coupling does not matter, because the cubes are rotationally symmetric: however the gripper's fingers land on a cube's sides, the grasp works. + +{{< checkpoint >}} +Open your machine in the Viam app and confirm the green **Live** indicator. You should be able to name the two components you will configure (arm and gripper) and the two services that will move the arm (frame system and motion). If your machine is not Live, start `viam-server` on the host before continuing. +{{< /checkpoint >}} + +{{< workshop-nav >}} From d2093300a0baa09f8fa31c3f956ee6936e5c677c Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Fri, 10 Jul 2026 23:51:45 -0400 Subject: [PATCH 04/29] fix(tutorials): correct 5-DOF motion-planning explanation (position_only) --- .../so-arm101-palletizing/01-platform-mental-model.md | 4 ++-- docs/tutorials/so-arm101-palletizing/_index.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md index 955d5907ca..07c06edd05 100644 --- a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md +++ b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md @@ -55,10 +55,10 @@ The **frame system** answers "where is everything, relative to the arm?" Every p The simulated Viam 101 course hands you the coordinates of every object in the cell: the pick station, the pallet, and each box already have a frame. On real hardware, nothing comes pre-measured. You have to map the physical world, the staging spot and the pallet grid in front of you, into the arm's frame yourself. You will do exactly that by hand in Phase 3, jogging the arm to each anchor point and recording where it is. -The SO-ARM101 is a five degree-of-freedom (5-DOF) arm, one fewer than the six-axis arms used in many industrial cells. On a top-down grasp, that missing degree of freedom couples the wrist's yaw to the rest of the approach: you cannot set an arbitrary wrist rotation independently of the arm's position the way a 6-DOF arm can. In this workshop that coupling does not matter, because the cubes are rotationally symmetric: however the gripper's fingers land on a cube's sides, the grasp works. +The SO-ARM101 also has one fewer joint than an industrial arm: it has five degrees of freedom (5-DOF) rather than six. A 5-DOF arm cannot reach an arbitrary position at an arbitrary orientation the way a 6-DOF arm can, so the arm's motion planner defaults to matching the target position and letting the final orientation fall where it may (a `position_only` goal). Because the cubes are rotationally symmetric, the exact rotation of the gripper around a cube does not matter, so this limitation never gets in the way in this workshop. {{< checkpoint >}} -Open your machine in the Viam app and confirm the green **Live** indicator. You should be able to name the two components you will configure (arm and gripper) and the two services that will move the arm (frame system and motion). If your machine is not Live, start `viam-server` on the host before continuing. +Open your machine in the Viam app and confirm the green **Live** indicator. You should be able to name the two components you will configure (arm and gripper) and the two services the arm relies on to move (frame system and motion). If your machine is not Live, start `viam-server` on the host before continuing. {{< /checkpoint >}} {{< workshop-nav >}} diff --git a/docs/tutorials/so-arm101-palletizing/_index.md b/docs/tutorials/so-arm101-palletizing/_index.md index 2c85b282f6..b644fb3cf2 100644 --- a/docs/tutorials/so-arm101-palletizing/_index.md +++ b/docs/tutorials/so-arm101-palletizing/_index.md @@ -54,7 +54,7 @@ Phases 1 through 4 are the core workshop. Phase 5 adds obstacle avoidance, and P ## How this relates to Viam 101 -This workshop is the physical twin of the simulated palletizer in the Viam 101 course: the same frame system, motion, and WorldState ideas apply here. On real hardware, you also learn to map the physical world into the arm's frame and to reason about the reach of a 5-DOF arm. Because the cubes are rotationally symmetric, the arm's coupled wrist yaw does not matter for this task. +This workshop is the physical twin of the simulated palletizer in the Viam 101 course: the same frame system, motion, and WorldState ideas apply here. On real hardware, you also learn to map the physical world into the arm's frame and to reason about a 5-DOF arm's reach. A 5-DOF arm plans to a target position without pinning the final orientation, and because the cubes are rotationally symmetric, that does not matter for this task. ## Companion code From fe8e073a217075909154aae4ea4fc9f50cdd39b2 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Fri, 10 Jul 2026 23:56:10 -0400 Subject: [PATCH 05/29] feat(tutorials): author SO-ARM101 workshop phase 2 (configure the arm) --- .../02-configure-the-arm.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md diff --git a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md new file mode 100644 index 0000000000..21f6711c5f --- /dev/null +++ b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md @@ -0,0 +1,123 @@ +--- +title: "Phase 2: Configure the SO-ARM101" +linkTitle: "2. Configure the arm" +type: "docs" +slug: "configure-the-arm" +weight: 20 +description: "Add the arm and gripper with the discovery service, verify them with test cards, and place the arm in the frame system." +workshop: "so-arm101-palletizing" +toc_hide: true +phase: 2 +phase_total: 6 +time_estimate: "20 minutes" +prev: "/tutorials/so-arm101-palletizing/platform-mental-model/" +next: "/tutorials/so-arm101-palletizing/teach-the-cell/" +languages: ["python"] +draft: true +--- + +In this phase you get the arm and gripper configured and verified on the **CONTROL** tab, then place the arm in the frame system so the rest of the platform knows where it sits. + +## Add the discovery service + +Rather than typing the arm and gripper configs by hand, start with the SO-101 module's **discovery service**. A discovery service reports the hardware attached to a machine and suggests configurations for it, so you configure the right components without hunting for serial ports or attribute names by hand. See [Discovery service](/reference/services/discovery/) for the general pattern. + +On the **CONFIGURE** tab, click the **+** icon and select **Blocks**. Search for `so101` and select the `devrel:so101:discovery` result. Leave its name as the default and save the config. Saving is the moment `viam-server` downloads the `devrel:so101` module; the arm, gripper, and calibration models you add later in this phase come from that same module, so the download happens only once. + +Before you open the discovery service's test panel, know what it is looking for: the serial port your SO-ARM101 is connected to over USB. + +- On Linux, the port shows up as `/dev/ttyUSB0` or `/dev/ttyACM0`. +- On macOS, look under `/dev/tty.*` for a name containing `usbmodem` or `usbserial`. + +Open the discovery service's **TEST** panel. It scans for a connected SO-ARM101 and, if it finds one, returns ready-made configuration snippets for the arm and the gripper, with the detected `port` already filled in. The discovery service also suggests a calibration sensor you can add if the arm still needs calibrating. + + + +With the arm connected and powered, select **Add component** next to each suggested snippet to create the arm and gripper components from it. If discovery does not find your arm, confirm the USB cable is connected and that no other program (such as a LeRobot script) is holding the serial port open, then retry. + +{{< alert title="Discovery has done its job" color="note" >}} +Like the config it suggests, the discovery service is not part of the pack sequence you build later in this workshop. Leave it in place if you expect to re-discover hardware, or remove it once the arm and gripper are configured. +{{< /alert >}} + +## Calibrate the arm (first-time builds) + +If discovery did not find an existing calibration file, your SO-ARM101 has not been calibrated yet and needs one guided pass before its joint positions are accurate. Add the `devrel:so101:calibration` sensor discovery suggested (or add it by hand with the same `port` as your arm), open its test panel, and follow the guided workflow: first you set the homing position, then you move each joint through its full range while the sensor records the min and max it sees. The sensor saves the result to a calibration file the arm and gripper read on startup. + +This workshop does not reproduce the full calibration flow. If you built and calibrated your arm following the [prerequisites](/tutorials/so-arm101-palletizing/#prerequisites), skip this section. Otherwise, follow the guided steps in the [SO-ARM101 module documentation](https://app.viam.com/module/devrel/so101-arm) before continuing. + +## Add the arm component + +If you used discovery, confirm the arm component it created has one attribute, `port`, set to your arm's serial port: + +```json +{ + "port": "/dev/ttyUSB0" +} +``` + +If you are configuring the arm by hand instead, click the **+** icon, select **Blocks**, search for `so101`, and select the `devrel:so101:arm` result. Name it `arm`, paste the attribute above with your own port, and save. + +Open the **CONTROL** tab and find the arm's test card. Test cards call the same API your Python code calls later in this workshop; jogging a joint here is an API call, not a simulation. Move one joint slider a small amount and press **Execute**, then watch the physical arm turn. + +{{< alert title="The arm is about to move" color="caution" >}} +Keep the workspace clear and change one joint a small amount at a time. Large or combined joint moves can drive the arm into the table or itself. +{{< /alert >}} + +{{< checkpoint >}} +Moving a joint slider and pressing **Execute** on the arm's test card moves the physical arm. If nothing moves, confirm the arm shows online in the CONFIGURE tab and check the LOGS tab for a serial connection error. +{{< /checkpoint >}} + +## Add the gripper component + +The gripper is the SO-ARM101's sixth servo, on the same serial bus as the other five, so its config looks almost identical to the arm's: the same `port` attribute, pointed at the same serial port. + +If you used discovery, confirm the gripper component it created carries this same port: + +```json +{ + "port": "/dev/ttyUSB0" +} +``` + +If you are configuring the gripper by hand, click the **+** icon, select **Blocks**, search for `so101`, and select the `devrel:so101:gripper` result. Name it `gripper`, set `port` to the same value as the arm's, and save. + +Open the gripper's test card on the **CONTROL** tab. Press **Open** and watch the jaw open, then press **Grab** and watch it close. + +{{< checkpoint >}} +Pressing **Open** and **Grab** on the gripper's test card opens and closes the physical jaw. If nothing moves, confirm the gripper shows online in the CONFIGURE tab and that its `port` matches the arm's. +{{< /checkpoint >}} + +## Place the arm in the frame system + +Adding the arm and gripper tells `viam-server` how to talk to them, but not where the arm sits in the cell. As Phase 1 covered, the frame system answers that question for every component in the workshop, the same idea the simulated Viam 101 course uses for its palletizer: a frame places a component relative to a parent, and every frame traces back to `world`. See [Frame system](/motion-planning/frame-system/overview/) for the general concept. + +Open the arm's card on the **CONFIGURE** tab and select **Frame**. If your arm's base sits at the origin of your workspace, the default frame already describes that: parent `world`, translation `(0, 0, 0)`, no rotation. Leave the defaults and save. If you measured your arm's base at an offset from wherever you plan to treat as your workspace origin, set that offset in `translation` instead. + +```json +{ + "parent": "world", + "translation": { "x": 0, "y": 0, "z": 0 }, + "orientation": { + "type": "ov_degrees", + "value": { "x": 0, "y": 0, "z": 1, "th": 0 } + } +} +``` + +You do not add a frame for the gripper. Unlike a gripper bolted onto a separate arm, the SO-ARM101's gripper is part of the arm module's own kinematic chain: the arm's kinematics already define a tool frame at the fingertip, the gripper's tool-center-point (TCP). Every motion command you send later in this workshop, through the motion service, already plans to that fingertip, with no separate frame to configure. + +## See it in the 3D scene + +Open the **3D scene** tab. The arm renders using the kinematics built into the `devrel:so101:arm` model, sitting at the frame you just configured. This is the same view you will return to throughout the rest of the workshop to watch the pack sequence run. + + + +Jog a joint on the arm's test card again and watch the 3D scene update alongside the physical arm. + +{{< checkpoint >}} +The 3D scene shows the SO-ARM101 model at its current joint positions, and it updates as you jog a joint from the CONTROL tab. If the scene is blank, confirm the arm's frame saved and that the arm shows online in the CONFIGURE tab. +{{< /checkpoint >}} + +With the arm and gripper configured, verified, and placed in the frame system, you are ready for [Phase 3](/tutorials/so-arm101-palletizing/teach-the-cell/), where you map the physical cell, the staging spot and the pallet, into the arm's frame by hand. + +{{< workshop-nav >}} From 32973ff64441bd9f24f6b53b923ba261bc57db90 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 00:13:58 -0400 Subject: [PATCH 06/29] refactor(tutorials): place SO-ARM101 base at world origin so taught poses are world poses Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EdtYcpEHeA2yvQ2ksBzZHH --- docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md index 21f6711c5f..12ced5a848 100644 --- a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md +++ b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md @@ -91,7 +91,7 @@ Pressing **Open** and **Grab** on the gripper's test card opens and closes the p Adding the arm and gripper tells `viam-server` how to talk to them, but not where the arm sits in the cell. As Phase 1 covered, the frame system answers that question for every component in the workshop, the same idea the simulated Viam 101 course uses for its palletizer: a frame places a component relative to a parent, and every frame traces back to `world`. See [Frame system](/motion-planning/frame-system/overview/) for the general concept. -Open the arm's card on the **CONFIGURE** tab and select **Frame**. If your arm's base sits at the origin of your workspace, the default frame already describes that: parent `world`, translation `(0, 0, 0)`, no rotation. Leave the defaults and save. If you measured your arm's base at an offset from wherever you plan to treat as your workspace origin, set that offset in `translation` instead. +Open the arm's card on the **CONFIGURE** tab and select **Frame**. Set the arm's frame so its base sits at the world origin: parent `world`, translation `(0, 0, 0)`, no rotation, which is what the default frame already describes. Leave the defaults and save. Keeping the base at the world origin means the poses you read back in phase 3 are already world poses, which keeps your motion code simple. ```json { From decf7d9e1fcba3e23cea41f98c23d32d934294f0 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 00:13:58 -0400 Subject: [PATCH 07/29] feat(tutorials): author SO-ARM101 workshop phase 3 (teach the cell by hand) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EdtYcpEHeA2yvQ2ksBzZHH --- .../03-teach-the-cell.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md new file mode 100644 index 0000000000..c0a67f83e5 --- /dev/null +++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md @@ -0,0 +1,139 @@ +--- +title: "Phase 3: Teach the cell by hand" +linkTitle: "3. Teach the cell" +type: "docs" +slug: "teach-the-cell" +weight: 30 +description: "Move the arm by hand with torque disabled, read back two anchor poses, and compute the pallet grid from them." +workshop: "so-arm101-palletizing" +toc_hide: true +phase: 3 +phase_total: 6 +time_estimate: "20 minutes" +prev: "/tutorials/so-arm101-palletizing/configure-the-arm/" +next: "/tutorials/so-arm101-palletizing/pack-from-python/" +languages: ["python"] +draft: true +--- + +In this phase you map the physical cell into the arm's frame. In the simulated Viam 101 course, every object already has a pose handed to you. On your real SO-ARM101, nothing is pre-measured: you find where the staging spot and the pallet actually sit, expressed in the arm's own coordinate frame, by moving the arm there yourself and reading back where it ended up. You capture two anchor poses this way, then compute the rest of the pallet grid from them arithmetically. + +{{< alert title="The arm goes limp" color="caution" >}} +Disabling torque lets you move the arm by hand, but it also means the arm no longer holds its position against gravity. It drops as soon as you disable torque, and stays free to fall until you re-enable it. Support the arm with one hand while torque is off, clear the workspace and cubes from underneath it, and re-enable torque before you send any motion command. +{{< /alert >}} + +## Why teach by hand + +An arm-position-saver switch, like the one the pick-and-place workshop uses, replays a single saved pose. This cell needs eight target poses, one per cube, and typing or jogging to all eight by hand would be slow and error-prone. Instead, you capture just two anchors: the staging spot, where you hand-feed each cube, and the pallet's origin corner, the bottom-layer cell at grid position [0, 0]. Every other pallet position is a fixed offset from that origin corner, so once you know the origin and the grid spacing, you compute the remaining seven poses instead of teaching them individually. This is also the one step a simulation cannot do for you: it has no physical staging spot or pallet to measure. + +## Disable torque + +The SO-ARM101 module exposes a `set_torque` command over `DoCommand`. On the arm's test card on the **CONTROL** tab, open the DoCommand box and send: + +```json +{ + "command": "set_torque", + "enable": false +} +``` + +You can send the same command from a terminal with `viam machines part run` if you prefer the command-line interface (CLI) to the test card. Once the command succeeds, the arm's joints go slack and you can move it by hand. + +## Capture the staging pose + +With torque disabled, gently guide the gripper to the staging spot, the place where you will set down one cube at the start of every pick cycle in later phases. Hold the arm steady once it's in position. + +The companion repo's `capture_pose.py` connects to your machine and prints the arm's current end-effector pose. Its core is a single call to the arm's `get_end_position` API: + +```python +import asyncio +from viam.robot.client import RobotClient +from viam.components.arm import Arm + + +async def main(): + opts = RobotClient.Options.with_api_key( + api_key="", api_key_id="" + ) + robot = await RobotClient.at_address("", opts) + arm = Arm.from_robot(robot, "arm") + pose = await arm.get_end_position() + print(f"x={pose.x:.1f} y={pose.y:.1f} z={pose.z:.1f}") + print(f"o_x={pose.o_x:.3f} o_y={pose.o_y:.3f} o_z={pose.o_z:.3f} theta={pose.theta:.1f}") + await robot.close() + + +asyncio.run(main()) +``` + +Fill in the API key, key ID, and machine address from your machine's **CONNECT** tab, then run it from your terminal: + +```bash +python capture_pose.py +``` + +The script calls the arm's standard [`get_end_position`](/reference/apis/components/arm/#getendposition) API, which returns the gripper's tool pose computed by forward kinematics: x, y, and z in millimeters, plus an orientation, already expressed in the arm's own frame. Because you placed the arm's base at the world origin in phase 2, this pose is also the pose in the world frame, which is what the motion service expects when you write `palletizer.py` in phase 4. Move the arm slightly and run the script again to confirm the printed numbers change with it. + +Record the printed x, y, and z for the staging pose. You will save these numbers in the last section of this phase. + +## Capture the pallet origin corner + +Still with torque disabled, guide the gripper to the bottom-layer corner of the pallet, cell [0, 0], the corner you treat as the origin of the pallet grid. Run `capture_pose.py` again and record the printed x, y, and z. This is your pallet origin pose. + +## Re-enable torque + +Send the same `DoCommand` with `enable` flipped to `true`: + +```json +{ + "command": "set_torque", + "enable": true +} +``` + +The arm's joints stiffen and it holds its current position. Confirm this by letting go of the arm; it should stay put instead of drooping. + +## Derive the grid + +With the pallet origin corner captured, the remaining seven target poses follow from two constants: the center-to-center spacing between cube slots, and the cube's own size, which sets the offset between the two stacked layers. + +```python +PITCH = 30 # mm, center-to-center spacing between adjacent pallet cells +CUBE = 20 # mm, cube side length, and the z offset between layers +``` + +The four bottom-layer cells are the origin corner plus every combination of `(0, PITCH)` in x and y: + +```text +(0, 0) (PITCH, 0) +(0, PITCH) (PITCH, PITCH) +``` + +The top layer repeats those same four x, y offsets at `z + CUBE`. The companion repo's `helpers.py` wraps this arithmetic in a `grid` function: + +```python +def grid(origin, pitch, cube): + """Return the eight target poses for a two-layer, four-cell pallet, + given the bottom-layer origin corner (cell [0, 0]).""" + bottom = [ + Pose(x=origin.x + dx, y=origin.y + dy, z=origin.z) + for dx in (0, pitch) + for dy in (0, pitch) + ] + top = [Pose(x=p.x, y=p.y, z=p.z + cube) for p in bottom] + return bottom + top +``` + +These are positions only. In phase 4 you apply a straight-down tool orientation to each one with a `down_pose` helper before sending it to the motion service. + +The staging pose is not part of this grid. It stays a single fixed pose for the whole pack sequence: you hand-feed one cube to that same spot at the start of every cycle, and the arm always picks from there. + +## Save your anchors + +Keep the two poses you recorded, staging and pallet origin, somewhere you can copy them from: a note, or a small `poses.py` file with two constants, `STAGING_POSE` and `PALLET_ORIGIN`, each holding the x, y, and z you read back with `capture_pose.py`. In Phase 4, when you write `palletizer.py`, these are the two constants you start from: you pass `PALLET_ORIGIN` into `helpers.grid` to get all eight target poses, and you use `STAGING_POSE` as the fixed pick location for every cycle. + +{{< checkpoint >}} +With torque disabled, running `capture_pose.py` repeatedly while you move the arm by hand returns different x, y, and z values each time, confirming the readback tracks the physical arm. After you re-enable torque, the arm holds its pose and does not drift when you let go. You have two recorded poses, staging and pallet origin, saved into the constants Phase 4 reads. If `capture_pose.py` returns the same values every time, confirm torque is actually disabled; if the arm still droops after re-enabling torque, resend the `set_torque` command with `enable` set to `true` and check the LOGS tab for a serial error. +{{< /checkpoint >}} + +{{< workshop-nav >}} From b48c96325da18a0e0a1aec522e8a585c909db293 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 00:20:07 -0400 Subject: [PATCH 08/29] feat(tutorials): author SO-ARM101 workshop phase 4 (pack from Python, milestone one) --- .../04-pack-from-python.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/tutorials/so-arm101-palletizing/04-pack-from-python.md diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md new file mode 100644 index 0000000000..a604f29771 --- /dev/null +++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md @@ -0,0 +1,239 @@ +--- +title: "Phase 4: Pack from Python" +linkTitle: "4. Pack from Python" +type: "docs" +slug: "pack-from-python" +weight: 40 +description: "Build palletizer.py method by method and drive a static bottom-layer pack from your own code." +workshop: "so-arm101-palletizing" +toc_hide: true +phase: 4 +phase_total: 6 +time_estimate: "20 minutes" +prev: "/tutorials/so-arm101-palletizing/teach-the-cell/" +next: "/tutorials/so-arm101-palletizing/avoid-placed-cubes/" +languages: ["python"] +draft: true +--- + +In this phase you write `palletizer.py`, a Python script that reads back the two anchor poses from phase 3 and drives the arm through a pick-and-place cycle for each of the four bottom-layer cells. Getting through this phase is milestone one: you drive the arm through a static pack from your own code, with no obstacle avoidance yet, on real hardware. + +## Set up the companion project + +Clone the workshop's companion repository and work from it for the rest of this phase: + +```bash +git clone https://github.com/viam-devrel/so-arm101-palletizing.git +cd so-arm101-palletizing +``` + +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. + +Open the machine's **CONNECT** tab in the Viam app, select **Python SDK**, toggle **Include API key**, and copy the machine address and the application programming interface (API) key and key ID pair it shows you. `helpers.py` reads these from constants near the top of the file; paste your own values in before you run anything. + +Open `helpers.py` and set the two constants `STAGING_POSE` and `PALLET_ORIGIN` to the two 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` is provided for you, from the same companion project you cloned in phase 3. You write `palletizer.py` yourself in this phase, starting from an empty file in the same directory. + +## What the helpers give you + +Start from [helpers.py](https://github.com/viam-devrel/so-arm101-palletizing/blob/main/helpers.py); import it rather than rewriting the connection and grid math. It gives you: + +- `helpers.connect()`, an `async` function that returns a connected `RobotClient`. +- The arm's resource name (`helpers.ARM`), which you hand to the motion service, plus the gripper and motion-service names (`helpers.GRIPPER`, `helpers.MOTION`), which you pass to `from_robot`. All three name resources configured in phase 2. +- `down_pose(x, y, z)`, which returns a `Pose` at that position with the tool pointing straight down. +- `helpers.grid(origin, pitch, cube)`, the same function from phase 3 that expands one origin corner into the eight target poses of a two-layer, four-cell pallet. +- `helpers.STAGING_POSE` and `helpers.PALLET_ORIGIN`, the two anchor poses you captured by hand in phase 3. + +You do not rewrite any of this. `palletizer.py` imports these names and composes them into motion calls. + +## Build 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 this phase adds, and a `Palletizer` class that holds a motion client and a gripper handle: + +```python +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 = 20 # mm, cube side length, and the z offset between layers +APPROACH = 40 # mm, hover height above a pose before descending +GRASP_DEPTH = 5 # mm, how far the gripper descends past the cube top to close on it + + +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 same constants phase 3 used to derive the grid. `APPROACH` and `GRASP_DEPTH` are new: `APPROACH` is how high above a target pose the arm hovers before descending, and `GRASP_DEPTH` is how far past a cube's top surface the gripper descends so its fingers close around the cube rather than skim its top. `self.placed` tracks which grid cells already hold a cube. + +There is nothing to run yet. This class only sets up handles; the next method makes the first move. + +### move_gripper + +Every move in this workshop reduces to one call: hand the motion service a destination pose and let it plan a path to the arm's fingertip. Add this method to the `Palletizer` class: + +```python + async def move_gripper(self, pose: Pose): + destination = PoseInFrame(reference_frame="world", pose=pose) + await self.motion.move( + component_name=helpers.ARM, + destination=destination, + world_state=None, + ) +``` + +Name the arm, `helpers.ARM`, not the gripper. As phase 2 covered, the SO-ARM101's kinematics already define the tool-center-point at the fingertip, so naming the arm is enough for the motion service to plan the fingertip to `pose`. `world_state=None` because this phase has no obstacles to avoid yet; phase 5 adds them. + +Add a small `move` method to the same class to smoke-test this before building `pick` and `place`: + +```python + async def move(self): + """Send the gripper to a safe pose, pointing straight down.""" + await self.move_gripper(down_pose(200, 0, 150)) +``` + +You test this once `main` is in place, at the end of this section. + +### pick + +`pick` reads the fixed staging pose, hovers above it, descends onto the cube, closes the gripper, and lifts back clear. Add it to the `Palletizer` class: + +```python + 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.move_gripper(grasp) + await self.gripper.grab() + await self.move_gripper(hover) +``` + +There is no pick-station here, unlike the simulated Viam 101 course: the staging spot is a single fixed pose, and you hand-feed one cube to it before every call to `pick`. Hovering above the staging pose first, then descending straight down, keeps the approach vertical instead of dragging the gripper sideways into a cube that is already sitting there. Note the grasp target is `staging.z - GRASP_DEPTH`, a few millimeters below the taught height, so the fingers close around the cube rather than stopping level with its top. + +{{< checkpoint >}} +Hand-feed a cube to the staging spot, then run `pick` on its own once `main` is wired up at the end of this section. The gripper hovers above the staging pose, descends, closes on the cube, and lifts it back to the hover height. If the fingers close on air, check that the cube is centered under `helpers.STAGING_POSE` and that `GRASP_DEPTH` is not so small that the fingers stop above the cube's top. +{{< /checkpoint >}} + +### place + +`place` takes a grid cell index and sets the held cube down at that cell. Add it to the `Palletizer` class: + +```python + 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)) + await self.gripper.open() + 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, and this phase only ever passes `0` through `3`, the four bottom-layer cells. 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. Unlike `pick`, `place` releases at exactly `target.z`, the taught cell height, rather than pressing below it, so the cube rests on the pallet surface at the height you captured. + +{{< checkpoint >}} +With a cube held from `pick`, running `place(0)` lowers it into the first grid cell and opens the gripper. 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. +{{< /checkpoint >}} + +### 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: + +```python + 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 the command-line plumbing at module level, outside the class, so `STEPS`, `main`, and the entry point sit at column 0: + +```python +STEPS = { + "move": Palletizer.move, + "pick": Palletizer.pick, + "pack": Palletizer.pack, +} + + +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) + finally: + await robot.close() + + +if __name__ == "__main__": + verb = sys.argv[1] if len(sys.argv) > 1 else "pack" + asyncio.run(main(verb)) +``` + +`STEPS` maps a command-line verb to a method, so you can run any single step by name instead of always running the whole pack. `pack` is the milestone-one path: it loops `seq` over the four bottom-layer cells, pausing for you to hand-feed a cube before each `pick`. + +## Run it + +Run each step with `uv run`, watching both the physical arm and the **3D scene** tab. + +First, confirm the connection and a basic move: + +```bash +uv run palletizer.py move +``` + +{{< checkpoint >}} +The gripper moves to a fixed pose, pointing straight down. If the script raises a connection error, recheck the machine address and API key in `helpers.py` against the CONNECT tab. If it raises a planning error, confirm `(200, 0, 150)` is inside your arm's reach; adjust the coordinates in `move` if your cell layout differs. +{{< /checkpoint >}} + +Next, hand-feed one cube to the staging spot and run `pick`: + +```bash +uv run palletizer.py pick +``` + +The arm should hover above the staging pose, descend, grab the cube, and lift it clear. Leave the cube held; you use it in the next step. + +Now run the full bottom-layer pack: + +```bash +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 >}} +After four cycles, `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. +{{< /checkpoint >}} + +## 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. + +{{< workshop-nav >}} From 7d9c50f2c419cf5b97e7e91074573cb108dde3d6 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 00:29:19 -0400 Subject: [PATCH 09/29] fix(tutorials): store taught poses in helpers.py for phase 4 to read --- docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md index c0a67f83e5..6002c255e1 100644 --- a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md +++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md @@ -130,7 +130,7 @@ The staging pose is not part of this grid. It stays a single fixed pose for the ## Save your anchors -Keep the two poses you recorded, staging and pallet origin, somewhere you can copy them from: a note, or a small `poses.py` file with two constants, `STAGING_POSE` and `PALLET_ORIGIN`, each holding the x, y, and z you read back with `capture_pose.py`. In Phase 4, when you write `palletizer.py`, these are the two constants you start from: you pass `PALLET_ORIGIN` into `helpers.grid` to get all eight target poses, and you use `STAGING_POSE` as the fixed pick location for every cycle. +Set the two constants `STAGING_POSE` and `PALLET_ORIGIN` in the companion project's `helpers.py` to the two poses you just captured, each holding the x, y, and z you read back with `capture_pose.py`. In Phase 4, `palletizer.py` reads them from there: it passes `PALLET_ORIGIN` into `helpers.grid` to get all eight target poses, and uses `STAGING_POSE` as the fixed pick location for every cycle. {{< checkpoint >}} With torque disabled, running `capture_pose.py` repeatedly while you move the arm by hand returns different x, y, and z values each time, confirming the readback tracks the physical arm. After you re-enable torque, the arm holds its pose and does not drift when you let go. You have two recorded poses, staging and pallet origin, saved into the constants Phase 4 reads. If `capture_pose.py` returns the same values every time, confirm torque is actually disabled; if the arm still droops after re-enabling torque, resend the `set_torque` command with `enable` set to `true` and check the LOGS tab for a serial error. From 807a9ac0fbf91680d8546b6d1976e68db15075e1 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 00:32:37 -0400 Subject: [PATCH 10/29] feat(tutorials): add SO-ARM101 workshop stubs for phases 5 and 6 Structured stubs for the WorldState obstacle-avoidance phase (milestone two) and the optional inline-module phase, completing the phase 1-6 sidebar chain for the SO-ARM101 palletizing workshop. --- .../05-avoid-placed-cubes.md | 39 +++++++++++++++++++ .../so-arm101-palletizing/06-inline-module.md | 34 ++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md create mode 100644 docs/tutorials/so-arm101-palletizing/06-inline-module.md diff --git a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md new file mode 100644 index 0000000000..9064351def --- /dev/null +++ b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md @@ -0,0 +1,39 @@ +--- +title: "Phase 5: Avoid placed cubes" +linkTitle: "5. Avoid placed cubes" +type: "docs" +slug: "avoid-placed-cubes" +weight: 50 +description: "Model placed cubes and the held cube in WorldState so the planner stacks the full two layers collision-free." +workshop: "so-arm101-palletizing" +toc_hide: true +phase: 5 +phase_total: 6 +time_estimate: "22 minutes" +prev: "/tutorials/so-arm101-palletizing/pack-from-python/" +next: "/tutorials/so-arm101-palletizing/inline-module/" +languages: ["python"] +draft: true +--- + +In this phase you teach the motion service about the cubes already on the pallet and the cube in the gripper, so it plans around them instead of through them. Finishing this phase is milestone two: a full, collision-free two-layer pack. + +## Why the planner collides + + + +## Model placed cubes as obstacles + + + + + +## Model the held cube + + + +## Run the full two-layer pack + + + +{{< workshop-nav >}} diff --git a/docs/tutorials/so-arm101-palletizing/06-inline-module.md b/docs/tutorials/so-arm101-palletizing/06-inline-module.md new file mode 100644 index 0000000000..69a2ccc234 --- /dev/null +++ b/docs/tutorials/so-arm101-palletizing/06-inline-module.md @@ -0,0 +1,34 @@ +--- +title: "Phase 6: Wrap the loop in a module" +linkTitle: "6. Inline module" +type: "docs" +slug: "inline-module" +weight: 60 +description: "Wrap the working pack loop in an inline module so it runs on the machine and can be triggered through DoCommand." +workshop: "so-arm101-palletizing" +toc_hide: true +phase: 6 +phase_total: 6 +time_estimate: "15 minutes" +prev: "/tutorials/so-arm101-palletizing/avoid-placed-cubes/" +languages: ["python"] +draft: true +--- + +This phase is optional. It takes the working pack loop from phase 5 off your laptop and onto the machine, where it runs as a module and can be triggered on demand instead of from a script. + +## From script to module + + + + + +## Scaffold the inline module + + + +## Reload and run on the machine + + + +{{< workshop-nav >}} From ab733da24b56951adc3906101fa5cc56789dd15c Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 00:33:54 -0400 Subject: [PATCH 11/29] chore: ignore pending SO-ARM101 companion repo in htmltest --- .htmltest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.htmltest.yml b/.htmltest.yml index c5fd7ec286..483971ef75 100644 --- a/.htmltest.yml +++ b/.htmltest.yml @@ -33,6 +33,7 @@ IgnoreURLs: - "twitter.com" - "github.com/viamrobotics/docs" - "github.com/viam-devrel/pick-and-place" + - "github.com/viam-devrel/so-arm101-palletizing" - "openai.com" - "espressif.com" - "pinout.xyz" From 84da53d5cab9c316afcab20709aec7c02e008001 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 00:43:47 -0400 Subject: [PATCH 12/29] fix(tutorials): introduce companion repo once in phase 4 and fix place checkpoint Phase 3's capture_pose.py is now framed as a self-contained script the learner saves and runs directly with the SDK, not something pulled from the companion repo; the learner just records the two anchor poses by hand. Phase 4 is now the first place the companion repo is cloned, and is where the recorded poses get pasted into helpers.py. Also reworks the place() checkpoint to verify against the first cycle of pack() instead of an unsupported standalone place(0) call. --- .../so-arm101-palletizing/03-teach-the-cell.md | 16 ++++++++-------- .../04-pack-from-python.md | 18 +++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md index 6002c255e1..ef7dfe22df 100644 --- a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md +++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md @@ -43,7 +43,7 @@ You can send the same command from a terminal with `viam machines part run` if y With torque disabled, gently guide the gripper to the staging spot, the place where you will set down one cube at the start of every pick cycle in later phases. Hold the arm steady once it's in position. -The companion repo's `capture_pose.py` connects to your machine and prints the arm's current end-effector pose. Its core is a single call to the arm's `get_end_position` API: +`capture_pose.py` is a small, self-contained script that connects to your machine and prints the arm's current end-effector pose. It needs nothing beyond the Viam Python SDK: no companion project, no helper file, just the code below. Its core is a single call to the arm's `get_end_position` API: ```python import asyncio @@ -66,13 +66,13 @@ async def main(): asyncio.run(main()) ``` -Fill in the API key, key ID, and machine address from your machine's **CONNECT** tab, then run it from your terminal: +Fill in the API key, key ID, and machine address from your machine's **CONNECT** tab. Save this as `capture_pose.py` and run it with: ```bash -python capture_pose.py +uv run --with viam-sdk python capture_pose.py ``` -The script calls the arm's standard [`get_end_position`](/reference/apis/components/arm/#getendposition) API, which returns the gripper's tool pose computed by forward kinematics: x, y, and z in millimeters, plus an orientation, already expressed in the arm's own frame. Because you placed the arm's base at the world origin in phase 2, this pose is also the pose in the world frame, which is what the motion service expects when you write `palletizer.py` in phase 4. Move the arm slightly and run the script again to confirm the printed numbers change with it. +The script calls the arm's standard [`get_end_position`](/reference/apis/components/arm/#getendposition) API, which returns the gripper's tool pose computed by forward kinematics: x, y, and z in millimeters, plus an orientation, already expressed in the arm's own frame. Because you placed the arm's base at the world origin in Phase 2, this pose is also the pose in the world frame, which is what the motion service expects when you write `palletizer.py` in Phase 4. Move the arm slightly and run the script again to confirm the printed numbers change with it. Record the printed x, y, and z for the staging pose. You will save these numbers in the last section of this phase. @@ -109,7 +109,7 @@ The four bottom-layer cells are the origin corner plus every combination of `(0, (0, PITCH) (PITCH, PITCH) ``` -The top layer repeats those same four x, y offsets at `z + CUBE`. The companion repo's `helpers.py` wraps this arithmetic in a `grid` function: +The top layer repeats those same four x, y offsets at `z + CUBE`. In Phase 4, the companion project's `helpers.py` wraps this same arithmetic in a `grid` function: ```python def grid(origin, pitch, cube): @@ -124,16 +124,16 @@ def grid(origin, pitch, cube): return bottom + top ``` -These are positions only. In phase 4 you apply a straight-down tool orientation to each one with a `down_pose` helper before sending it to the motion service. +These are positions only. In Phase 4 you apply a straight-down tool orientation to each one with a `down_pose` helper before sending it to the motion service. The staging pose is not part of this grid. It stays a single fixed pose for the whole pack sequence: you hand-feed one cube to that same spot at the start of every cycle, and the arm always picks from there. ## Save your anchors -Set the two constants `STAGING_POSE` and `PALLET_ORIGIN` in the companion project's `helpers.py` to the two poses you just captured, each holding the x, y, and z you read back with `capture_pose.py`. In Phase 4, `palletizer.py` reads them from there: it passes `PALLET_ORIGIN` into `helpers.grid` to get all eight target poses, and uses `STAGING_POSE` as the fixed pick location for every cycle. +Write down the two poses you just captured, staging and pallet origin, each as the x, y, and z you read back with `capture_pose.py`. Keep this note handy: in Phase 4 you paste these numbers into the companion project's `helpers.py`, into the `STAGING_POSE` and `PALLET_ORIGIN` constants that `palletizer.py` reads. From there, `palletizer.py` passes `PALLET_ORIGIN` into `helpers.grid` to get all eight target poses, and uses `STAGING_POSE` as the fixed pick location for every cycle. {{< checkpoint >}} -With torque disabled, running `capture_pose.py` repeatedly while you move the arm by hand returns different x, y, and z values each time, confirming the readback tracks the physical arm. After you re-enable torque, the arm holds its pose and does not drift when you let go. You have two recorded poses, staging and pallet origin, saved into the constants Phase 4 reads. If `capture_pose.py` returns the same values every time, confirm torque is actually disabled; if the arm still droops after re-enabling torque, resend the `set_torque` command with `enable` set to `true` and check the LOGS tab for a serial error. +With torque disabled, running `capture_pose.py` repeatedly while you move the arm by hand returns different x, y, and z values each time, confirming the readback tracks the physical arm. After you re-enable torque, the arm holds its pose and does not drift when you let go. You have two recorded poses, staging and pallet origin, written down and ready to carry into Phase 4. If `capture_pose.py` returns the same values every time, confirm torque is actually disabled; if the arm still droops after re-enabling torque, resend the `set_torque` command with `enable` set to `true` and check the LOGS tab for a serial error. {{< /checkpoint >}} {{< workshop-nav >}} diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md index a604f29771..21d2cb1587 100644 --- a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md +++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md @@ -16,7 +16,7 @@ languages: ["python"] draft: true --- -In this phase you write `palletizer.py`, a Python script that reads back the two anchor poses from phase 3 and drives the arm through a pick-and-place cycle for each of the four bottom-layer cells. Getting through this phase is milestone one: you drive the arm through a static pack from your own code, with no obstacle avoidance yet, on real hardware. +In this phase you write `palletizer.py`, a Python script that reads back the two anchor poses from Phase 3 and drives the arm through a pick-and-place cycle for each of the four bottom-layer cells. Getting through this phase is milestone one: you drive the arm through a static pack from your own code, with no obstacle avoidance yet, on real hardware. ## Set up the companion project @@ -31,19 +31,19 @@ The project ships with a `pyproject.toml`, so `uv run` resolves and installs the Open the machine's **CONNECT** tab in the Viam app, select **Python SDK**, toggle **Include API key**, and copy the machine address and the application programming interface (API) key and key ID pair it shows you. `helpers.py` reads these from constants near the top of the file; paste your own values in before you run anything. -Open `helpers.py` and set the two constants `STAGING_POSE` and `PALLET_ORIGIN` to the two 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. +Open `helpers.py` and set the two constants `STAGING_POSE` and `PALLET_ORIGIN` to the two 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` is provided for you, from the same companion project you cloned in phase 3. You write `palletizer.py` yourself in this phase, starting from an empty file in the same directory. +`helpers.py` is provided for you as part of the companion project. You write `palletizer.py` yourself in this phase, starting from an empty file in the same directory. ## What the helpers give you Start from [helpers.py](https://github.com/viam-devrel/so-arm101-palletizing/blob/main/helpers.py); import it rather than rewriting the connection and grid math. It gives you: - `helpers.connect()`, an `async` function that returns a connected `RobotClient`. -- The arm's resource name (`helpers.ARM`), which you hand to the motion service, plus the gripper and motion-service names (`helpers.GRIPPER`, `helpers.MOTION`), which you pass to `from_robot`. All three name resources configured in phase 2. +- The arm's resource name (`helpers.ARM`), which you hand to the motion service, plus the gripper and motion-service names (`helpers.GRIPPER`, `helpers.MOTION`), which you pass to `from_robot`. All three name resources configured in Phase 2. - `down_pose(x, y, z)`, which returns a `Pose` at that position with the tool pointing straight down. -- `helpers.grid(origin, pitch, cube)`, the same function from phase 3 that expands one origin corner into the eight target poses of a two-layer, four-cell pallet. -- `helpers.STAGING_POSE` and `helpers.PALLET_ORIGIN`, the two anchor poses you captured by hand in phase 3. +- `helpers.grid(origin, pitch, cube)`, the same function from Phase 3 that expands one origin corner into the eight target poses of a two-layer, four-cell pallet. +- `helpers.STAGING_POSE` and `helpers.PALLET_ORIGIN`, the two anchor poses you captured by hand in Phase 3. You do not rewrite any of this. `palletizer.py` imports these names and composes them into motion calls. @@ -80,7 +80,7 @@ class Palletizer: self.placed = [] ``` -`PITCH` and `CUBE` are the same constants phase 3 used to derive the grid. `APPROACH` and `GRASP_DEPTH` are new: `APPROACH` is how high above a target pose the arm hovers before descending, and `GRASP_DEPTH` is how far past a cube's top surface the gripper descends so its fingers close around the cube rather than skim its top. `self.placed` tracks which grid cells already hold a cube. +`PITCH` and `CUBE` are the same constants Phase 3 used to derive the grid. `APPROACH` and `GRASP_DEPTH` are new: `APPROACH` is how high above a target pose the arm hovers before descending, and `GRASP_DEPTH` is how far past a cube's top surface the gripper descends so its fingers close around the cube rather than skim its top. `self.placed` tracks which grid cells already hold a cube. There is nothing to run yet. This class only sets up handles; the next method makes the first move. @@ -98,7 +98,7 @@ Every move in this workshop reduces to one call: hand the motion service a desti ) ``` -Name the arm, `helpers.ARM`, not the gripper. As phase 2 covered, the SO-ARM101's kinematics already define the tool-center-point at the fingertip, so naming the arm is enough for the motion service to plan the fingertip to `pose`. `world_state=None` because this phase has no obstacles to avoid yet; phase 5 adds them. +Name the arm, `helpers.ARM`, not the gripper. As Phase 2 covered, the SO-ARM101's kinematics already define the tool-center-point at the fingertip, so naming the arm is enough for the motion service to plan the fingertip to `pose`. `world_state=None` because this phase has no obstacles to avoid yet; Phase 5 adds them. Add a small `move` method to the same class to smoke-test this before building `pick` and `place`: @@ -151,7 +151,7 @@ Hand-feed a cube to the staging spot, then run `pick` on its own once `main` is `helpers.grid` returns all eight target poses, bottom layer followed by top layer; `seq` indexes into that list, and this phase only ever passes `0` through `3`, the four bottom-layer cells. 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. Unlike `pick`, `place` releases at exactly `target.z`, the taught cell height, rather than pressing below it, so the cube rests on the pallet surface at the height you captured. {{< checkpoint >}} -With a cube held from `pick`, running `place(0)` lowers it into the first grid cell and opens the gripper. 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. +`place` takes a `seq` argument, so there is no standalone step for it in `STEPS` yet; 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. {{< /checkpoint >}} ### Pack the bottom layer From 4c808e557ff2c5e2a0e38b32262ce2c16599bc08 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 00:43:53 -0400 Subject: [PATCH 13/29] style(tutorials): consistent 'Phase N' capitalization and 'SO-ARM101 module' naming Capitalizes "Phase N" in body prose across the workshop to match the sibling workshop's convention, and standardizes on "SO-ARM101 module" instead of "SO-101 module" when referring to the Viam module. --- docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md | 4 ++-- docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md | 2 +- docs/tutorials/so-arm101-palletizing/06-inline-module.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md index 12ced5a848..a671a83ef1 100644 --- a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md +++ b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md @@ -20,7 +20,7 @@ In this phase you get the arm and gripper configured and verified on the **CONTR ## Add the discovery service -Rather than typing the arm and gripper configs by hand, start with the SO-101 module's **discovery service**. A discovery service reports the hardware attached to a machine and suggests configurations for it, so you configure the right components without hunting for serial ports or attribute names by hand. See [Discovery service](/reference/services/discovery/) for the general pattern. +Rather than typing the arm and gripper configs by hand, start with the SO-ARM101 module's **discovery service**. A discovery service reports the hardware attached to a machine and suggests configurations for it, so you configure the right components without hunting for serial ports or attribute names by hand. See [Discovery service](/reference/services/discovery/) for the general pattern. On the **CONFIGURE** tab, click the **+** icon and select **Blocks**. Search for `so101` and select the `devrel:so101:discovery` result. Leave its name as the default and save the config. Saving is the moment `viam-server` downloads the `devrel:so101` module; the arm, gripper, and calibration models you add later in this phase come from that same module, so the download happens only once. @@ -91,7 +91,7 @@ Pressing **Open** and **Grab** on the gripper's test card opens and closes the p Adding the arm and gripper tells `viam-server` how to talk to them, but not where the arm sits in the cell. As Phase 1 covered, the frame system answers that question for every component in the workshop, the same idea the simulated Viam 101 course uses for its palletizer: a frame places a component relative to a parent, and every frame traces back to `world`. See [Frame system](/motion-planning/frame-system/overview/) for the general concept. -Open the arm's card on the **CONFIGURE** tab and select **Frame**. Set the arm's frame so its base sits at the world origin: parent `world`, translation `(0, 0, 0)`, no rotation, which is what the default frame already describes. Leave the defaults and save. Keeping the base at the world origin means the poses you read back in phase 3 are already world poses, which keeps your motion code simple. +Open the arm's card on the **CONFIGURE** tab and select **Frame**. Set the arm's frame so its base sits at the world origin: parent `world`, translation `(0, 0, 0)`, no rotation, which is what the default frame already describes. Leave the defaults and save. Keeping the base at the world origin means the poses you read back in Phase 3 are already world poses, which keeps your motion code simple. ```json { diff --git a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md index 9064351def..750cb80e9c 100644 --- a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md +++ b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md @@ -20,7 +20,7 @@ In this phase you teach the motion service about the cubes already on the pallet ## Why the planner collides - + ## Model placed cubes as obstacles diff --git a/docs/tutorials/so-arm101-palletizing/06-inline-module.md b/docs/tutorials/so-arm101-palletizing/06-inline-module.md index 69a2ccc234..ac37984455 100644 --- a/docs/tutorials/so-arm101-palletizing/06-inline-module.md +++ b/docs/tutorials/so-arm101-palletizing/06-inline-module.md @@ -15,7 +15,7 @@ languages: ["python"] draft: true --- -This phase is optional. It takes the working pack loop from phase 5 off your laptop and onto the machine, where it runs as a module and can be triggered on demand instead of from a script. +This phase is optional. It takes the working pack loop from Phase 5 off your laptop and onto the machine, where it runs as a module and can be triggered on demand instead of from a script. ## From script to module @@ -25,7 +25,7 @@ This phase is optional. It takes the working pack loop from phase 5 off your lap ## Scaffold the inline module - + ## Reload and run on the machine From 2b0e566105f43d937edb96f81df4d5149823de11 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 13:53:09 -0400 Subject: [PATCH 14/29] fix(tutorials): shorten SO-ARM101 phase 1 title under the 70-char head limit --- .../tutorials/so-arm101-palletizing/01-platform-mental-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md index 07c06edd05..e8a0fd4815 100644 --- a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md +++ b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md @@ -1,5 +1,5 @@ --- -title: "Phase 1: The platform and the miniature cell" +title: "Phase 1: The platform and the cell" linkTitle: "1. Platform mental model" type: "docs" slug: "platform-mental-model" From eec8a2c7e12deff159668eda957d2d9ef7dac8e1 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 14:20:39 -0400 Subject: [PATCH 15/29] docs(tutorials): make SO-ARM101 workshop stand alone (remove Viam 101 refs, drop unknown-context negatives) --- .../so-arm101-palletizing/01-platform-mental-model.md | 2 +- .../so-arm101-palletizing/02-configure-the-arm.md | 6 +++--- .../so-arm101-palletizing/03-teach-the-cell.md | 4 ++-- .../so-arm101-palletizing/04-pack-from-python.md | 2 +- .../so-arm101-palletizing/05-avoid-placed-cubes.md | 6 +++--- .../so-arm101-palletizing/06-inline-module.md | 4 ++-- docs/tutorials/so-arm101-palletizing/_index.md | 10 ++++------ 7 files changed, 16 insertions(+), 18 deletions(-) diff --git a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md index e8a0fd4815..d42be84b78 100644 --- a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md +++ b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md @@ -53,7 +53,7 @@ The **frame system** answers "where is everything, relative to the arm?" Every p ## Why a real arm changes the job -The simulated Viam 101 course hands you the coordinates of every object in the cell: the pick station, the pallet, and each box already have a frame. On real hardware, nothing comes pre-measured. You have to map the physical world, the staging spot and the pallet grid in front of you, into the arm's frame yourself. You will do exactly that by hand in Phase 3, jogging the arm to each anchor point and recording where it is. +Nothing in your cell comes pre-measured. To plan a motion, the arm needs to know where the staging spot and the pallet grid actually sit, expressed in its own frame. You map that yourself: in Phase 3 you jog the arm to each anchor point and record where it is. The SO-ARM101 also has one fewer joint than an industrial arm: it has five degrees of freedom (5-DOF) rather than six. A 5-DOF arm cannot reach an arbitrary position at an arbitrary orientation the way a 6-DOF arm can, so the arm's motion planner defaults to matching the target position and letting the final orientation fall where it may (a `position_only` goal). Because the cubes are rotationally symmetric, the exact rotation of the gripper around a cube does not matter, so this limitation never gets in the way in this workshop. diff --git a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md index a671a83ef1..ec82c0c352 100644 --- a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md +++ b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md @@ -16,7 +16,7 @@ languages: ["python"] draft: true --- -In this phase you get the arm and gripper configured and verified on the **CONTROL** tab, then place the arm in the frame system so the rest of the platform knows where it sits. +In this phase you configure the arm and gripper, verify them with their test cards on the **CONTROL** tab, then place the arm in the frame system so the rest of the platform knows where it sits. ## Add the discovery service @@ -89,7 +89,7 @@ Pressing **Open** and **Grab** on the gripper's test card opens and closes the p ## Place the arm in the frame system -Adding the arm and gripper tells `viam-server` how to talk to them, but not where the arm sits in the cell. As Phase 1 covered, the frame system answers that question for every component in the workshop, the same idea the simulated Viam 101 course uses for its palletizer: a frame places a component relative to a parent, and every frame traces back to `world`. See [Frame system](/motion-planning/frame-system/overview/) for the general concept. +Adding the arm and gripper tells `viam-server` how to talk to them, but not where the arm sits in the cell. As Phase 1 covered, the frame system answers that question for every component in the workshop: a frame places a component relative to a parent, and every frame traces back to `world`. See [Frame system](/motion-planning/frame-system/overview/) for the general concept. Open the arm's card on the **CONFIGURE** tab and select **Frame**. Set the arm's frame so its base sits at the world origin: parent `world`, translation `(0, 0, 0)`, no rotation, which is what the default frame already describes. Leave the defaults and save. Keeping the base at the world origin means the poses you read back in Phase 3 are already world poses, which keeps your motion code simple. @@ -104,7 +104,7 @@ Open the arm's card on the **CONFIGURE** tab and select **Frame**. Set the arm's } ``` -You do not add a frame for the gripper. Unlike a gripper bolted onto a separate arm, the SO-ARM101's gripper is part of the arm module's own kinematic chain: the arm's kinematics already define a tool frame at the fingertip, the gripper's tool-center-point (TCP). Every motion command you send later in this workshop, through the motion service, already plans to that fingertip, with no separate frame to configure. +The gripper needs no frame of its own. It is part of the SO-ARM101's own kinematic chain, so the arm's kinematics already define a tool frame at the fingertip, the gripper's tool-center-point (TCP). Every motion command you send later in this workshop, through the motion service, already plans to that fingertip, with no separate frame to configure. ## See it in the 3D scene diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md index ef7dfe22df..c16f905cf8 100644 --- a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md +++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md @@ -16,7 +16,7 @@ languages: ["python"] draft: true --- -In this phase you map the physical cell into the arm's frame. In the simulated Viam 101 course, every object already has a pose handed to you. On your real SO-ARM101, nothing is pre-measured: you find where the staging spot and the pallet actually sit, expressed in the arm's own coordinate frame, by moving the arm there yourself and reading back where it ended up. You capture two anchor poses this way, then compute the rest of the pallet grid from them arithmetically. +In this phase you map the physical cell into the arm's frame. Nothing in the cell is pre-measured: you find where the staging spot and the pallet actually sit, expressed in the arm's own coordinate frame, by moving the arm there yourself and reading back where it ended up. You capture two anchor poses this way, then compute the rest of the pallet grid from them arithmetically. {{< alert title="The arm goes limp" color="caution" >}} Disabling torque lets you move the arm by hand, but it also means the arm no longer holds its position against gravity. It drops as soon as you disable torque, and stays free to fall until you re-enable it. Support the arm with one hand while torque is off, clear the workspace and cubes from underneath it, and re-enable torque before you send any motion command. @@ -24,7 +24,7 @@ Disabling torque lets you move the arm by hand, but it also means the arm no lon ## Why teach by hand -An arm-position-saver switch, like the one the pick-and-place workshop uses, replays a single saved pose. This cell needs eight target poses, one per cube, and typing or jogging to all eight by hand would be slow and error-prone. Instead, you capture just two anchors: the staging spot, where you hand-feed each cube, and the pallet's origin corner, the bottom-layer cell at grid position [0, 0]. Every other pallet position is a fixed offset from that origin corner, so once you know the origin and the grid spacing, you compute the remaining seven poses instead of teaching them individually. This is also the one step a simulation cannot do for you: it has no physical staging spot or pallet to measure. +This cell needs eight target poses, one per cube, and teaching all eight by hand would be slow and error-prone. Instead, you capture just two anchors: the staging spot, where you hand-feed each cube, and the pallet's origin corner, the bottom-layer cell at grid position [0, 0]. Every other pallet position is a fixed offset from that origin corner, so once you know the origin and the grid spacing, you compute the remaining seven poses instead of teaching them individually. ## Disable torque diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md index 21d2cb1587..4df37e56aa 100644 --- a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md +++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md @@ -126,7 +126,7 @@ You test this once `main` is in place, at the end of this section. await self.move_gripper(hover) ``` -There is no pick-station here, unlike the simulated Viam 101 course: the staging spot is a single fixed pose, and you hand-feed one cube to it before every call to `pick`. Hovering above the staging pose first, then descending straight down, keeps the approach vertical instead of dragging the gripper sideways into a cube that is already sitting there. Note the grasp target is `staging.z - GRASP_DEPTH`, a few millimeters below the taught height, so the fingers close around the cube rather than stopping level with its top. +The staging spot is a single fixed pose, and you hand-feed one cube to it before every call to `pick`. Hovering above the staging pose first, then descending straight down, keeps the approach vertical instead of dragging the gripper sideways into a cube that is already sitting there. Note the grasp target is `staging.z - GRASP_DEPTH`, a few millimeters below the taught height, so the fingers close around the cube rather than stopping level with its top. {{< checkpoint >}} Hand-feed a cube to the staging spot, then run `pick` on its own once `main` is wired up at the end of this section. The gripper hovers above the staging pose, descends, closes on the cube, and lifts it back to the hover height. If the fingers close on air, check that the cube is centered under `helpers.STAGING_POSE` and that `GRASP_DEPTH` is not so small that the fingers stop above the cube's top. diff --git a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md index 750cb80e9c..0fa664ec4d 100644 --- a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md +++ b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md @@ -20,17 +20,17 @@ In this phase you teach the motion service about the cubes already on the pallet ## Why the planner collides - + ## Model placed cubes as obstacles - + ## Model the held cube - + ## Run the full two-layer pack diff --git a/docs/tutorials/so-arm101-palletizing/06-inline-module.md b/docs/tutorials/so-arm101-palletizing/06-inline-module.md index ac37984455..b28544c339 100644 --- a/docs/tutorials/so-arm101-palletizing/06-inline-module.md +++ b/docs/tutorials/so-arm101-palletizing/06-inline-module.md @@ -19,13 +19,13 @@ This phase is optional. It takes the working pack loop from Phase 5 off your lap ## From script to module - + ## Scaffold the inline module - + ## Reload and run on the machine diff --git a/docs/tutorials/so-arm101-palletizing/_index.md b/docs/tutorials/so-arm101-palletizing/_index.md index b644fb3cf2..50869585b9 100644 --- a/docs/tutorials/so-arm101-palletizing/_index.md +++ b/docs/tutorials/so-arm101-palletizing/_index.md @@ -3,7 +3,7 @@ title: "Miniature Palletizing with the SO-ARM101" linkTitle: "SO-ARM101 Palletizing Workshop" type: "docs" weight: 60 -description: "Build a desktop palletizing robot with an affordable SO-ARM101 arm, the physical twin of the simulated Viam 101 palletizer. Teach its poses by hand, then program a collision-free pick-and-stack in Python." +description: "Build a desktop palletizing robot with an affordable SO-ARM101 arm. Teach its poses by hand, then program a collision-free pick-and-stack in Python." authors: [] level: "Intermediate" languages: ["python"] @@ -22,7 +22,7 @@ no_list: true draft: true --- -You will build a miniature palletizing cell: an SO-ARM101 arm picks cubes from a staging spot and stacks them on a small pallet, two layers of four. It's the same set of concepts as the simulated Viam 101 course, but running on a real arm you can hold in your hands. +You will build a miniature palletizing cell: an SO-ARM101 arm picks cubes from a staging spot and stacks them on a small pallet, two layers of four. It all runs on a real arm you can hold in your hands. The workshop is structured as six sequential phases, each ending with checkpoints you can verify before moving on. Finishing Phase 4, where you drive the robot from your own Python code through a static, pre-planned pack, is a complete success. Phases 5 and 6 go further. @@ -39,8 +39,6 @@ You will configure an SO-ARM101 arm with its finger gripper, teach it a small se - **A flat surface** with a marked pallet area about 60 by 60 mm, plus a staging spot for the cubes. - **A host**, either a Raspberry Pi or a laptop, running `viam-agent` and `viam-server`. -You do not need a camera or a 3D-printed jig for this workshop. - ## Phases Phases 1 through 4 are the core workshop. Phase 5 adds obstacle avoidance, and Phase 6 is optional. @@ -52,9 +50,9 @@ Phases 1 through 4 are the core workshop. Phase 5 adds obstacle avoidance, and P 5. **[Avoid placed cubes](/tutorials/so-arm101-palletizing/avoid-placed-cubes/)** (~22 min, milestone two: a collision-free full pack) 6. **[Wrap it in a module](/tutorials/so-arm101-palletizing/inline-module/)** (~15 min, optional) -## How this relates to Viam 101 +## Working with a 5-DOF arm -This workshop is the physical twin of the simulated palletizer in the Viam 101 course: the same frame system, motion, and WorldState ideas apply here. On real hardware, you also learn to map the physical world into the arm's frame and to reason about a 5-DOF arm's reach. A 5-DOF arm plans to a target position without pinning the final orientation, and because the cubes are rotationally symmetric, that does not matter for this task. +Along the way you learn to map the physical world into the arm's frame and to reason about a five degree-of-freedom (5-DOF) arm's reach. A 5-DOF arm plans to a target position without pinning the final orientation, and because the cubes are rotationally symmetric, that does not matter for this task. ## Companion code From 50c1c46a1368988764ca3a6159afe2d692e8d684 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 14:36:16 -0400 Subject: [PATCH 16/29] fix(tutorials): configure the SO-ARM101 gripper frame (separate component, parent arm) --- .../02-configure-the-arm.md | 27 ++++++++++++++----- .../03-teach-the-cell.md | 2 +- .../04-pack-from-python.md | 2 +- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md index ec82c0c352..b6b6b8b3bd 100644 --- a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md +++ b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md @@ -16,7 +16,7 @@ languages: ["python"] draft: true --- -In this phase you configure the arm and gripper, verify them with their test cards on the **CONTROL** tab, then place the arm in the frame system so the rest of the platform knows where it sits. +In this phase you configure the arm and gripper, verify them with their test cards on the **CONTROL** tab, then place both in the frame system so the rest of the platform knows where they sit. ## Add the discovery service @@ -87,9 +87,9 @@ Open the gripper's test card on the **CONTROL** tab. Press **Open** and watch th Pressing **Open** and **Grab** on the gripper's test card opens and closes the physical jaw. If nothing moves, confirm the gripper shows online in the CONFIGURE tab and that its `port` matches the arm's. {{< /checkpoint >}} -## Place the arm in the frame system +## Place the arm and gripper in the frame system -Adding the arm and gripper tells `viam-server` how to talk to them, but not where the arm sits in the cell. As Phase 1 covered, the frame system answers that question for every component in the workshop: a frame places a component relative to a parent, and every frame traces back to `world`. See [Frame system](/motion-planning/frame-system/overview/) for the general concept. +Adding the arm and gripper tells `viam-server` how to talk to them, but not where they sit in the cell. As Phase 1 covered, the frame system answers that question for every component in the workshop: a frame places a component relative to a parent, and every frame traces back to `world`. See [Frame system](/motion-planning/frame-system/overview/) for the general concept. Open the arm's card on the **CONFIGURE** tab and select **Frame**. Set the arm's frame so its base sits at the world origin: parent `world`, translation `(0, 0, 0)`, no rotation, which is what the default frame already describes. Leave the defaults and save. Keeping the base at the world origin means the poses you read back in Phase 3 are already world poses, which keeps your motion code simple. @@ -104,18 +104,33 @@ Open the arm's card on the **CONFIGURE** tab and select **Frame**. Set the arm's } ``` -The gripper needs no frame of its own. It is part of the SO-ARM101's own kinematic chain, so the arm's kinematics already define a tool frame at the fingertip, the gripper's tool-center-point (TCP). Every motion command you send later in this workshop, through the motion service, already plans to that fingertip, with no separate frame to configure. +The gripper is a separate component with its own collision geometry, so it needs its own frame to place that geometry in the cell. You attach it to the arm. Open the gripper's card, select **Frame**, set its parent to `arm`, and leave the translation and rotation at zero: + +```json +{ + "parent": "arm", + "translation": { "x": 0, "y": 0, "z": 0 }, + "orientation": { + "type": "ov_degrees", + "value": { "x": 0, "y": 0, "z": 1, "th": 0 } + } +} +``` + +The zero offset is specific to this arm. The `devrel:so101:gripper` model already positions its jaw geometry relative to the arm's end point, so attaching the gripper there with no offset places the jaws correctly. A gripper on a different arm would instead need the measured offset from the arm's end point to the jaws entered here. + +Attaching the gripper to the arm does two things. The 3D scene draws the gripper on the end of the arm, and the motion service includes the gripper's shape when it plans, so it keeps the jaws clear of obstacles. The arm's kinematic chain ends at the point where the gripper mounts, and that end point is what the motion service drives to the poses you command later. You never enter a tool-center-point (TCP) by hand: in Phase 3 you position the gripper's jaws where you want them and record where the arm's end point lands, and replaying that record in Phase 4 returns the jaws to the same place. ## See it in the 3D scene -Open the **3D scene** tab. The arm renders using the kinematics built into the `devrel:so101:arm` model, sitting at the frame you just configured. This is the same view you will return to throughout the rest of the workshop to watch the pack sequence run. +Open the **3D scene** tab. The arm renders using the kinematics built into the `devrel:so101:arm` model, sitting at the frame you configured, with the gripper attached at its end point. This is the same view you will return to throughout the rest of the workshop to watch the pack sequence run. Jog a joint on the arm's test card again and watch the 3D scene update alongside the physical arm. {{< checkpoint >}} -The 3D scene shows the SO-ARM101 model at its current joint positions, and it updates as you jog a joint from the CONTROL tab. If the scene is blank, confirm the arm's frame saved and that the arm shows online in the CONFIGURE tab. +The 3D scene shows the SO-ARM101 model, gripper included, at its current joint positions, and it updates as you jog a joint from the CONTROL tab. If the gripper is missing or floating away from the arm, recheck that its frame parent is set to `arm` with zero translation. If the scene is blank, confirm the arm's frame saved and that the arm shows online in the CONFIGURE tab. {{< /checkpoint >}} With the arm and gripper configured, verified, and placed in the frame system, you are ready for [Phase 3](/tutorials/so-arm101-palletizing/teach-the-cell/), where you map the physical cell, the staging spot and the pallet, into the arm's frame by hand. diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md index c16f905cf8..97c0c1c9c4 100644 --- a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md +++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md @@ -72,7 +72,7 @@ Fill in the API key, key ID, and machine address from your machine's **CONNECT** uv run --with viam-sdk python capture_pose.py ``` -The script calls the arm's standard [`get_end_position`](/reference/apis/components/arm/#getendposition) API, which returns the gripper's tool pose computed by forward kinematics: x, y, and z in millimeters, plus an orientation, already expressed in the arm's own frame. Because you placed the arm's base at the world origin in Phase 2, this pose is also the pose in the world frame, which is what the motion service expects when you write `palletizer.py` in Phase 4. Move the arm slightly and run the script again to confirm the printed numbers change with it. +The script calls the arm's standard [`get_end_position`](/reference/apis/components/arm/#getendposition) API, which returns the arm's end point, where the gripper mounts, computed by forward kinematics: x, y, and z in millimeters, plus an orientation, already expressed in the arm's own frame. You position the arm so the gripper's jaws sit at the spot you want, then record that end point; because the gripper is rigidly attached, driving the end point back to the same pose in Phase 4 returns the jaws to the same spot. Because you placed the arm's base at the world origin in Phase 2, this pose is also the pose in the world frame, which is what the motion service expects when you write `palletizer.py` in Phase 4. Move the arm slightly and run the script again to confirm the printed numbers change with it. Record the printed x, y, and z for the staging pose. You will save these numbers in the last section of this phase. diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md index 4df37e56aa..71facbc56d 100644 --- a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md +++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md @@ -98,7 +98,7 @@ Every move in this workshop reduces to one call: hand the motion service a desti ) ``` -Name the arm, `helpers.ARM`, not the gripper. As Phase 2 covered, the SO-ARM101's kinematics already define the tool-center-point at the fingertip, so naming the arm is enough for the motion service to plan the fingertip to `pose`. `world_state=None` because this phase has no obstacles to avoid yet; Phase 5 adds them. +Name the arm, `helpers.ARM`. The motion service drives the arm's end point, where the gripper mounts, to `pose`. The anchor poses you taught in Phase 3 were recorded at that same end point with the gripper's jaws in position, so replaying them returns the jaws to where you taught them. The gripper, attached to the arm in Phase 2, rides along, and the planner accounts for its shape. `world_state=None` because this phase has no obstacles to avoid yet; Phase 5 adds them. Add a small `move` method to the same class to smoke-test this before building `pick` and `place`: From db4dad9570f43bb7382cb41bc86bebbd7e78aa36 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 14:42:57 -0400 Subject: [PATCH 17/29] docs(tutorials): add JSON snippet for SO-ARM101 calibration sensor config --- .../so-arm101-palletizing/02-configure-the-arm.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md index b6b6b8b3bd..90d004ce83 100644 --- a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md +++ b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md @@ -41,7 +41,15 @@ Like the config it suggests, the discovery service is not part of the pack seque ## Calibrate the arm (first-time builds) -If discovery did not find an existing calibration file, your SO-ARM101 has not been calibrated yet and needs one guided pass before its joint positions are accurate. Add the `devrel:so101:calibration` sensor discovery suggested (or add it by hand with the same `port` as your arm), open its test panel, and follow the guided workflow: first you set the homing position, then you move each joint through its full range while the sensor records the min and max it sees. The sensor saves the result to a calibration file the arm and gripper read on startup. +If discovery did not find an existing calibration file, your SO-ARM101 has not been calibrated yet and needs one guided pass before its joint positions are accurate. Add the `devrel:so101:calibration` sensor discovery suggested, or add it by hand: click the **+** icon, select **Blocks**, search for `so101`, select the `devrel:so101:calibration` result, and set its `port` to the same value as the arm. + +```json +{ + "port": "/dev/ttyUSB0" +} +``` + +Open the sensor's test panel and follow the guided workflow: first you set the homing position, then you move each joint through its full range while the sensor records the min and max it sees. The sensor saves the result to a calibration file the arm and gripper read on startup. This workshop does not reproduce the full calibration flow. If you built and calibrated your arm following the [prerequisites](/tutorials/so-arm101-palletizing/#prerequisites), skip this section. Otherwise, follow the guided steps in the [SO-ARM101 module documentation](https://app.viam.com/module/devrel/so101-arm) before continuing. From 168daf0a649ff23a0a92996fb2aadb9087d39e7d Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 22:01:54 -0400 Subject: [PATCH 18/29] docs(tutorials): align SO-ARM101 workshop with pick-and-place format Immediate consistency fixes from the format audit plus the wrap-up page: - Use sh code fences (was bash) to match the reference workshop - Add a "Where to start" routing section to the overview - Add forward-transition sentences to phases 1 and 3 before workshop-nav - Add 07-wrap-up.md (what you built / exercised / where to go next), wire the overview pointer and phase 6 next link to it --- .../01-platform-mental-model.md | 2 + .../03-teach-the-cell.md | 4 +- .../04-pack-from-python.md | 8 ++-- .../so-arm101-palletizing/06-inline-module.md | 1 + .../so-arm101-palletizing/07-wrap-up.md | 46 +++++++++++++++++++ .../tutorials/so-arm101-palletizing/_index.md | 9 ++++ 6 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 docs/tutorials/so-arm101-palletizing/07-wrap-up.md diff --git a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md index d42be84b78..12680ed509 100644 --- a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md +++ b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md @@ -61,4 +61,6 @@ The SO-ARM101 also has one fewer joint than an industrial arm: it has five degre Open your machine in the Viam app and confirm the green **Live** indicator. You should be able to name the two components you will configure (arm and gripper) and the two services the arm relies on to move (frame system and motion). If your machine is not Live, start `viam-server` on the host before continuing. {{< /checkpoint >}} +With the mental model in place, [Phase 2](/tutorials/so-arm101-palletizing/configure-the-arm/) is where you add the arm and gripper, verify them, and place them in the frame system. + {{< workshop-nav >}} diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md index 97c0c1c9c4..ef76537a51 100644 --- a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md +++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md @@ -68,7 +68,7 @@ asyncio.run(main()) Fill in the API key, key ID, and machine address from your machine's **CONNECT** tab. Save this as `capture_pose.py` and run it with: -```bash +```sh uv run --with viam-sdk python capture_pose.py ``` @@ -136,4 +136,6 @@ Write down the two poses you just captured, staging and pallet origin, each as t With torque disabled, running `capture_pose.py` repeatedly while you move the arm by hand returns different x, y, and z values each time, confirming the readback tracks the physical arm. After you re-enable torque, the arm holds its pose and does not drift when you let go. You have two recorded poses, staging and pallet origin, written down and ready to carry into Phase 4. If `capture_pose.py` returns the same values every time, confirm torque is actually disabled; if the arm still droops after re-enabling torque, resend the `set_torque` command with `enable` set to `true` and check the LOGS tab for a serial error. {{< /checkpoint >}} +With your two anchor poses recorded, [Phase 4](/tutorials/so-arm101-palletizing/pack-from-python/) is where you write the Python that reads them back and drives the arm through a pick-and-place pack. + {{< workshop-nav >}} diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md index 71facbc56d..7e536df12e 100644 --- a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md +++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md @@ -22,7 +22,7 @@ In this phase you write `palletizer.py`, a Python script that reads back the two Clone the workshop's companion repository and work from it for the rest of this phase: -```bash +```sh git clone https://github.com/viam-devrel/so-arm101-palletizing.git cd so-arm101-palletizing ``` @@ -204,7 +204,7 @@ Run each step with `uv run`, watching both the physical arm and the **3D scene** First, confirm the connection and a basic move: -```bash +```sh uv run palletizer.py move ``` @@ -214,7 +214,7 @@ The gripper moves to a fixed pose, pointing straight down. If the script raises Next, hand-feed one cube to the staging spot and run `pick`: -```bash +```sh uv run palletizer.py pick ``` @@ -222,7 +222,7 @@ The arm should hover above the staging pose, descend, grab the cube, and lift it Now run the full bottom-layer pack: -```bash +```sh uv run palletizer.py pack ``` diff --git a/docs/tutorials/so-arm101-palletizing/06-inline-module.md b/docs/tutorials/so-arm101-palletizing/06-inline-module.md index b28544c339..fbd5434a40 100644 --- a/docs/tutorials/so-arm101-palletizing/06-inline-module.md +++ b/docs/tutorials/so-arm101-palletizing/06-inline-module.md @@ -11,6 +11,7 @@ phase: 6 phase_total: 6 time_estimate: "15 minutes" prev: "/tutorials/so-arm101-palletizing/avoid-placed-cubes/" +next: "/tutorials/so-arm101-palletizing/wrap-up/" languages: ["python"] draft: true --- diff --git a/docs/tutorials/so-arm101-palletizing/07-wrap-up.md b/docs/tutorials/so-arm101-palletizing/07-wrap-up.md new file mode 100644 index 0000000000..47f7445c5f --- /dev/null +++ b/docs/tutorials/so-arm101-palletizing/07-wrap-up.md @@ -0,0 +1,46 @@ +--- +title: "Wrap-up and next steps" +linkTitle: "Wrap-up" +type: "docs" +slug: "wrap-up" +weight: 70 +description: "Review what you built in the SO-ARM101 palletizing workshop, the parts of the Viam platform you exercised, and where to take your solution next." +workshop: "so-arm101-palletizing" +toc_hide: true +prev: "/tutorials/so-arm101-palletizing/inline-module/" +languages: ["python"] +draft: true +--- + +Congratulations, you have finished the SO-ARM101 palletizing workshop! Starting from an empty machine, you configured a real desktop arm, taught it the cell by hand, and wrote the code that packs cubes onto a pallet. + +## What you built + +- **Milestone one.** You drove the arm through a static bottom-layer pack from your own Python script, proving your connection, your configured resources, and the poses you taught by hand all hold up under real code. +- **Milestone two.** You closed the loop with obstacle avoidance: the motion service plans a collision-free path around the cubes already on the pallet and the cube in the gripper, so the second layer stacks cleanly on top of the first. + +If you completed the optional Phase 6, you also packaged that same pack loop as a module that runs on the machine itself. + +## What you exercised on the platform + +This workshop was small on purpose, but it touched most of the moving parts you will use on any Viam machine: + +- **Configuration and runtime:** the Viam app as the single source of truth (the CONFIGURE tab and its JSON view), `viam-server` running your resources while `viam-agent` keeps it alive, and the module system, including a discovery service that suggested the arm and gripper configuration for you. +- **Resources:** the arm and gripper components from the SO-ARM101 module, with the gripper attached to the arm through the frame system, plus the calibration sensor that made the arm's joint positions accurate. +- **Frame system and motion:** placing the arm at the world origin so hand-taught poses are world poses, teaching real-world anchor poses by back-driving the arm with torque disabled, and letting the motion service plan to the arm's end point. You also learned how a five degree-of-freedom (5-DOF) arm plans to a position without pinning orientation, and how a WorldState of placed and held cubes keeps the planner from routing through the stack. +- **Code:** the Python SDK (`RobotClient`, the typed `Gripper` and `MotionClient`, and `motion.move`), built up one method at a time into `palletizer.py`, plus packaging that same script as an inline module. + +## Where to go next + +Everything above is a foundation you can build on. A few directions, each with a starting point in the docs: + +- **Extend the pack.** Add more layers, change the grid pattern, or force a straight-line descent into each cell with a [motion constraint](/motion-planning/move-an-arm/move-with-constraints/). +- **Add perception.** Replace the fixed staging spot with a camera that finds cubes: [capture and sync images](/data/capture-sync/), [build a dataset and train a model](/train/train-a-model/), then deploy it through the [ML model vision service](/reference/services/vision/mlmodel/) so the arm picks whatever it sees. +- **Build an interface.** Put a browser UI in front of the cell with a [Viam application](/build-apps/): start a pack and watch progress from a dashboard instead of a terminal. +- **Operationalize it.** Reuse this configuration across machines with a [fragment](/hardware/fragments/) and [capture data](/data/capture-sync/) from every run. +- **Run the module unattended.** The optional Phase 6 module runs a pack on demand through `do_command`. Drive it on a cadence with a [trigger](/reference/triggers/) so the cell packs on its own. +- **Explore the rest of the platform.** The same patterns work from other [SDKs](/reference/sdks/) (Go, TypeScript, C++, Flutter) and across the full [component and service APIs](/reference/apis/). + +When you are ready to build on your own hardware, the [Viam documentation](/) and the [module registry](https://app.viam.com/registry) are where to start. + +{{< workshop-nav >}} diff --git a/docs/tutorials/so-arm101-palletizing/_index.md b/docs/tutorials/so-arm101-palletizing/_index.md index 50869585b9..9647442c59 100644 --- a/docs/tutorials/so-arm101-palletizing/_index.md +++ b/docs/tutorials/so-arm101-palletizing/_index.md @@ -50,6 +50,8 @@ Phases 1 through 4 are the core workshop. Phase 5 adds obstacle avoidance, and P 5. **[Avoid placed cubes](/tutorials/so-arm101-palletizing/avoid-placed-cubes/)** (~22 min, milestone two: a collision-free full pack) 6. **[Wrap it in a module](/tutorials/so-arm101-palletizing/inline-module/)** (~15 min, optional) +When you finish, the **[wrap-up](/tutorials/so-arm101-palletizing/wrap-up/)** reviews what you built and points to next steps. + ## Working with a 5-DOF arm Along the way you learn to map the physical world into the arm's frame and to reason about a five degree-of-freedom (5-DOF) arm's reach. A 5-DOF arm plans to a target position without pinning the final orientation, and because the cubes are rotationally symmetric, that does not matter for this task. @@ -76,3 +78,10 @@ uv run --with viam-sdk python -c "import viam; print(viam.__version__)" # prin ``` If either command fails, revisit the checklist above before continuing. + +### Where to start + + + +- **Arm built and machine online (`viam-server` running):** start at [Phase 1](/tutorials/so-arm101-palletizing/platform-mental-model/). +- **Still building your SO-ARM101:** complete the [first-time arm setup](https://app.viam.com/module/devrel/so101-arm) (install LeRobot, configure the motors, build, and calibrate), then return here for [Phase 1](/tutorials/so-arm101-palletizing/platform-mental-model/). From 60e1f77cdf3cde50d690ec56075a6f6fab818b5d Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sat, 11 Jul 2026 23:26:11 -0400 Subject: [PATCH 19/29] docs(tutorials): revise SO-ARM101 workshop from review feedback - More engaging, phase-free overview intro (story + audience) - Host is a personal computer; add printable cube/pallet template - Drop per-phase time estimates and the core/optional framing - Remove the overview 5-DOF section (too much upfront context) - Rename companion repo to viam-devrel/mini-palletizer - Drop LeRobot install from prerequisites - Phase 1: adapt the seed's three-layers (ASCII) and SDK-connect sections; frame system is part of viam-server, not a service; rename to "three robotics concepts"; drop "why a real arm" section; fix the checkpoint objective - Phase 2: use family/model names (so101/arm); remove the calibrate section (a prerequisite), the LeRobot mention, "not a simulation", and the gripper-frame/TCP asides - Phase 3: capture poses from the arm test card in the app instead of a Python script - Phase 4: "packing routine"; drop "no obstacle avoidance yet" --- .htmltest.yml | 2 +- .../01-platform-mental-model.md | 59 ++++++++++++------- .../02-configure-the-arm.md | 33 +++-------- .../03-teach-the-cell.md | 50 ++++------------ .../04-pack-from-python.md | 9 ++- .../05-avoid-placed-cubes.md | 1 - .../so-arm101-palletizing/06-inline-module.md | 1 - .../tutorials/so-arm101-palletizing/_index.md | 43 ++++++-------- 8 files changed, 80 insertions(+), 118 deletions(-) diff --git a/.htmltest.yml b/.htmltest.yml index 483971ef75..0d3899f7f1 100644 --- a/.htmltest.yml +++ b/.htmltest.yml @@ -33,7 +33,7 @@ IgnoreURLs: - "twitter.com" - "github.com/viamrobotics/docs" - "github.com/viam-devrel/pick-and-place" - - "github.com/viam-devrel/so-arm101-palletizing" + - "github.com/viam-devrel/mini-palletizer" - "openai.com" - "espressif.com" - "pinout.xyz" diff --git a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md index 12680ed509..583af86265 100644 --- a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md +++ b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md @@ -4,61 +4,76 @@ linkTitle: "1. Platform mental model" type: "docs" slug: "platform-mental-model" weight: 10 -description: "How your laptop, the arm's host, and Viam fit together, and the three ideas this workshop is built on." +description: "How your computer and Viam fit together, and the three robotics concepts this workshop is built on." workshop: "so-arm101-palletizing" toc_hide: true phase: 1 phase_total: 6 -time_estimate: "15 minutes" next: "/tutorials/so-arm101-palletizing/configure-the-arm/" languages: ["python"] draft: true --- -Before you configure anything, this phase gives you a mental model of the cell you are about to build and the platform it runs on. You will not touch config or code yet, but every phase after this one leans on the ideas introduced here. +Before you configure anything, this phase gives you a mental model of the cell you are about to build and the platform it runs on. You will not touch config or code yet, but everything after this leans on the ideas introduced here. ## What you'll build -You are building a miniature palletizing cell. An SO-ARM101 arm picks cubes, one at a time, from a staging spot and stacks them on a small pallet: two layers of four, eight cubes total. The arm and its gripper are the hardware; a set of hand-taught poses and, later, your own Python code, are what turn that hardware into a working pack sequence. +You are building a miniature palletizing cell. An SO-ARM101 arm picks cubes, one at a time, from a staging spot and stacks them on a small pallet: two layers of four, eight cubes total. The arm and its gripper are the hardware; a set of hand-taught poses and, later, your own Python code, are what turn that hardware into a working pack routine. -## How your laptop, the arm's host, and Viam fit together +## The three layers -A Viam machine has three parts working together, and it helps to know which one does what before you start configuring. +A Viam machine is made of three layers that each do one job: -The Viam cloud app, at [app.viam.com](https://app.viam.com), holds your machine's configuration: the arm and gripper you add, the poses you save, and any services you configure. It is the source of truth for what the machine should run, not something that talks to the arm directly. +```text +Viam cloud app + └─ machine configuration (the source of truth) + │ + │ viam-server pulls config + ▼ +Your personal computer + ├─ viam-agent supervises viam-server (installs, updates, restarts) + └─ viam-server drives the arm and gripper, exposes the control API + ▲ + │ control API calls + │ +Your Python script +``` -On the host, either a Raspberry Pi or a laptop, two programs do the work. `viam-agent` supervises `viam-server`: it installs it, keeps it running, and restarts it if it crashes. `viam-server` reads the configuration from the cloud app, starts the resources it describes, and exposes them over an API. The SO-ARM101 itself connects to this host over a USB serial cable; `viam-server` talks to the arm's motors through that connection. +- **The Viam cloud app** is the source of truth for configuration. When you add a component, change an attribute, or configure a service, you are editing a JSON document stored in the cloud. The app never controls your robot directly; it describes what should run. +- **viam-agent** runs on your computer and manages `viam-server`: it installs it, keeps it running, and restarts it if it crashes. Think of it as the process supervisor, not something you interact with directly during this workshop. +- **viam-server** pulls the configuration from the cloud app, starts every component and service that configuration describes, and exposes them over a control API. This is the layer that talks to the arm. The SO-ARM101 connects to your computer over a USB serial cable, and `viam-server` drives the arm's motors through that connection. -Your Python code runs from your own laptop, separate from the host. When your script connects to the machine, it reaches `viam-server` over the network, using the address and credentials from the machine's **CONNECT** tab. The cloud app helps your script find and authenticate to the machine, but the actual move commands travel straight to `viam-server` on the host. +Open your machine's page in the Viam app now and find the status indicator that shows the machine is live. That indicator reflects `viam-agent` keeping `viam-server` running and connected to the cloud app, the handoff between all three layers happening continuously in the background. -## Components and services +## How your code connects -Everything a Viam machine does is modeled as a resource, and resources split into two kinds. +When a Python script imports the Viam SDK and connects to your machine, the connection goes to `viam-server`, not to the cloud app. The cloud app helps your script locate the machine and authenticate, but once the connection is established, every control API call, moving the arm and reading its position, goes directly to `viam-server`. You will write exactly this kind of script later in the workshop. -The arm and the gripper are **components**: each one wraps a piece of physical hardware and exposes an API for it. An arm component moves to a pose; a gripper component opens and closes. +Open the **CONNECT** tab on your machine's page and look at the code sample it generates. It contains your machine's address and an API key and ID pair, the things any script needs to authenticate and reach `viam-server`. -The frame system and the motion service are **services**: software that reasons about the components rather than being hardware itself. The frame system tracks where every object in the cell sits relative to the arm's base. The motion service uses that information to plan a path from where the arm is to where it needs to be, without colliding with anything along the way. +## Components and services -## The three ideas this workshop is built on +Everything a Viam machine does is modeled as a resource, and resources split into components and services. -Three concepts carry the whole workshop. This section only previews them; you will work with each one directly in a later phase. +- The arm and the gripper are **components**: each one wraps a piece of physical hardware and exposes a standard API for it. An arm moves to a pose; a gripper opens and closes. +- The **motion service** is a service: software that plans how the arm should move, rather than hardware you can point at. You configure it like any other resource and call its API to move the arm. -The **frame system** answers "where is everything, relative to the arm?" Every pose you teach, and every obstacle you configure, is expressed as a position relative to the arm's base frame. Once something has a place in the frame system, the arm and the motion service can reason about it. +The **frame system** is neither. It is built into `viam-server` itself, not a resource you add or call directly. It keeps track of where every component and object sits relative to the others, and the motion service uses it to reason about the cell. You will watch it at work in the 3D scene. -**Motion planning** answers "how does the arm get there?" Given a target pose, the motion service works out a path of joint movements that reaches it. You will not write this logic yourself; you call the motion service and it does the planning. +## Three robotics concepts to learn -**WorldState** answers "what must the planner avoid?" It is the set of obstacles, expressed in the frame system, that the motion service treats as solid. In this workshop, WorldState grows as you pack: once a cube is sitting on the pallet, it becomes an obstacle the planner must route around when placing the next one. +Three concepts carry the whole workshop. This section only previews them; you will work with each one directly in a later phase. -## Why a real arm changes the job +The **frame system** answers "where is everything, relative to the arm?" Every pose you teach, and every obstacle you configure, is a position in the frame system, expressed relative to the arm's base. You teach the two key positions to the arm by hand in Phase 3. -Nothing in your cell comes pre-measured. To plan a motion, the arm needs to know where the staging spot and the pallet grid actually sit, expressed in its own frame. You map that yourself: in Phase 3 you jog the arm to each anchor point and record where it is. +**Motion planning** answers "how does the arm get there?" Given a target pose, the motion service works out a path of joint movements that reaches it without colliding with anything. You do not write this logic yourself; you call the motion service and it does the planning. -The SO-ARM101 also has one fewer joint than an industrial arm: it has five degrees of freedom (5-DOF) rather than six. A 5-DOF arm cannot reach an arbitrary position at an arbitrary orientation the way a 6-DOF arm can, so the arm's motion planner defaults to matching the target position and letting the final orientation fall where it may (a `position_only` goal). Because the cubes are rotationally symmetric, the exact rotation of the gripper around a cube does not matter, so this limitation never gets in the way in this workshop. +**WorldState** answers "what must the planner avoid?" It is the set of obstacles, expressed in the frame system, that the motion service treats as solid. In this workshop, WorldState grows as you pack: once a cube is sitting on the pallet, it becomes an obstacle the planner routes around when placing the next one. {{< checkpoint >}} -Open your machine in the Viam app and confirm the green **Live** indicator. You should be able to name the two components you will configure (arm and gripper) and the two services the arm relies on to move (frame system and motion). If your machine is not Live, start `viam-server` on the host before continuing. +Open your machine in the Viam app and confirm the green **Live** indicator, so you know `viam-server` is running and reachable before you configure anything. You should also be able to say, in your own words, what the frame system, the motion service, and WorldState each do, since every later phase builds on them. If the machine is not Live, start `viam-server` on your computer before continuing. {{< /checkpoint >}} With the mental model in place, [Phase 2](/tutorials/so-arm101-palletizing/configure-the-arm/) is where you add the arm and gripper, verify them, and place them in the frame system. diff --git a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md index 90d004ce83..2341b4944c 100644 --- a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md +++ b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md @@ -9,7 +9,6 @@ workshop: "so-arm101-palletizing" toc_hide: true phase: 2 phase_total: 6 -time_estimate: "20 minutes" prev: "/tutorials/so-arm101-palletizing/platform-mental-model/" next: "/tutorials/so-arm101-palletizing/teach-the-cell/" languages: ["python"] @@ -22,37 +21,23 @@ In this phase you configure the arm and gripper, verify them with their test car Rather than typing the arm and gripper configs by hand, start with the SO-ARM101 module's **discovery service**. A discovery service reports the hardware attached to a machine and suggests configurations for it, so you configure the right components without hunting for serial ports or attribute names by hand. See [Discovery service](/reference/services/discovery/) for the general pattern. -On the **CONFIGURE** tab, click the **+** icon and select **Blocks**. Search for `so101` and select the `devrel:so101:discovery` result. Leave its name as the default and save the config. Saving is the moment `viam-server` downloads the `devrel:so101` module; the arm, gripper, and calibration models you add later in this phase come from that same module, so the download happens only once. +On the **CONFIGURE** tab, click the **+** icon and select **Blocks**. Search for `so101` and select the `so101/discovery` result. Leave its name as the default and save the config. Saving is the moment `viam-server` downloads the SO-ARM101 module; the arm and gripper models you add later in this phase come from that same module, so the download happens only once. Before you open the discovery service's test panel, know what it is looking for: the serial port your SO-ARM101 is connected to over USB. - On Linux, the port shows up as `/dev/ttyUSB0` or `/dev/ttyACM0`. - On macOS, look under `/dev/tty.*` for a name containing `usbmodem` or `usbserial`. -Open the discovery service's **TEST** panel. It scans for a connected SO-ARM101 and, if it finds one, returns ready-made configuration snippets for the arm and the gripper, with the detected `port` already filled in. The discovery service also suggests a calibration sensor you can add if the arm still needs calibrating. +Open the discovery service's **TEST** panel. It scans for a connected SO-ARM101 and, if it finds one, returns ready-made configuration snippets for the arm and the gripper, with the detected `port` already filled in. -With the arm connected and powered, select **Add component** next to each suggested snippet to create the arm and gripper components from it. If discovery does not find your arm, confirm the USB cable is connected and that no other program (such as a LeRobot script) is holding the serial port open, then retry. +With the arm connected and powered, select **Add component** next to each suggested snippet to create the arm and gripper components from it. If discovery does not find your arm, confirm the USB cable is connected and that no other program is holding the serial port open, then retry. {{< alert title="Discovery has done its job" color="note" >}} Like the config it suggests, the discovery service is not part of the pack sequence you build later in this workshop. Leave it in place if you expect to re-discover hardware, or remove it once the arm and gripper are configured. {{< /alert >}} -## Calibrate the arm (first-time builds) - -If discovery did not find an existing calibration file, your SO-ARM101 has not been calibrated yet and needs one guided pass before its joint positions are accurate. Add the `devrel:so101:calibration` sensor discovery suggested, or add it by hand: click the **+** icon, select **Blocks**, search for `so101`, select the `devrel:so101:calibration` result, and set its `port` to the same value as the arm. - -```json -{ - "port": "/dev/ttyUSB0" -} -``` - -Open the sensor's test panel and follow the guided workflow: first you set the homing position, then you move each joint through its full range while the sensor records the min and max it sees. The sensor saves the result to a calibration file the arm and gripper read on startup. - -This workshop does not reproduce the full calibration flow. If you built and calibrated your arm following the [prerequisites](/tutorials/so-arm101-palletizing/#prerequisites), skip this section. Otherwise, follow the guided steps in the [SO-ARM101 module documentation](https://app.viam.com/module/devrel/so101-arm) before continuing. - ## Add the arm component If you used discovery, confirm the arm component it created has one attribute, `port`, set to your arm's serial port: @@ -63,9 +48,9 @@ If you used discovery, confirm the arm component it created has one attribute, ` } ``` -If you are configuring the arm by hand instead, click the **+** icon, select **Blocks**, search for `so101`, and select the `devrel:so101:arm` result. Name it `arm`, paste the attribute above with your own port, and save. +If you are configuring the arm by hand instead, click the **+** icon, select **Blocks**, search for `so101`, and select the `so101/arm` result. Name it `arm`, paste the attribute above with your own port, and save. -Open the **CONTROL** tab and find the arm's test card. Test cards call the same API your Python code calls later in this workshop; jogging a joint here is an API call, not a simulation. Move one joint slider a small amount and press **Execute**, then watch the physical arm turn. +Open the **CONTROL** tab and find the arm's test card. Test cards call the same API your Python code calls later in this workshop; jogging a joint here is a real API call that moves the hardware. Move one joint slider a small amount and press **Execute**, then watch the physical arm turn. {{< alert title="The arm is about to move" color="caution" >}} Keep the workspace clear and change one joint a small amount at a time. Large or combined joint moves can drive the arm into the table or itself. @@ -87,7 +72,7 @@ If you used discovery, confirm the gripper component it created carries this sam } ``` -If you are configuring the gripper by hand, click the **+** icon, select **Blocks**, search for `so101`, and select the `devrel:so101:gripper` result. Name it `gripper`, set `port` to the same value as the arm's, and save. +If you are configuring the gripper by hand, click the **+** icon, select **Blocks**, search for `so101`, and select the `so101/gripper` result. Name it `gripper`, set `port` to the same value as the arm's, and save. Open the gripper's test card on the **CONTROL** tab. Press **Open** and watch the jaw open, then press **Grab** and watch it close. @@ -125,13 +110,11 @@ The gripper is a separate component with its own collision geometry, so it needs } ``` -The zero offset is specific to this arm. The `devrel:so101:gripper` model already positions its jaw geometry relative to the arm's end point, so attaching the gripper there with no offset places the jaws correctly. A gripper on a different arm would instead need the measured offset from the arm's end point to the jaws entered here. - -Attaching the gripper to the arm does two things. The 3D scene draws the gripper on the end of the arm, and the motion service includes the gripper's shape when it plans, so it keeps the jaws clear of obstacles. The arm's kinematic chain ends at the point where the gripper mounts, and that end point is what the motion service drives to the poses you command later. You never enter a tool-center-point (TCP) by hand: in Phase 3 you position the gripper's jaws where you want them and record where the arm's end point lands, and replaying that record in Phase 4 returns the jaws to the same place. +Attaching the gripper to the arm places its shape in the cell: the 3D scene draws the gripper on the end of the arm, and the motion service accounts for the gripper's shape when it plans, so it keeps the jaws clear of obstacles. ## See it in the 3D scene -Open the **3D scene** tab. The arm renders using the kinematics built into the `devrel:so101:arm` model, sitting at the frame you configured, with the gripper attached at its end point. This is the same view you will return to throughout the rest of the workshop to watch the pack sequence run. +Open the **3D scene** tab. The arm renders using the kinematics built into the `so101/arm` model, sitting at the frame you configured, with the gripper attached at its end point. This is the same view you will return to throughout the rest of the workshop to watch the pack sequence run. diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md index ef76537a51..6b94f6bddb 100644 --- a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md +++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md @@ -4,19 +4,18 @@ linkTitle: "3. Teach the cell" type: "docs" slug: "teach-the-cell" weight: 30 -description: "Move the arm by hand with torque disabled, read back two anchor poses, and compute the pallet grid from them." +description: "Move the arm by hand with torque disabled, read two anchor poses off its test card, and compute the pallet grid from them." workshop: "so-arm101-palletizing" toc_hide: true phase: 3 phase_total: 6 -time_estimate: "20 minutes" prev: "/tutorials/so-arm101-palletizing/configure-the-arm/" next: "/tutorials/so-arm101-palletizing/pack-from-python/" languages: ["python"] draft: true --- -In this phase you map the physical cell into the arm's frame. Nothing in the cell is pre-measured: you find where the staging spot and the pallet actually sit, expressed in the arm's own coordinate frame, by moving the arm there yourself and reading back where it ended up. You capture two anchor poses this way, then compute the rest of the pallet grid from them arithmetically. +In this phase you map the physical cell into the arm's frame. Nothing in the cell is pre-measured: you find where the staging spot and the pallet actually sit, expressed in the arm's own coordinate frame, by moving the arm there yourself and reading its position back from the Viam app. You capture two anchor poses this way, then compute the rest of the pallet grid from them arithmetically. {{< alert title="The arm goes limp" color="caution" >}} Disabling torque lets you move the arm by hand, but it also means the arm no longer holds its position against gravity. It drops as soon as you disable torque, and stays free to fall until you re-enable it. Support the arm with one hand while torque is off, clear the workspace and cubes from underneath it, and re-enable torque before you send any motion command. @@ -37,48 +36,23 @@ The SO-ARM101 module exposes a `set_torque` command over `DoCommand`. On the arm } ``` -You can send the same command from a terminal with `viam machines part run` if you prefer the command-line interface (CLI) to the test card. Once the command succeeds, the arm's joints go slack and you can move it by hand. +Once the command succeeds, the arm's joints go slack and you can move it by hand. -## Capture the staging pose - -With torque disabled, gently guide the gripper to the staging spot, the place where you will set down one cube at the start of every pick cycle in later phases. Hold the arm steady once it's in position. - -`capture_pose.py` is a small, self-contained script that connects to your machine and prints the arm's current end-effector pose. It needs nothing beyond the Viam Python SDK: no companion project, no helper file, just the code below. Its core is a single call to the arm's `get_end_position` API: - -```python -import asyncio -from viam.robot.client import RobotClient -from viam.components.arm import Arm +## Read the arm's position from the app +You do not need any code to read where the arm is. The arm's test card on the **CONTROL** tab shows its current **end position**: the x, y, and z of the arm's end point, in millimeters, plus an orientation. As you move the arm by hand with torque disabled, that readout updates to track it. Because you placed the arm's base at the world origin in Phase 2, this end position is also a world pose, which is exactly what the motion service expects when you write `palletizer.py` in Phase 4. -async def main(): - opts = RobotClient.Options.with_api_key( - api_key="", api_key_id="" - ) - robot = await RobotClient.at_address("", opts) - arm = Arm.from_robot(robot, "arm") - pose = await arm.get_end_position() - print(f"x={pose.x:.1f} y={pose.y:.1f} z={pose.z:.1f}") - print(f"o_x={pose.o_x:.3f} o_y={pose.o_y:.3f} o_z={pose.o_z:.3f} theta={pose.theta:.1f}") - await robot.close() +You position the arm so the gripper's jaws sit where you want them, then read the end position off the card. Because the gripper is rigidly attached, driving the arm's end point back to that same pose later returns the jaws to the same spot. + -asyncio.run(main()) -``` - -Fill in the API key, key ID, and machine address from your machine's **CONNECT** tab. Save this as `capture_pose.py` and run it with: - -```sh -uv run --with viam-sdk python capture_pose.py -``` - -The script calls the arm's standard [`get_end_position`](/reference/apis/components/arm/#getendposition) API, which returns the arm's end point, where the gripper mounts, computed by forward kinematics: x, y, and z in millimeters, plus an orientation, already expressed in the arm's own frame. You position the arm so the gripper's jaws sit at the spot you want, then record that end point; because the gripper is rigidly attached, driving the end point back to the same pose in Phase 4 returns the jaws to the same spot. Because you placed the arm's base at the world origin in Phase 2, this pose is also the pose in the world frame, which is what the motion service expects when you write `palletizer.py` in Phase 4. Move the arm slightly and run the script again to confirm the printed numbers change with it. +## Capture the staging pose -Record the printed x, y, and z for the staging pose. You will save these numbers in the last section of this phase. +With torque disabled, gently guide the gripper to the staging spot, the place where you will set down one cube at the start of every pick cycle in later phases. Hold the arm steady once it is in position, then read the **end position** off the arm's test card and record the x, y, and z. This is your staging pose. Move the arm slightly and watch the readout change, so you know it is tracking the live position, then guide it back and re-read if needed. ## Capture the pallet origin corner -Still with torque disabled, guide the gripper to the bottom-layer corner of the pallet, cell [0, 0], the corner you treat as the origin of the pallet grid. Run `capture_pose.py` again and record the printed x, y, and z. This is your pallet origin pose. +Still with torque disabled, guide the gripper to the bottom-layer corner of the pallet, cell [0, 0], the corner you treat as the origin of the pallet grid. Read the **end position** again and record the x, y, and z. This is your pallet origin pose. ## Re-enable torque @@ -130,10 +104,10 @@ The staging pose is not part of this grid. It stays a single fixed pose for the ## Save your anchors -Write down the two poses you just captured, staging and pallet origin, each as the x, y, and z you read back with `capture_pose.py`. Keep this note handy: in Phase 4 you paste these numbers into the companion project's `helpers.py`, into the `STAGING_POSE` and `PALLET_ORIGIN` constants that `palletizer.py` reads. From there, `palletizer.py` passes `PALLET_ORIGIN` into `helpers.grid` to get all eight target poses, and uses `STAGING_POSE` as the fixed pick location for every cycle. +Write down the two poses you just read, staging and pallet origin, each as the x, y, and z from the arm's test card. Keep this note handy: in Phase 4 you paste these numbers into the companion project's `helpers.py`, into the `STAGING_POSE` and `PALLET_ORIGIN` constants that `palletizer.py` reads. From there, `palletizer.py` passes `PALLET_ORIGIN` into `helpers.grid` to get all eight target poses, and uses `STAGING_POSE` as the fixed pick location for every cycle. {{< checkpoint >}} -With torque disabled, running `capture_pose.py` repeatedly while you move the arm by hand returns different x, y, and z values each time, confirming the readback tracks the physical arm. After you re-enable torque, the arm holds its pose and does not drift when you let go. You have two recorded poses, staging and pallet origin, written down and ready to carry into Phase 4. If `capture_pose.py` returns the same values every time, confirm torque is actually disabled; if the arm still droops after re-enabling torque, resend the `set_torque` command with `enable` set to `true` and check the LOGS tab for a serial error. +With torque disabled, the arm's end position on its test card changes as you move the arm by hand, confirming the readout tracks the physical arm. After you re-enable torque, the arm holds its pose and does not drift when you let go. You have two recorded poses, staging and pallet origin, written down and ready to carry into Phase 4. If the readout does not change as you move the arm, confirm torque is actually disabled; if the arm still droops after re-enabling torque, resend the `set_torque` command with `enable` set to `true` and check the LOGS tab for a serial error. {{< /checkpoint >}} With your two anchor poses recorded, [Phase 4](/tutorials/so-arm101-palletizing/pack-from-python/) is where you write the Python that reads them back and drives the arm through a pick-and-place pack. diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md index 7e536df12e..24aaa70764 100644 --- a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md +++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md @@ -9,22 +9,21 @@ workshop: "so-arm101-palletizing" toc_hide: true phase: 4 phase_total: 6 -time_estimate: "20 minutes" prev: "/tutorials/so-arm101-palletizing/teach-the-cell/" next: "/tutorials/so-arm101-palletizing/avoid-placed-cubes/" languages: ["python"] draft: true --- -In this phase you write `palletizer.py`, a Python script that reads back the two anchor poses from Phase 3 and drives the arm through a pick-and-place cycle for each of the four bottom-layer cells. Getting through this phase is milestone one: you drive the arm through a static pack from your own code, with no obstacle avoidance yet, on real hardware. +In this phase you write `palletizer.py`, a Python script that reads back the two anchor poses from Phase 3 and drives the arm through a packing routine for each of the four bottom-layer cells. Getting through this phase is milestone one: you drive the arm through a static pack from your own code, on real hardware. ## Set up the companion project Clone the workshop's companion repository and work from it for the rest of this phase: ```sh -git clone https://github.com/viam-devrel/so-arm101-palletizing.git -cd so-arm101-palletizing +git clone https://github.com/viam-devrel/mini-palletizer.git +cd mini-palletizer ``` 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. @@ -37,7 +36,7 @@ Open `helpers.py` and set the two constants `STAGING_POSE` and `PALLET_ORIGIN` t ## What the helpers give you -Start from [helpers.py](https://github.com/viam-devrel/so-arm101-palletizing/blob/main/helpers.py); import it rather than rewriting the connection and grid math. It gives you: +Start from [helpers.py](https://github.com/viam-devrel/mini-palletizer/blob/main/helpers.py); import it rather than rewriting the connection and grid math. It gives you: - `helpers.connect()`, an `async` function that returns a connected `RobotClient`. - The arm's resource name (`helpers.ARM`), which you hand to the motion service, plus the gripper and motion-service names (`helpers.GRIPPER`, `helpers.MOTION`), which you pass to `from_robot`. All three name resources configured in Phase 2. diff --git a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md index 0fa664ec4d..7eb142c3e0 100644 --- a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md +++ b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md @@ -9,7 +9,6 @@ workshop: "so-arm101-palletizing" toc_hide: true phase: 5 phase_total: 6 -time_estimate: "22 minutes" prev: "/tutorials/so-arm101-palletizing/pack-from-python/" next: "/tutorials/so-arm101-palletizing/inline-module/" languages: ["python"] diff --git a/docs/tutorials/so-arm101-palletizing/06-inline-module.md b/docs/tutorials/so-arm101-palletizing/06-inline-module.md index fbd5434a40..1a026f557d 100644 --- a/docs/tutorials/so-arm101-palletizing/06-inline-module.md +++ b/docs/tutorials/so-arm101-palletizing/06-inline-module.md @@ -9,7 +9,6 @@ workshop: "so-arm101-palletizing" toc_hide: true phase: 6 phase_total: 6 -time_estimate: "15 minutes" prev: "/tutorials/so-arm101-palletizing/avoid-placed-cubes/" next: "/tutorials/so-arm101-palletizing/wrap-up/" languages: ["python"] diff --git a/docs/tutorials/so-arm101-palletizing/_index.md b/docs/tutorials/so-arm101-palletizing/_index.md index 9647442c59..1ccbd81a88 100644 --- a/docs/tutorials/so-arm101-palletizing/_index.md +++ b/docs/tutorials/so-arm101-palletizing/_index.md @@ -15,50 +15,43 @@ workshop_overview: true time_estimate: "2 hours" hardware: - "SO-ARM101 (5-DOF + gripper)" - - "8 × ~20 mm cubes" - - "Raspberry Pi or laptop host" -companion_repo: "https://github.com/viam-devrel/so-arm101-palletizing" + - "Eight ~20 mm cubes" + - "Personal computer" +companion_repo: "https://github.com/viam-devrel/mini-palletizer" no_list: true draft: true --- -You will build a miniature palletizing cell: an SO-ARM101 arm picks cubes from a staging spot and stacks them on a small pallet, two layers of four. It all runs on a real arm you can hold in your hands. +Warehouse robots spend all day stacking boxes onto pallets. In this workshop you build the same thing in miniature, on your desk: an affordable SO-ARM101 arm that picks up cubes and stacks them neatly, two layers at a time. -The workshop is structured as six sequential phases, each ending with checkpoints you can verify before moving on. Finishing Phase 4, where you drive the robot from your own Python code through a static, pre-planned pack, is a complete success. Phases 5 and 6 go further. +It is a hands-on introduction to robot manipulation with Viam, for developers and makers who would rather program a real arm than a simulator. You do not need industrial hardware or prior robotics experience, just a desktop arm you can build yourself. You configure it, teach it where the cubes and the pallet are by guiding it with your own hands, then write the Python that runs the whole packing routine. By the end, the arm packs a full stack of cubes on its own, driven entirely by code you wrote. ## What you'll build -You will configure an SO-ARM101 arm with its finger gripper, teach it a small set of anchor poses by hand using its freedrive (torque-off) capability, then write a Python script that reads those poses back and plans a collision-free pick-and-stack sequence. By the end, you will have packed a two-by-two-by-two stack of cubes onto a marked pallet area, all driven by code you wrote yourself. +You configure an SO-ARM101 arm and its finger gripper, teach it a small set of anchor poses by hand using its freedrive (torque-off) capability, then write a Python program that reads those poses back and plans a collision-free pick-and-stack. The finished cell packs a two-by-two-by-two stack of eight cubes onto a marked pallet, one cube at a time. ## Required hardware -- **SO-ARM101**: the five degree-of-freedom (5-DOF) arm with a finger gripper, connected to its host over USB serial. -- **Eight cubes**, roughly 20 mm on a side. -- **A flat surface** with a marked pallet area about 60 by 60 mm, plus a staging spot for the cubes. -- **A host**, either a Raspberry Pi or a laptop, running `viam-agent` and `viam-server`. +- **An SO-ARM101 arm** with its finger gripper, connected to your computer over USB. +- **Eight cubes and a pallet mat.** You need eight cubes about 20 mm on a side, a two-by-two pallet grid to stack them on, and a staging spot to feed cubes from. The companion project includes a printable template: paper cube nets you fold into 20 mm cubes, and a mat that marks the pallet grid and the staging spot at the exact spacing the code expects. Print it, cut it out, and you are ready. Wooden or foam 20 mm cubes work too if you have them. +- **A personal computer** running `viam-agent` and `viam-server`, with the arm plugged into it over USB. ## Phases -Phases 1 through 4 are the core workshop. Phase 5 adds obstacle avoidance, and Phase 6 is optional. - -1. **[Platform mental model](/tutorials/so-arm101-palletizing/platform-mental-model/)** (~15 min) -2. **[Configure the SO-ARM101](/tutorials/so-arm101-palletizing/configure-the-arm/)** (~20 min) -3. **[Teach the cell by hand](/tutorials/so-arm101-palletizing/teach-the-cell/)** (~20 min) -4. **[Pack from Python](/tutorials/so-arm101-palletizing/pack-from-python/)** (~20 min, milestone one: a static pack from your own code) -5. **[Avoid placed cubes](/tutorials/so-arm101-palletizing/avoid-placed-cubes/)** (~22 min, milestone two: a collision-free full pack) -6. **[Wrap it in a module](/tutorials/so-arm101-palletizing/inline-module/)** (~15 min, optional) +1. **[Platform mental model](/tutorials/so-arm101-palletizing/platform-mental-model/)** +2. **[Configure the SO-ARM101](/tutorials/so-arm101-palletizing/configure-the-arm/)** +3. **[Teach the cell by hand](/tutorials/so-arm101-palletizing/teach-the-cell/)** +4. **[Pack from Python](/tutorials/so-arm101-palletizing/pack-from-python/)** (milestone one: a static pack from your own code) +5. **[Avoid placed cubes](/tutorials/so-arm101-palletizing/avoid-placed-cubes/)** (milestone two: a collision-free full pack) +6. **[Wrap it in a module](/tutorials/so-arm101-palletizing/inline-module/)** (optional) When you finish, the **[wrap-up](/tutorials/so-arm101-palletizing/wrap-up/)** reviews what you built and points to next steps. -## Working with a 5-DOF arm - -Along the way you learn to map the physical world into the arm's frame and to reason about a five degree-of-freedom (5-DOF) arm's reach. A 5-DOF arm plans to a target position without pinning the final orientation, and because the cubes are rotationally symmetric, that does not matter for this task. - ## Companion code -All supporting files for this workshop live in the [viam-devrel/so-arm101-palletizing](https://github.com/viam-devrel/so-arm101-palletizing) repository. `helpers.py` is provided for you; you build `palletizer.py` yourself as you work through the phases. +All supporting files for this workshop live in the [viam-devrel/mini-palletizer](https://github.com/viam-devrel/mini-palletizer) repository, including the printable cube-and-pallet template. `helpers.py` is provided for you; you build `palletizer.py` yourself as you work through the phases. ## Prerequisites @@ -66,7 +59,7 @@ This is a self-serve workshop, so confirm each of the following before you start - **A Viam account with an online machine.** Log in at [app.viam.com](https://app.viam.com), [create a machine](https://docs.viam.com/set-up-a-machine/first-machine/), and confirm the green **Live** indicator before you begin. - **Python 3.10 or newer.** Install it with [uv](https://docs.astral.sh/uv/getting-started/installation/) (recommended) or from [python.org](https://www.python.org/downloads/). -- **An SO-ARM101 that's built, has its motors configured, and is calibrated.** Follow the first-time arm setup steps in the [SO-ARM101 module](https://app.viam.com/module/devrel/so101-arm) documentation: install the LeRobot software, configure the motors, build the arm, and calibrate it. +- **An SO-ARM101 that's built, has its motors configured, and is calibrated.** Follow the first-time arm setup steps in the [SO-ARM101 module](https://app.viam.com/module/devrel/so101-arm) documentation: configure the motors, build the arm, and calibrate it. ### Validate your environment @@ -84,4 +77,4 @@ If either command fails, revisit the checklist above before continuing. - **Arm built and machine online (`viam-server` running):** start at [Phase 1](/tutorials/so-arm101-palletizing/platform-mental-model/). -- **Still building your SO-ARM101:** complete the [first-time arm setup](https://app.viam.com/module/devrel/so101-arm) (install LeRobot, configure the motors, build, and calibrate), then return here for [Phase 1](/tutorials/so-arm101-palletizing/platform-mental-model/). +- **Still building your SO-ARM101:** complete the [first-time arm setup](https://app.viam.com/module/devrel/so101-arm) (configure the motors, build the arm, and calibrate it), then return here for [Phase 1](/tutorials/so-arm101-palletizing/platform-mental-model/). From 1f9240ee4ef6dcbe4b62713e7fc34e8fbb05e1f9 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sun, 12 Jul 2026 09:43:01 -0400 Subject: [PATCH 20/29] docs(tutorials): apply second round of review feedback - Overview: drop redundant "What you'll build"; require only viam-server - Phase 1: trim intro; drop viam-agent from the three-layers (not on macOS); remove frame-system from components/services; trim motion line - Phase 2: explain what parenting the arm to the world frame means - Phase 3: add a DoCommand intro; drop stray lines; move the grid derivation out to Phase 4 - Phase 4: add "The pallet grid" section with a stacked-grid diagram (plus an isometric ASSET); drop "You do not rewrite any of this" --- .../01-platform-mental-model.md | 13 ++--- .../02-configure-the-arm.md | 6 +- .../03-teach-the-cell.md | 43 ++------------ .../04-pack-from-python.md | 56 ++++++++++++++++++- .../tutorials/so-arm101-palletizing/_index.md | 8 +-- 5 files changed, 70 insertions(+), 56 deletions(-) diff --git a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md index 583af86265..36ae9500da 100644 --- a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md +++ b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md @@ -14,7 +14,7 @@ languages: ["python"] draft: true --- -Before you configure anything, this phase gives you a mental model of the cell you are about to build and the platform it runs on. You will not touch config or code yet, but everything after this leans on the ideas introduced here. +Before you configure anything, this phase gives you a mental model of the cell you are about to build and the platform it runs on. Everything after this leans on the ideas introduced here. ## What you'll build @@ -33,7 +33,6 @@ Viam cloud app │ viam-server pulls config ▼ Your personal computer - ├─ viam-agent supervises viam-server (installs, updates, restarts) └─ viam-server drives the arm and gripper, exposes the control API ▲ │ control API calls @@ -42,10 +41,10 @@ Your Python script ``` - **The Viam cloud app** is the source of truth for configuration. When you add a component, change an attribute, or configure a service, you are editing a JSON document stored in the cloud. The app never controls your robot directly; it describes what should run. -- **viam-agent** runs on your computer and manages `viam-server`: it installs it, keeps it running, and restarts it if it crashes. Think of it as the process supervisor, not something you interact with directly during this workshop. -- **viam-server** pulls the configuration from the cloud app, starts every component and service that configuration describes, and exposes them over a control API. This is the layer that talks to the arm. The SO-ARM101 connects to your computer over a USB serial cable, and `viam-server` drives the arm's motors through that connection. +- **viam-server** runs on your computer. It pulls the configuration from the cloud app, starts every component and service that configuration describes, and exposes them over a control API. This is the layer that talks to the arm: the SO-ARM101 connects to your computer over a USB serial cable, and `viam-server` drives the arm's motors through that connection. +- **Your Python script** connects to `viam-server` and calls that control API to move the arm and read its position. You write it later in the workshop. -Open your machine's page in the Viam app now and find the status indicator that shows the machine is live. That indicator reflects `viam-agent` keeping `viam-server` running and connected to the cloud app, the handoff between all three layers happening continuously in the background. +Open your machine's page in the Viam app now and find the status indicator that shows the machine is live. That indicator confirms `viam-server` is running on your computer and connected to the cloud app. ## How your code connects @@ -60,15 +59,13 @@ Everything a Viam machine does is modeled as a resource, and resources split int - The arm and the gripper are **components**: each one wraps a piece of physical hardware and exposes a standard API for it. An arm moves to a pose; a gripper opens and closes. - The **motion service** is a service: software that plans how the arm should move, rather than hardware you can point at. You configure it like any other resource and call its API to move the arm. -The **frame system** is neither. It is built into `viam-server` itself, not a resource you add or call directly. It keeps track of where every component and object sits relative to the others, and the motion service uses it to reason about the cell. You will watch it at work in the 3D scene. - ## Three robotics concepts to learn Three concepts carry the whole workshop. This section only previews them; you will work with each one directly in a later phase. The **frame system** answers "where is everything, relative to the arm?" Every pose you teach, and every obstacle you configure, is a position in the frame system, expressed relative to the arm's base. You teach the two key positions to the arm by hand in Phase 3. -**Motion planning** answers "how does the arm get there?" Given a target pose, the motion service works out a path of joint movements that reaches it without colliding with anything. You do not write this logic yourself; you call the motion service and it does the planning. +**Motion planning** answers "how does the arm get there?" Given a target pose, the motion service works out a path of joint movements that reaches it without colliding with anything. **WorldState** answers "what must the planner avoid?" It is the set of obstacles, expressed in the frame system, that the motion service treats as solid. In this workshop, WorldState grows as you pack: once a cube is sitting on the pallet, it becomes an obstacle the planner routes around when placing the next one. diff --git a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md index 2341b4944c..5686bffb83 100644 --- a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md +++ b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md @@ -84,7 +84,11 @@ Pressing **Open** and **Grab** on the gripper's test card opens and closes the p Adding the arm and gripper tells `viam-server` how to talk to them, but not where they sit in the cell. As Phase 1 covered, the frame system answers that question for every component in the workshop: a frame places a component relative to a parent, and every frame traces back to `world`. See [Frame system](/motion-planning/frame-system/overview/) for the general concept. -Open the arm's card on the **CONFIGURE** tab and select **Frame**. Set the arm's frame so its base sits at the world origin: parent `world`, translation `(0, 0, 0)`, no rotation, which is what the default frame already describes. Leave the defaults and save. Keeping the base at the world origin means the poses you read back in Phase 3 are already world poses, which keeps your motion code simple. +The **world frame** is the fixed reference point for the whole cell, the origin that every other position is measured from. Every frame in the system traces back to it. Placing the arm's frame with parent `world` and translation `(0, 0, 0)` puts the arm's base exactly at that origin. + +That choice matters for the next phase. Because the arm's base is the world origin, any position you read from the arm, including the poses you capture by hand in Phase 3, is already a position in the world frame, with no conversion needed. You use the arm itself as your measuring tool, and what it reports is directly usable. + +Open the arm's card on the **CONFIGURE** tab and select **Frame**. The default frame already describes parent `world`, translation `(0, 0, 0)`, and no rotation, so you can leave the defaults and save. ```json { diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md index 6b94f6bddb..fa56c68c09 100644 --- a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md +++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md @@ -15,7 +15,7 @@ languages: ["python"] draft: true --- -In this phase you map the physical cell into the arm's frame. Nothing in the cell is pre-measured: you find where the staging spot and the pallet actually sit, expressed in the arm's own coordinate frame, by moving the arm there yourself and reading its position back from the Viam app. You capture two anchor poses this way, then compute the rest of the pallet grid from them arithmetically. +In this phase you map the physical cell into the arm's frame. Nothing in the cell is pre-measured: you find where the staging spot and the pallet actually sit, expressed in the arm's own coordinate frame, by moving the arm there yourself and reading its position back from the Viam app. You capture two anchor poses this way; the code you write in Phase 4 computes the rest of the pallet grid from them. {{< alert title="The arm goes limp" color="caution" >}} Disabling torque lets you move the arm by hand, but it also means the arm no longer holds its position against gravity. It drops as soon as you disable torque, and stays free to fall until you re-enable it. Support the arm with one hand while torque is off, clear the workspace and cubes from underneath it, and re-enable torque before you send any motion command. @@ -27,7 +27,9 @@ This cell needs eight target poses, one per cube, and teaching all eight by hand ## Disable torque -The SO-ARM101 module exposes a `set_torque` command over `DoCommand`. On the arm's test card on the **CONTROL** tab, open the DoCommand box and send: +The standard arm API covers moving the arm and reading its position, but hardware often has extra capabilities that do not fit those standard methods. Viam exposes those through **`DoCommand`**, a general-purpose command channel a module can use to accept commands specific to its hardware. The SO-ARM101 module uses it for a `set_torque` command that turns the servos' holding torque on and off. + +On the arm's test card on the **CONTROL** tab, open the DoCommand box and send: ```json { @@ -40,7 +42,7 @@ Once the command succeeds, the arm's joints go slack and you can move it by hand ## Read the arm's position from the app -You do not need any code to read where the arm is. The arm's test card on the **CONTROL** tab shows its current **end position**: the x, y, and z of the arm's end point, in millimeters, plus an orientation. As you move the arm by hand with torque disabled, that readout updates to track it. Because you placed the arm's base at the world origin in Phase 2, this end position is also a world pose, which is exactly what the motion service expects when you write `palletizer.py` in Phase 4. +The arm's test card on the **CONTROL** tab shows its current **end position**: the x, y, and z of the arm's end point, in millimeters, plus an orientation. As you move the arm by hand with torque disabled, that readout updates to track it. Because you placed the arm's base at the world origin in Phase 2, this end position is also a position in the world frame. You position the arm so the gripper's jaws sit where you want them, then read the end position off the card. Because the gripper is rigidly attached, driving the arm's end point back to that same pose later returns the jaws to the same spot. @@ -67,41 +69,6 @@ Send the same `DoCommand` with `enable` flipped to `true`: The arm's joints stiffen and it holds its current position. Confirm this by letting go of the arm; it should stay put instead of drooping. -## Derive the grid - -With the pallet origin corner captured, the remaining seven target poses follow from two constants: the center-to-center spacing between cube slots, and the cube's own size, which sets the offset between the two stacked layers. - -```python -PITCH = 30 # mm, center-to-center spacing between adjacent pallet cells -CUBE = 20 # mm, cube side length, and the z offset between layers -``` - -The four bottom-layer cells are the origin corner plus every combination of `(0, PITCH)` in x and y: - -```text -(0, 0) (PITCH, 0) -(0, PITCH) (PITCH, PITCH) -``` - -The top layer repeats those same four x, y offsets at `z + CUBE`. In Phase 4, the companion project's `helpers.py` wraps this same arithmetic in a `grid` function: - -```python -def grid(origin, pitch, cube): - """Return the eight target poses for a two-layer, four-cell pallet, - given the bottom-layer origin corner (cell [0, 0]).""" - bottom = [ - Pose(x=origin.x + dx, y=origin.y + dy, z=origin.z) - for dx in (0, pitch) - for dy in (0, pitch) - ] - top = [Pose(x=p.x, y=p.y, z=p.z + cube) for p in bottom] - return bottom + top -``` - -These are positions only. In Phase 4 you apply a straight-down tool orientation to each one with a `down_pose` helper before sending it to the motion service. - -The staging pose is not part of this grid. It stays a single fixed pose for the whole pack sequence: you hand-feed one cube to that same spot at the start of every cycle, and the arm always picks from there. - ## Save your anchors Write down the two poses you just read, staging and pallet origin, each as the x, y, and z from the arm's test card. Keep this note handy: in Phase 4 you paste these numbers into the companion project's `helpers.py`, into the `STAGING_POSE` and `PALLET_ORIGIN` constants that `palletizer.py` reads. From there, `palletizer.py` passes `PALLET_ORIGIN` into `helpers.grid` to get all eight target poses, and uses `STAGING_POSE` as the fixed pick location for every cycle. diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md index 24aaa70764..4a2c6b3265 100644 --- a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md +++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md @@ -41,10 +41,60 @@ Start from [helpers.py](https://github.com/viam-devrel/mini-palletizer/blob/main - `helpers.connect()`, an `async` function that returns a connected `RobotClient`. - The arm's resource name (`helpers.ARM`), which you hand to the motion service, plus the gripper and motion-service names (`helpers.GRIPPER`, `helpers.MOTION`), which you pass to `from_robot`. All three name resources configured in Phase 2. - `down_pose(x, y, z)`, which returns a `Pose` at that position with the tool pointing straight down. -- `helpers.grid(origin, pitch, cube)`, the same function from Phase 3 that expands one origin corner into the eight target poses of a two-layer, four-cell pallet. +- `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_POSE` and `helpers.PALLET_ORIGIN`, the two anchor poses you captured by hand in Phase 3. -You do not rewrite any of this. `palletizer.py` imports these names and composes them into motion calls. +`palletizer.py` imports these names and composes them into motion calls. + +## The pallet grid + +You captured one pallet corner in Phase 3. The other seven target poses follow from two constants: the center-to-center spacing between cells, and the cube's size, which sets the gap between the two stacked layers. + +```python +PITCH = 30 # mm, center-to-center spacing between adjacent pallet cells +CUBE = 20 # mm, cube side length, and the z offset between layers +``` + +The four bottom-layer cells are the origin corner plus every combination of `0` and `PITCH` in x and y. The top layer repeats those four positions one `CUBE` higher in z, giving eight target poses in all: four on the pallet and four stacked directly on top. + + + +```text + top layer (cells 4-7), one CUBE higher in z + +-----+-----+ + | 4 | 5 | + +-----+-----+ + | 6 | 7 | + +-----+-----+ + stacked directly above + bottom layer (cells 0-3), resting on the pallet + +-----+-----+ + | 0 | 1 | + +-----+-----+ + | 2 | 3 | + +-----+-----+ + ^ + origin corner (cell 0) = the pallet pose you captured +``` + +`helpers.grid` builds that list of eight poses for you from the origin corner you captured: + +```python +def grid(origin, pitch, cube): + """Return the eight target poses for a two-layer, four-cell pallet, + given the bottom-layer origin corner (cell [0, 0]).""" + bottom = [ + Pose(x=origin.x + dx, y=origin.y + dy, z=origin.z) + for dx in (0, pitch) + for dy in (0, pitch) + ] + top = [Pose(x=p.x, y=p.y, z=p.z + cube) for p in bottom] + return bottom + top +``` + +These are positions only. You apply a straight-down tool orientation to each one with the `down_pose` helper before sending it to the motion service, which the `place` method does below. + +The staging pose is not part of this grid. It stays a single fixed pose for the whole routine: you hand-feed one cube to that same spot at the start of every cycle, and the arm always picks from there. ## Build palletizer.py @@ -79,7 +129,7 @@ class Palletizer: self.placed = [] ``` -`PITCH` and `CUBE` are the same constants Phase 3 used to derive the grid. `APPROACH` and `GRASP_DEPTH` are new: `APPROACH` is how high above a target pose the arm hovers before descending, and `GRASP_DEPTH` is how far past a cube's top surface the gripper descends so its fingers close around the cube rather than skim its top. `self.placed` tracks which grid cells already hold a cube. +`PITCH` and `CUBE` are the constants from the pallet grid above. `APPROACH` and `GRASP_DEPTH` are new: `APPROACH` is how high above a target pose the arm hovers before descending, and `GRASP_DEPTH` is how far past a cube's top surface the gripper descends so its fingers close around the cube rather than skim its top. `self.placed` tracks which grid cells already hold a cube. There is nothing to run yet. This class only sets up handles; the next method makes the first move. diff --git a/docs/tutorials/so-arm101-palletizing/_index.md b/docs/tutorials/so-arm101-palletizing/_index.md index 1ccbd81a88..d567fd07a7 100644 --- a/docs/tutorials/so-arm101-palletizing/_index.md +++ b/docs/tutorials/so-arm101-palletizing/_index.md @@ -24,11 +24,7 @@ draft: true Warehouse robots spend all day stacking boxes onto pallets. In this workshop you build the same thing in miniature, on your desk: an affordable SO-ARM101 arm that picks up cubes and stacks them neatly, two layers at a time. -It is a hands-on introduction to robot manipulation with Viam, for developers and makers who would rather program a real arm than a simulator. You do not need industrial hardware or prior robotics experience, just a desktop arm you can build yourself. You configure it, teach it where the cubes and the pallet are by guiding it with your own hands, then write the Python that runs the whole packing routine. By the end, the arm packs a full stack of cubes on its own, driven entirely by code you wrote. - -## What you'll build - -You configure an SO-ARM101 arm and its finger gripper, teach it a small set of anchor poses by hand using its freedrive (torque-off) capability, then write a Python program that reads those poses back and plans a collision-free pick-and-stack. The finished cell packs a two-by-two-by-two stack of eight cubes onto a marked pallet, one cube at a time. +It is a hands-on introduction to robot manipulation with Viam, for developers and makers who would rather program a real arm than a simulator. You do not need industrial hardware or prior robotics experience, just a desktop arm you can build yourself. You configure it, teach it where the cubes and the pallet are by guiding it with your own hands, then write the Python that runs the whole packing routine. By the end, the arm packs a full two-by-two-by-two stack of cubes on its own, driven entirely by code you wrote. @@ -36,7 +32,7 @@ You configure an SO-ARM101 arm and its finger gripper, teach it a small set of a - **An SO-ARM101 arm** with its finger gripper, connected to your computer over USB. - **Eight cubes and a pallet mat.** You need eight cubes about 20 mm on a side, a two-by-two pallet grid to stack them on, and a staging spot to feed cubes from. The companion project includes a printable template: paper cube nets you fold into 20 mm cubes, and a mat that marks the pallet grid and the staging spot at the exact spacing the code expects. Print it, cut it out, and you are ready. Wooden or foam 20 mm cubes work too if you have them. -- **A personal computer** running `viam-agent` and `viam-server`, with the arm plugged into it over USB. +- **A personal computer** running `viam-server`, with the arm plugged into it over USB. ## Phases From e485a140e8c5f5bdb0dfe8368c913692cfff8997 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sun, 12 Jul 2026 14:07:54 -0400 Subject: [PATCH 21/29] docs(tutorials): third round of review feedback - Phase 1: motion service is a builtin service (no config); reframe the frame system ("where is everything and how is it related") and WorldState ("what is in the world": obstacles + transforms), with links to the frame-system and obstacles docs - Phase 3: add mat/staging marker placement (align to arm axes, tape down) - Phase 4: replace the ASCII grid with a rendered isometric diagram --- .../so-arm101-palletizing/grid-iso.png | Bin 0 -> 62102 bytes .../01-platform-mental-model.md | 6 +++--- .../03-teach-the-cell.md | 10 ++++++++++ .../04-pack-from-python.md | 18 +----------------- 4 files changed, 14 insertions(+), 20 deletions(-) create mode 100644 assets/tutorials/so-arm101-palletizing/grid-iso.png diff --git a/assets/tutorials/so-arm101-palletizing/grid-iso.png b/assets/tutorials/so-arm101-palletizing/grid-iso.png new file mode 100644 index 0000000000000000000000000000000000000000..494ff4e2ac9ff6f6ba15b11c73da28b5e4fae29c GIT binary patch literal 62102 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 Open your machine in the Viam app and confirm the green **Live** indicator, so you know `viam-server` is running and reachable before you configure anything. You should also be able to say, in your own words, what the frame system, the motion service, and WorldState each do, since every later phase builds on them. If the machine is not Live, start `viam-server` on your computer before continuing. diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md index fa56c68c09..9755326662 100644 --- a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md +++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md @@ -25,6 +25,16 @@ Disabling torque lets you move the arm by hand, but it also means the arm no lon This cell needs eight target poses, one per cube, and teaching all eight by hand would be slow and error-prone. Instead, you capture just two anchors: the staging spot, where you hand-feed each cube, and the pallet's origin corner, the bottom-layer cell at grid position [0, 0]. Every other pallet position is a fixed offset from that origin corner, so once you know the origin and the grid spacing, you compute the remaining seven poses instead of teaching them individually. +## Place the pallet mat and staging marker + +Before you capture anything, set the printed markers in place. Print the cube-and-pallet template from the [companion project](https://github.com/viam-devrel/mini-palletizer) at 100% scale and cut out the pallet mat and the staging square. (If you are using your own cubes, you still want the mat and staging marker for consistent positions.) + +- Set the **pallet mat** on a flat surface within the arm's reach. Line up the mat's **x** and **y** arrows with the arm's x and y axes, which you can see on the world frame in the **3D scene** tab. The mat's **origin** square is the pallet corner you will teach. +- Set the **staging square** to one side of the pallet, also within reach. This is where you hand-feed each cube. +- **Tape both down.** They must not move while you teach poses or while the arm runs the pack later, or the cubes will miss their marks. Once they are fixed, leave them put for the rest of the workshop. + +With the markers fixed, you teach the arm two spots: the origin square on the mat and the staging square. + ## Disable torque The standard arm API covers moving the arm and reading its position, but hardware often has extra capabilities that do not fit those standard methods. Viam exposes those through **`DoCommand`**, a general-purpose command channel a module can use to accept commands specific to its hardware. The SO-ARM101 module uses it for a `set_torque` command that turns the servos' holding torque on and off. diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md index 4a2c6b3265..0351c97df7 100644 --- a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md +++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md @@ -59,23 +59,7 @@ The four bottom-layer cells are the origin corner plus every combination of `0` -```text - top layer (cells 4-7), one CUBE higher in z - +-----+-----+ - | 4 | 5 | - +-----+-----+ - | 6 | 7 | - +-----+-----+ - stacked directly above - bottom layer (cells 0-3), resting on the pallet - +-----+-----+ - | 0 | 1 | - +-----+-----+ - | 2 | 3 | - +-----+-----+ - ^ - origin corner (cell 0) = the pallet pose you captured -``` +{{}} `helpers.grid` builds that list of eight poses for you from the origin corner you captured: From 00ff12dc389c8291d4d430237a73b18276669db8 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sun, 12 Jul 2026 14:30:24 -0400 Subject: [PATCH 22/29] feat(tutorials): author SO-ARM101 workshop phases 5 and 6 Phase 5 walks through modeling placed cubes and the held cube in WorldState so the motion planner routes around the growing stack, completing the collision-free two-layer pack (milestone two). Phase 6 mirrors the pick-and-place workshop's inline-module pattern, wrapping the pack loop in a generic-service module with dependency injection and a do_command entry point. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EdtYcpEHeA2yvQ2ksBzZHH --- .../05-avoid-placed-cubes.md | 145 +++++++++++++++++- .../so-arm101-palletizing/06-inline-module.md | 122 ++++++++++++++- 2 files changed, 256 insertions(+), 11 deletions(-) diff --git a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md index 7eb142c3e0..5e93d68503 100644 --- a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md +++ b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md @@ -19,20 +19,155 @@ In this phase you teach the motion service about the cubes already on the pallet ## Why the planner collides - +The motion service only avoids what you hand it. Every call to `motion.move` takes a `world_state` argument, and the planner routes around exactly what that `world_state` describes, nothing more. In Phase 4, `move_gripper` always passed `world_state=None`, and that was fine: the bottom layer is flat, so nothing the arm carried or passed over could collide with anything else on the pallet. + +The second layer breaks that assumption. To stack a cube in a top-layer cell, the arm and the cube in its gripper now travel over cubes already sitting in the bottom layer. If you keep passing `world_state=None`, the planner has no idea those cubes exist and plans a path straight through them, because as far as it knows, the space above the pallet is empty. + +This is exactly the WorldState idea from [Phase 1](/tutorials/so-arm101-palletizing/platform-mental-model/#three-robotics-concepts-to-learn): obstacles are the things to avoid, and transforms are the things that move with the arm. This phase builds both kinds of WorldState content from `self.placed`, the list your `place` method already appends to. See [Obstacles and WorldState](/motion-planning/obstacles/) for the full reference. ## Model placed cubes as obstacles - +Every pose in `self.placed` is a cube already sitting on the pallet. Turn each one into a `RectangularPrism` geometry the planner should avoid on the current move. + +Replace the `viam.proto.common` import from Phase 4 with this expanded block; it re-lists `Pose` and `PoseInFrame` so there is no duplicate import line: + +```python +from viam.proto.common import ( + Pose, + PoseInFrame, + WorldState, + GeometriesInFrame, + Geometry, + RectangularPrism, + Vector3, + Transform, +) +``` + +Then add an `obstacles` method to the `Palletizer` class: + +```python + def obstacles(self, held=False): + """Build the WorldState for this move: placed cubes as obstacles, and + the carried cube as a transform that rides the gripper.""" + placed = [ + GeometriesInFrame( + reference_frame="world", + geometries=[ + Geometry( + center=Pose(x=p.x, y=p.y, z=p.z, o_x=0, o_y=0, o_z=1, theta=0), + box=RectangularPrism(dims_mm=Vector3(x=CUBE, y=CUBE, z=CUBE)), + label=f"placed-{i}", + ) + ], + ) + for i, p in enumerate(self.placed) + ] + transforms = [] + if held: + transforms.append( + Transform( + reference_frame="held-cube", + pose_in_observer_frame=PoseInFrame( + reference_frame=helpers.GRIPPER, + pose=Pose(x=0, y=0, z=CUBE / 2, o_x=0, o_y=0, o_z=1, theta=0), + ), + physical_object=Geometry( + center=Pose(x=0, y=0, z=0), + box=RectangularPrism(dims_mm=Vector3(x=CUBE, y=CUBE, z=CUBE)), + label="held-cube", + ), + ) + ) + if not placed and not transforms: + return None + return WorldState(obstacles=placed, transforms=transforms) +``` + +Each placed cube becomes a cube-shaped box, `CUBE` millimeters on every side, in the `world` frame. Pay attention to `center`: a `Geometry`'s `center` pose is the middle of the box, not its base. `self.placed` stores the tool pose you released the cube at, the arm's end-point pose from Phase 3, so each box is centered on that same x, y, z. On real hardware, the true cube center sits a little off that point, roughly half the cube's height up; this phase keeps the math simple and treats it as close enough, but if the arm clips a placed cube's top edge, that is the first number to tune. - +These obstacles exist only for the duration of one move. They are not something you add to the machine's static configuration, because they change every cycle as `self.placed` grows; a config obstacle is fixed, but a `world_state` argument is built fresh on every call. That is why `obstacles` is a method that reads `self.placed` live rather than a value computed once. + +Update `move_gripper` to accept a `world_state` and forward it, replacing the hardcoded `None` from Phase 4: + +```python + async def move_gripper(self, pose: Pose, world_state=None): + destination = PoseInFrame(reference_frame="world", pose=pose) + await self.motion.move( + component_name=helpers.ARM, + destination=destination, + world_state=world_state, + ) +``` + +`move_gripper` still defaults to no obstacles, so any call that does not pass a `world_state` behaves exactly as it did in Phase 4. The next two sections update `pick`, `place`, and `pack` to actually pass one. ## Model the held cube - +`obstacles` also models the cube currently in the gripper, when you call it with `held=True`. Between a `pick` and the matching `place`, the cube in the gripper is not on the pallet, it moves wherever the arm moves. Modeling it as an obstacle in the `world` frame would be wrong: obstacles given in a component frame are resolved to world coordinates once, at the start of planning, and then stay fixed, so a "held" obstacle would not ride the gripper as the arm moves. + +The carried cube belongs in `WorldState.transforms` instead, as a `Transform` whose parent frame is the gripper. A `Transform` adds a new frame to the planner's world for the duration of the move: `pose_in_observer_frame` names the parent frame (`helpers.GRIPPER`) and the pose of the new `held-cube` frame relative to it, and `physical_object` gives that frame a cube-shaped geometry for collision checking. Because the parent frame is the gripper, the new frame moves with the arm, so the planner tracks the carried cube through the whole motion instead of freezing it in place. This is the transform half of WorldState from Phase 1, and it is the same runtime attach pattern described in [Attach and detach geometries](/motion-planning/obstacles/attach-detach-geometries/). Placed cubes stay in `obstacles`; only the carried cube goes in `transforms`. + +The offset `(0, 0, CUBE / 2)` in `pose_in_observer_frame` approximates the cube's center sitting just past the gripper's fingertips; like the placed-cube pose above, it is a reasonable starting guess, not a measured value, and you may need to nudge it on hardware if the carried cube's model does not line up with where it actually rides. + +{{< alert title="If the first carry move fails on a collision" color="note" >}} +Because the held-cube geometry sits right at the gripper (`z=CUBE / 2`), the first move that passes `held=True` can fail with a "start state in collision" error: the cube geometry overlaps the gripper's own fingers at the start pose. That specific pair of shapes is allowed to touch, so you tell the planner to ignore it with a collision specification that names the gripper and the `held-cube` frame. See [Attach and detach geometries](/motion-planning/obstacles/attach-detach-geometries/) for that pattern; this draft leaves it out to keep the code short. +{{< /alert >}} + +Passing `held=True` folds the carried cube into the same `WorldState` as the placed cubes, so a single `world_state` argument covers both: the obstacles already on the pallet, and the transform the gripper is carrying right now. ## Run the full two-layer pack - +With `obstacles` in place, update `pick`, `place`, and `pack` to pass it, and extend the loop from four cubes to all eight: + +```python + 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, self.obstacles()) + await self.move_gripper(grasp, self.obstacles()) + await self.gripper.grab() + await self.move_gripper(hover, self.obstacles(held=True)) + + async def place(self, seq: int): + """Place the held cube into 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, self.obstacles(held=True)) + await self.move_gripper(down_pose(target.x, target.y, target.z), self.obstacles()) + await self.gripper.open() + await self.move_gripper(hover, self.obstacles()) + self.placed.append(target) + + async def pack(self): + """Pack both layers: eight cubes, cells 0 through 7.""" + for seq in range(8): + 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") +``` + +Look at where `held=True` shows up and where it does not. The last hover in `pick` and the first hover in `place` both carry the cube across open space toward or away from the pallet, so both pass `self.obstacles(held=True)`: the planner needs to know about the cube riding in the gripper while it is in transit. The final descent into an empty cell in `place`, and the lift straight back up afterward, drop the `held` flag and use `self.obstacles()` alone. By that point the cube is either about to be released or already released, so modeling it as still held would have the planner route around a cube that, from the planner's point of view, is about to overlap the cell it is being placed into. `self.placed.append(target)` still runs after the release, so the next cycle's `obstacles()` call sees this cube too. + +Run the full pack: + +```sh +uv run palletizer.py pack +``` + +Hand-feed a cube to the staging spot for each of the eight prompts, the same rhythm as Phase 4. Keep the **3D scene** tab open while it runs; each time `place` appends to `self.placed`, the next move's obstacles include one more cube, and you can watch the set of avoided geometries grow cell by cell as the pallet fills. + + + +{{< checkpoint >}} +After eight cycles, `pack` prints `packed 8 cubes` and both layers of the pallet are full, four cubes on the bottom and four stacked directly above them, with no collisions along the way. If the arm clips a placed cube, first confirm every call to `move_gripper` in `pick` and `place` passes a `world_state`, none should fall back to the `None` default; then confirm `self.placed.append(target)` runs after each successful `place`, so later cycles actually see the cubes placed before them. +{{< /checkpoint >}} + +## Milestone two + +You now drive a full, collision-free two-layer pack: eight cubes, planned around each other automatically because you model placed cubes as obstacles and the held cube as a transform on every move. That is the complete robotics result this workshop set out to teach. Phase 6 is optional: it takes this same pack loop off your laptop and runs it as a module on the machine itself. {{< workshop-nav >}} diff --git a/docs/tutorials/so-arm101-palletizing/06-inline-module.md b/docs/tutorials/so-arm101-palletizing/06-inline-module.md index 1a026f557d..b67449a7e5 100644 --- a/docs/tutorials/so-arm101-palletizing/06-inline-module.md +++ b/docs/tutorials/so-arm101-palletizing/06-inline-module.md @@ -17,18 +17,128 @@ draft: true This phase is optional. It takes the working pack loop from Phase 5 off your laptop and onto the machine, where it runs as a module and can be triggered on demand instead of from a script. -## From script to module +## Why use a module - +A [module](/build-modules/) packages a resource so `viam-server` runs it directly, the same way the `so101` module you configured in Phase 2 runs the arm and gripper. Packaging your pack loop as a module puts your control code on that same footing. - +Reach for a module when one of these is true for your setup: -## Scaffold the inline module +- The pack has to keep running after you close your laptop. +- The pack has to restart on its own after a crash or a machine reboot. +- You want to deploy updates without pushing code from a laptop by hand. +- You want the pack to run on a schedule instead of a manual trigger. - +If none of those apply, stop here. Phase 5 already gave you the complete robotics result this workshop set out to teach. + +This phase builds an **inline module**: the Viam app hosts the code and builds it for you in the cloud, with the source in an editor in your browser. A conventional module lives in its own Git repository that you build and upload yourself, and it is the better choice for code you share across many machines or maintain as a team. Inline is the faster path for a single machine's control code, so it is what you use here. + +## What changes when you package it + +The pick, place, and pack logic from Phase 5 is unchanged, moved into a module's lifecycle methods with no change to what it does. Only the wiring around that logic changes, in two places: + +- **How the module gets its resources.** Your script called `Gripper.from_robot`, `MotionClient.from_robot`, and read `helpers.ARM` after it connected. A module does not connect to itself, so it cannot call `from_robot` the same way; instead it receives its resources through **dependency injection**. +- **How the logic gets triggered.** Your script ran top to bottom under `__main__`, driven by the `STEPS` dictionary and a command-line verb. A module runs its logic behind a **`do_command`** entry point that something else calls, such as the CONTROL tab's test card. + +The next three sections cover creating the module, then those two changes in turn. + +## Create a control code module + +On the **CONFIGURE** tab, click the **+** icon and select **Code**. Choose to create a Viam-hosted module with an inline editor, proceed past the information about configuring components, and select Python as the language. The Viam app creates a new configured resource with an embedded code editor in your browser, seeded with a generated module skeleton. + + + +Control code like this is typically modeled as a **generic service**: a resource with no specialized API of its own, so you can put arbitrary logic behind it. The skeleton builds that service on the `EasyResource` mixin, a convenience base class that fills in the boilerplate every resource needs and lets you override only the parts you care about. Those parts are **lifecycle methods**: functions the module framework calls at set points, such as `validate_config` when the config is checked, `new` when the resource starts, and `close` when it shuts down. One more method, `do_command`, is the generic service's entry point: because the service has no typed API, `do_command` is how a caller runs the actions it exposes. + +Over the next two sections you move the pack loop into this skeleton: the typed resource handles you built with `from_robot` in Phase 4 become dependency injection through `new`, and `pick`, `place`, and `pack` gather behind the `do_command` entry point. + +## Dependency injection + +A script builds its resource handles once, right after it connects: `Gripper.from_robot(robot, helpers.GRIPPER)`, `MotionClient.from_robot(robot, helpers.MOTION)`, and `helpers.ARM` for the arm's name. A module cannot do that, so the module framework hands your module its dependencies instead, through two lifecycle methods: + +- `validate_config` runs before your module starts and declares which resources it depends on, so `viam-server` knows to hold your module back until those resources are online. It reads the module's own config attributes, where each attribute value is the name of a resource on the machine, and returns those names as required dependencies. +- `new` receives the resolved dependencies as a mapping keyed by resource name, and this is where you build the typed handles `pick`, `place`, and `pack` call. It reads the same config attributes to look each dependency up by its configured name. + +The two anchor poses you taught by hand in Phase 3, staging and pallet origin, come in the same way: instead of reading `helpers.STAGING_POSE` and `helpers.PALLET_ORIGIN` from a Python file, the module reads them as config attributes. + +A short illustrative sketch of both methods: + +```python +from viam.components.arm import Arm +from viam.components.gripper import Gripper +from viam.services.motion import Motion +from viam.utils import struct_to_dict + + +@classmethod +def validate_config(cls, config): + attrs = struct_to_dict(config.attributes) + required_deps = [] + for key in ("arm", "gripper"): + if key not in attrs or not attrs[key]: + raise ValueError(f"attribute '{key}' (non-empty string) is required") + required_deps.append(attrs[key]) + required_deps.append("builtin") # motion service + return required_deps, [] + + +@classmethod +def new(cls, config, dependencies): + self = super().new(config, dependencies) + attrs = struct_to_dict(config.attributes) + self.arm = dependencies[Arm.get_resource_name(attrs["arm"])] + self.gripper = dependencies[Gripper.get_resource_name(attrs["gripper"])] + self.motion = dependencies[Motion.get_resource_name("builtin")] + self.staging_pose = attrs["staging_pose"] + self.pallet_origin = attrs["pallet_origin"] + self.placed = [] + return self +``` + +This sketch is illustrative, not a complete file: fill in the pose attributes with however you choose to encode `x`, `y`, and `z` in the config (a small object with three numeric fields works well), and carry over `PITCH`, `CUBE`, `APPROACH`, and `GRASP_DEPTH` as module-level constants exactly as they were in `palletizer.py`. + +## Trigger the module with do_command + +With dependencies wired up, assemble the pack loop into a `run_pack` method on the module, using `self.arm`, `self.gripper`, and `self.motion` in place of the resource handles a script built with `from_robot`. The pick, place, and obstacle logic from Phase 5 carries over unchanged; only the resource handles and the pose source are different. + +The Phase 5 `pack` loop paused on `input()` before each cycle so you could hand-feed a cube to the staging spot. A module runs on the machine with no attached terminal, so it cannot call `input()`. Drop the pause and let `run_pack` loop straight through all eight cells; unattended operation assumes something supplies cubes to the staging spot on its own, for example a feeder, which is out of scope for this workshop. + +```python + async def run_pack(self): + """Pack both layers: eight cubes, cells 0 through 7. No hand-feed pause.""" + for seq in range(8): + await self.pick() + await self.place(seq) + return len(self.placed) +``` + +One robustness note worth calling out: `self.gripper.grab()` returns a boolean, and on grippers that sense their grip it reports whether the jaws actually closed on something. The SO-101's finger gripper does not sense grip, so its `grab()` returns `True` on command completion whether or not a cube is there. If your gripper does report holding state, checking that return value before lifting lets a module catch a missed grasp instead of carrying an empty gripper through the place step; treat that hold-check as an enhancement to add when your hardware supports it, rather than something the SO-101 provides on its own. + +You trigger the module through `do_command`. Because a generic service has no typed API of its own, `do_command` is its entry point: the method you dispatch on to run the actions your service exposes. A small illustrative sketch: + +```python +async def do_command(self, command, *, timeout=None, **kwargs): + if command.get("action") == "pack": + count = await self.run_pack() + return {"packed": count} + return {} +``` + +{{< alert title="Module build feedback loop" color="note" >}} +Saving an inline Python module triggers a cloud build that takes about a minute. Give it that minute rather than assuming the save failed. +{{< /alert >}} ## Reload and run on the machine - +Save the module. The Viam app packages it and deploys it to the machine, and the **LOGS** tab shows the build progress the same way it showed the `so101` module downloading back in Phase 2. + +{{< checkpoint >}} +The module finishes its cloud build and starts without errors in the **LOGS** tab, and its resource shows online on the **CONFIGURE** tab. If the build fails, read the build log for the specific error; a missing import or a syntax error carried over from `palletizer.py` is the most common cause. +{{< /checkpoint >}} + +From the **CONTROL** tab, find your module's test card and send `{"action": "pack"}` to run a full eight-cube pack on the machine, no laptop script required. To compare your module against a finished one, read the complete [`palletizer.py` reference](https://github.com/viam-devrel/mini-palletizer/blob/main/reference/palletizer.py) in the companion repo. + +{{< checkpoint >}} +Sending `{"action": "pack"}` from the test card runs the eight-cell pack loop on the machine, the same collision-free motion you drove from your laptop in Phase 5, now with no laptop in the loop and no hand-feed pause. If the command errors out partway through, check the LOGS tab for the raised exception, most likely a pose attribute that does not match what you taught in Phase 3, or the start-state collision from the held-cube geometry if you carried that over without a collision specification. +{{< /checkpoint >}} {{< workshop-nav >}} From 14ea7f4e19caae8c8c739eb0178a5a56e75a950d Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sun, 12 Jul 2026 14:46:10 -0400 Subject: [PATCH 23/29] docs(tutorials): fix wrap-up claims and standardize frame-system link End-to-end review fixes: drop viam-agent and the calibration sensor from the wrap-up recap (neither is part of this workshop), remove the untaught 5-DOF-orientation claim, and point Phase 1's frame-system link at the same overview page Phase 2 uses. --- .../so-arm101-palletizing/01-platform-mental-model.md | 2 +- docs/tutorials/so-arm101-palletizing/07-wrap-up.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md index aa89e66156..e595af2346 100644 --- a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md +++ b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md @@ -63,7 +63,7 @@ Everything a Viam machine does is modeled as a resource, and resources split int Three concepts carry the whole workshop. This section only previews them; you will work with each one directly in a later phase. -The **frame system** answers "where is everything around the robot, and how is it related?" It tracks the position of every component and object in the cell, and the parent-child relationships between them, all traced back to the world origin. You teach the two key positions to the arm by hand in Phase 3. See [Frame system](/motion-planning/frame-system/) for the full picture. +The **frame system** answers "where is everything around the robot, and how is it related?" It tracks the position of every component and object in the cell, and the parent-child relationships between them, all traced back to the world origin. You teach the two key positions to the arm by hand in Phase 3. See [Frame system](/motion-planning/frame-system/overview/) for the full picture. **Motion planning** answers "how does the arm get there?" Given a target pose, the motion service works out a path of joint movements that reaches it without colliding with anything. diff --git a/docs/tutorials/so-arm101-palletizing/07-wrap-up.md b/docs/tutorials/so-arm101-palletizing/07-wrap-up.md index 47f7445c5f..f7747c07ab 100644 --- a/docs/tutorials/so-arm101-palletizing/07-wrap-up.md +++ b/docs/tutorials/so-arm101-palletizing/07-wrap-up.md @@ -25,9 +25,9 @@ If you completed the optional Phase 6, you also packaged that same pack loop as This workshop was small on purpose, but it touched most of the moving parts you will use on any Viam machine: -- **Configuration and runtime:** the Viam app as the single source of truth (the CONFIGURE tab and its JSON view), `viam-server` running your resources while `viam-agent` keeps it alive, and the module system, including a discovery service that suggested the arm and gripper configuration for you. -- **Resources:** the arm and gripper components from the SO-ARM101 module, with the gripper attached to the arm through the frame system, plus the calibration sensor that made the arm's joint positions accurate. -- **Frame system and motion:** placing the arm at the world origin so hand-taught poses are world poses, teaching real-world anchor poses by back-driving the arm with torque disabled, and letting the motion service plan to the arm's end point. You also learned how a five degree-of-freedom (5-DOF) arm plans to a position without pinning orientation, and how a WorldState of placed and held cubes keeps the planner from routing through the stack. +- **Configuration and runtime:** the Viam app as the single source of truth (the CONFIGURE tab and its JSON view), `viam-server` running your resources, and the module system, including a discovery service that suggested the arm and gripper configuration for you. +- **Resources:** the arm and gripper components from the SO-ARM101 module, with the gripper attached to the arm through the frame system. +- **Frame system and motion:** placing the arm at the world origin so hand-taught poses are world poses, teaching real-world anchor poses by back-driving the arm with torque disabled, and letting the motion service plan to the arm's end point. You also saw how a WorldState of placed cubes and the held cube keeps the planner from routing through the stack. - **Code:** the Python SDK (`RobotClient`, the typed `Gripper` and `MotionClient`, and `motion.move`), built up one method at a time into `palletizer.py`, plus packaging that same script as an inline module. ## Where to go next From a2f382a2ba61b2d22a239a6db43d20c5b9c6bc64 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sun, 12 Jul 2026 15:51:00 -0400 Subject: [PATCH 24/29] docs(tutorials): apply phase 5 and 6 review feedback Phase 5: positive reframe ("What the planner already knows") clarifying the default WorldState is configured components with frames; introduce geometries before RectangularPrism; split the obstacles method build-up into an obstacles section and a transforms section; move the cube-center and held-cube tuning caveats into aside callouts; reword the config- obstacle line with a table example. Phase 6: reword the dependency- injection lead-in and rename the run section. Reduce "real hardware". --- .../04-pack-from-python.md | 2 +- .../05-avoid-placed-cubes.md | 107 ++++++++++++------ .../so-arm101-palletizing/06-inline-module.md | 4 +- 3 files changed, 74 insertions(+), 39 deletions(-) diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md index 0351c97df7..5b4a50ebb9 100644 --- a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md +++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md @@ -15,7 +15,7 @@ languages: ["python"] draft: true --- -In this phase you write `palletizer.py`, a Python script that reads back the two anchor poses from Phase 3 and drives the arm through a packing routine for each of the four bottom-layer cells. Getting through this phase is milestone one: you drive the arm through a static pack from your own code, on real hardware. +In this phase you write `palletizer.py`, a Python script that reads back the two anchor poses from Phase 3 and drives the arm through a packing routine for each of the four bottom-layer cells. Getting through this phase is milestone one: you drive the arm through a static pack from your own code. ## Set up the companion project diff --git a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md index 5e93d68503..ccac0edf98 100644 --- a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md +++ b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md @@ -17,19 +17,19 @@ draft: true In this phase you teach the motion service about the cubes already on the pallet and the cube in the gripper, so it plans around them instead of through them. Finishing this phase is milestone two: a full, collision-free two-layer pack. -## Why the planner collides +## What the planner already knows -The motion service only avoids what you hand it. Every call to `motion.move` takes a `world_state` argument, and the planner routes around exactly what that `world_state` describes, nothing more. In Phase 4, `move_gripper` always passed `world_state=None`, and that was fine: the bottom layer is flat, so nothing the arm carried or passed over could collide with anything else on the pallet. +The motion service already knows about every component you configured with a frame. The arm and the gripper both went into the frame system in Phase 2, so the planner accounts for their shapes on every move without you doing anything. What it does not know about is anything you did not configure, like a cube sitting on the pallet. You add those per move, through the `world_state` argument to `motion.move`: the planner routes around exactly what that WorldState describes, and nothing more. -The second layer breaks that assumption. To stack a cube in a top-layer cell, the arm and the cube in its gripper now travel over cubes already sitting in the bottom layer. If you keep passing `world_state=None`, the planner has no idea those cubes exist and plans a path straight through them, because as far as it knows, the space above the pallet is empty. +In Phase 4, `move_gripper` always passed `world_state=None`, and that was fine. The bottom layer is flat, so nothing the arm carried or passed over could collide with anything else on the pallet. The second layer changes that. To stack a cube in a top-layer cell, the arm and the cube in its gripper travel over cubes already sitting in the bottom layer. With `world_state=None`, the planner has no idea those cubes exist and plans a path straight through them. -This is exactly the WorldState idea from [Phase 1](/tutorials/so-arm101-palletizing/platform-mental-model/#three-robotics-concepts-to-learn): obstacles are the things to avoid, and transforms are the things that move with the arm. This phase builds both kinds of WorldState content from `self.placed`, the list your `place` method already appends to. See [Obstacles and WorldState](/motion-planning/obstacles/) for the full reference. +This is the WorldState idea from [Phase 1](/tutorials/so-arm101-palletizing/platform-mental-model/#three-robotics-concepts-to-learn): obstacles are the things to avoid, and transforms are the things that move with the arm. This phase builds both from `self.placed`, the list your `place` method already appends to. See [Obstacles and WorldState](/motion-planning/obstacles/) for the full reference. -## Model placed cubes as obstacles +## Keeping track of obstacles -Every pose in `self.placed` is a cube already sitting on the pallet. Turn each one into a `RectangularPrism` geometry the planner should avoid on the current move. +The planner reasons about collisions using **geometries**: simple shapes, a box, a sphere, or a capsule, that stand in for the volume a real object takes up. To keep the arm clear of a cube on the pallet, you describe that cube as a box geometry so the planner knows the space is occupied. -Replace the `viam.proto.common` import from Phase 4 with this expanded block; it re-lists `Pose` and `PoseInFrame` so there is no duplicate import line: +Every pose in `self.placed` is a cube already on the pallet. Turn each one into a box geometry, a `RectangularPrism`, that the planner should avoid on the current move. Replace the `viam.proto.common` import from Phase 4 with this expanded block: ```python from viam.proto.common import ( @@ -40,12 +40,71 @@ from viam.proto.common import ( Geometry, RectangularPrism, Vector3, - Transform, ) ``` Then add an `obstacles` method to the `Palletizer` class: +```python + def obstacles(self): + """Build the WorldState of placed cubes for the planner to avoid.""" + placed = [ + GeometriesInFrame( + reference_frame="world", + geometries=[ + Geometry( + center=Pose(x=p.x, y=p.y, z=p.z, o_x=0, o_y=0, o_z=1, theta=0), + box=RectangularPrism(dims_mm=Vector3(x=CUBE, y=CUBE, z=CUBE)), + label=f"placed-{i}", + ) + ], + ) + for i, p in enumerate(self.placed) + ] + return WorldState(obstacles=placed) if placed else None +``` + +Each placed cube becomes a box, `CUBE` millimeters on every side, in the `world` frame. A `Geometry`'s `center` is the middle of the box, and `self.placed` stores the tool pose you released each cube at, so each box is centered on that same x, y, z. + +These obstacles exist only for the duration of one move. They are not something you add to the machine's static configuration, because they change every cycle as `self.placed` grows. An obstacle in your machine configuration is fixed: you would configure something like the table the arm sits on that way, once, because it never moves. The cubes are different, so `obstacles` is a method that reads `self.placed` live and builds a fresh `world_state` on every call. + +Update `move_gripper` to accept and forward a `world_state`, replacing the hardcoded `None` from Phase 4: + +```python + async def move_gripper(self, pose: Pose, world_state=None): + destination = PoseInFrame(reference_frame="world", pose=pose) + await self.motion.move( + component_name=helpers.ARM, + destination=destination, + world_state=world_state, + ) +``` + +`move_gripper` still defaults to no obstacles, so any call that does not pass a `world_state` behaves exactly as it did before. + +{{< alert title="Approximate cube centers" color="note" >}} +Each placed-cube box is centered on the pose you released the cube at, which is the arm's end point, not the exact cube center. The two are close, within about half a cube height. If the arm clips the top edge of a placed cube, this offset is the first number to tune. +{{< /alert >}} + +## Model the held cube + +The `obstacles` method covers the cubes on the pallet, but not the cube in the gripper. Between a `pick` and the matching `place`, the carried cube is not on the pallet, it moves wherever the arm moves. Modeling it as a fixed `world` obstacle would be wrong: obstacles are resolved to world coordinates once, at the start of planning, and then stay put. + +The carried cube belongs in `WorldState.transforms` instead. A `Transform` adds a new frame to the planner's world for the duration of a move, and because you parent that frame to the gripper, it moves with the arm. Add `Transform` to the import block, then extend `obstacles` to take a `held` flag: + +```python +from viam.proto.common import ( + Pose, + PoseInFrame, + WorldState, + GeometriesInFrame, + Geometry, + RectangularPrism, + Vector3, + Transform, +) +``` + ```python def obstacles(self, held=False): """Build the WorldState for this move: placed cubes as obstacles, and @@ -84,38 +143,14 @@ Then add an `obstacles` method to the `Palletizer` class: return WorldState(obstacles=placed, transforms=transforms) ``` -Each placed cube becomes a cube-shaped box, `CUBE` millimeters on every side, in the `world` frame. Pay attention to `center`: a `Geometry`'s `center` pose is the middle of the box, not its base. `self.placed` stores the tool pose you released the cube at, the arm's end-point pose from Phase 3, so each box is centered on that same x, y, z. On real hardware, the true cube center sits a little off that point, roughly half the cube's height up; this phase keeps the math simple and treats it as close enough, but if the arm clips a placed cube's top edge, that is the first number to tune. - -These obstacles exist only for the duration of one move. They are not something you add to the machine's static configuration, because they change every cycle as `self.placed` grows; a config obstacle is fixed, but a `world_state` argument is built fresh on every call. That is why `obstacles` is a method that reads `self.placed` live rather than a value computed once. - -Update `move_gripper` to accept a `world_state` and forward it, replacing the hardcoded `None` from Phase 4: - -```python - async def move_gripper(self, pose: Pose, world_state=None): - destination = PoseInFrame(reference_frame="world", pose=pose) - await self.motion.move( - component_name=helpers.ARM, - destination=destination, - world_state=world_state, - ) -``` - -`move_gripper` still defaults to no obstacles, so any call that does not pass a `world_state` behaves exactly as it did in Phase 4. The next two sections update `pick`, `place`, and `pack` to actually pass one. - -## Model the held cube - -`obstacles` also models the cube currently in the gripper, when you call it with `held=True`. Between a `pick` and the matching `place`, the cube in the gripper is not on the pallet, it moves wherever the arm moves. Modeling it as an obstacle in the `world` frame would be wrong: obstacles given in a component frame are resolved to world coordinates once, at the start of planning, and then stay fixed, so a "held" obstacle would not ride the gripper as the arm moves. +In the `Transform`, `pose_in_observer_frame` names the parent frame (`helpers.GRIPPER`) and the pose of the new `held-cube` frame relative to it, and `physical_object` gives that frame a cube-shaped geometry for collision checking. Because the parent is the gripper, the new frame rides the arm, so the planner tracks the carried cube through the whole motion instead of freezing it in place. This is the transform half of WorldState from Phase 1, the same runtime attach pattern described in [Attach and detach geometries](/motion-planning/obstacles/attach-detach-geometries/). Placed cubes stay in `obstacles`; only the carried cube goes in `transforms`. -The carried cube belongs in `WorldState.transforms` instead, as a `Transform` whose parent frame is the gripper. A `Transform` adds a new frame to the planner's world for the duration of the move: `pose_in_observer_frame` names the parent frame (`helpers.GRIPPER`) and the pose of the new `held-cube` frame relative to it, and `physical_object` gives that frame a cube-shaped geometry for collision checking. Because the parent frame is the gripper, the new frame moves with the arm, so the planner tracks the carried cube through the whole motion instead of freezing it in place. This is the transform half of WorldState from Phase 1, and it is the same runtime attach pattern described in [Attach and detach geometries](/motion-planning/obstacles/attach-detach-geometries/). Placed cubes stay in `obstacles`; only the carried cube goes in `transforms`. - -The offset `(0, 0, CUBE / 2)` in `pose_in_observer_frame` approximates the cube's center sitting just past the gripper's fingertips; like the placed-cube pose above, it is a reasonable starting guess, not a measured value, and you may need to nudge it on hardware if the carried cube's model does not line up with where it actually rides. +The offset `(0, 0, CUBE / 2)` places the cube just past the gripper's fingertips. Like the placed-cube center, it is a reasonable starting guess you may need to nudge. {{< alert title="If the first carry move fails on a collision" color="note" >}} -Because the held-cube geometry sits right at the gripper (`z=CUBE / 2`), the first move that passes `held=True` can fail with a "start state in collision" error: the cube geometry overlaps the gripper's own fingers at the start pose. That specific pair of shapes is allowed to touch, so you tell the planner to ignore it with a collision specification that names the gripper and the `held-cube` frame. See [Attach and detach geometries](/motion-planning/obstacles/attach-detach-geometries/) for that pattern; this draft leaves it out to keep the code short. +Because the held-cube geometry sits right at the gripper (`z=CUBE / 2`), the first move that passes `held=True` can fail with a "start state in collision" error: the cube geometry overlaps the gripper's own fingers at the start pose. That pair of shapes is allowed to touch, so you tell the planner to ignore it with a collision specification that names the gripper and the `held-cube` frame. See [Attach and detach geometries](/motion-planning/obstacles/attach-detach-geometries/) for that pattern; this draft leaves it out to keep the code short. {{< /alert >}} -Passing `held=True` folds the carried cube into the same `WorldState` as the placed cubes, so a single `world_state` argument covers both: the obstacles already on the pallet, and the transform the gripper is carrying right now. - ## Run the full two-layer pack With `obstacles` in place, update `pick`, `place`, and `pack` to pass it, and extend the loop from four cubes to all eight: @@ -150,7 +185,7 @@ With `obstacles` in place, update `pick`, `place`, and `pack` to pass it, and ex print(f"packed {len(self.placed)} cubes") ``` -Look at where `held=True` shows up and where it does not. The last hover in `pick` and the first hover in `place` both carry the cube across open space toward or away from the pallet, so both pass `self.obstacles(held=True)`: the planner needs to know about the cube riding in the gripper while it is in transit. The final descent into an empty cell in `place`, and the lift straight back up afterward, drop the `held` flag and use `self.obstacles()` alone. By that point the cube is either about to be released or already released, so modeling it as still held would have the planner route around a cube that, from the planner's point of view, is about to overlap the cell it is being placed into. `self.placed.append(target)` still runs after the release, so the next cycle's `obstacles()` call sees this cube too. +Look at where `held=True` shows up and where it does not. The last hover in `pick` and the first hover in `place` both carry the cube across open space toward or away from the pallet, so both pass `self.obstacles(held=True)`: the planner needs to know about the cube riding in the gripper while it is in transit. The final descent into an empty cell in `place`, and the lift straight back up afterward, drop the `held` flag and use `self.obstacles()` alone. By that point the cube is either about to be released or already released, so modeling it as still held would have the planner route around a cube that is about to overlap the cell it is being placed into. `self.placed.append(target)` still runs after the release, so the next cycle's `obstacles()` call sees this cube too. Run the full pack: diff --git a/docs/tutorials/so-arm101-palletizing/06-inline-module.md b/docs/tutorials/so-arm101-palletizing/06-inline-module.md index b67449a7e5..0bd6b8be95 100644 --- a/docs/tutorials/so-arm101-palletizing/06-inline-module.md +++ b/docs/tutorials/so-arm101-palletizing/06-inline-module.md @@ -53,7 +53,7 @@ Over the next two sections you move the pack loop into this skeleton: the typed ## Dependency injection -A script builds its resource handles once, right after it connects: `Gripper.from_robot(robot, helpers.GRIPPER)`, `MotionClient.from_robot(robot, helpers.MOTION)`, and `helpers.ARM` for the arm's name. A module cannot do that, so the module framework hands your module its dependencies instead, through two lifecycle methods: +A script builds its resource handles once, right after it connects: `Gripper.from_robot(robot, helpers.GRIPPER)`, `MotionClient.from_robot(robot, helpers.MOTION)`, and `helpers.ARM` for the arm's name. A module works differently: it relies on `viam-server` to provide its dependencies to its constructor, which it declares through a configuration validation step. Two lifecycle methods carry this: - `validate_config` runs before your module starts and declares which resources it depends on, so `viam-server` knows to hold your module back until those resources are online. It reads the module's own config attributes, where each attribute value is the name of a resource on the machine, and returns those names as required dependencies. - `new` receives the resolved dependencies as a mapping keyed by resource name, and this is where you build the typed handles `pick`, `place`, and `pack` call. It reads the same config attributes to look each dependency up by its configured name. @@ -127,7 +127,7 @@ async def do_command(self, command, *, timeout=None, **kwargs): Saving an inline Python module triggers a cloud build that takes about a minute. Give it that minute rather than assuming the save failed. {{< /alert >}} -## Reload and run on the machine +## Run the pack sequence Save the module. The Viam app packages it and deploys it to the machine, and the **LOGS** tab shows the build progress the same way it showed the `so101` module downloading back in Phase 2. From 67328c8b4215c2d08f9b06ca7c3f7934e4eee699 Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sun, 12 Jul 2026 15:55:53 -0400 Subject: [PATCH 25/29] docs(tutorials): add asset placement comments across all phases Mark screenshot/video/diagram placements for the shot list (rig photos, test-card and 3D-scene UI shots, the teach-by-hand and milestone videos, inline-module build/trigger shots). Full list in plans/. --- .../tutorials/so-arm101-palletizing/02-configure-the-arm.md | 6 ++++++ docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md | 6 ++++++ docs/tutorials/so-arm101-palletizing/04-pack-from-python.md | 4 ++++ .../so-arm101-palletizing/05-avoid-placed-cubes.md | 1 + docs/tutorials/so-arm101-palletizing/06-inline-module.md | 4 ++++ docs/tutorials/so-arm101-palletizing/07-wrap-up.md | 2 ++ 6 files changed, 23 insertions(+) diff --git a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md index 5686bffb83..2e60950200 100644 --- a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md +++ b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md @@ -23,6 +23,8 @@ Rather than typing the arm and gripper configs by hand, start with the SO-ARM101 On the **CONFIGURE** tab, click the **+** icon and select **Blocks**. Search for `so101` and select the `so101/discovery` result. Leave its name as the default and save the config. Saving is the moment `viam-server` downloads the SO-ARM101 module; the arm and gripper models you add later in this phase come from that same module, so the download happens only once. + + Before you open the discovery service's test panel, know what it is looking for: the serial port your SO-ARM101 is connected to over USB. - On Linux, the port shows up as `/dev/ttyUSB0` or `/dev/ttyACM0`. @@ -52,6 +54,8 @@ If you are configuring the arm by hand instead, click the **+** icon, select **B Open the **CONTROL** tab and find the arm's test card. Test cards call the same API your Python code calls later in this workshop; jogging a joint here is a real API call that moves the hardware. Move one joint slider a small amount and press **Execute**, then watch the physical arm turn. + + {{< alert title="The arm is about to move" color="caution" >}} Keep the workspace clear and change one joint a small amount at a time. Large or combined joint moves can drive the arm into the table or itself. {{< /alert >}} @@ -76,6 +80,8 @@ If you are configuring the gripper by hand, click the **+** icon, select **Block Open the gripper's test card on the **CONTROL** tab. Press **Open** and watch the jaw open, then press **Grab** and watch it close. + + {{< checkpoint >}} Pressing **Open** and **Grab** on the gripper's test card opens and closes the physical jaw. If nothing moves, confirm the gripper shows online in the CONFIGURE tab and that its `port` matches the arm's. {{< /checkpoint >}} diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md index 9755326662..985234d19d 100644 --- a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md +++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md @@ -35,6 +35,8 @@ Before you capture anything, set the printed markers in place. Print the cube-an With the markers fixed, you teach the arm two spots: the origin square on the mat and the staging square. + + ## Disable torque The standard arm API covers moving the arm and reading its position, but hardware often has extra capabilities that do not fit those standard methods. Viam exposes those through **`DoCommand`**, a general-purpose command channel a module can use to accept commands specific to its hardware. The SO-ARM101 module uses it for a `set_torque` command that turns the servos' holding torque on and off. @@ -50,6 +52,8 @@ On the arm's test card on the **CONTROL** tab, open the DoCommand box and send: Once the command succeeds, the arm's joints go slack and you can move it by hand. + + ## Read the arm's position from the app The arm's test card on the **CONTROL** tab shows its current **end position**: the x, y, and z of the arm's end point, in millimeters, plus an orientation. As you move the arm by hand with torque disabled, that readout updates to track it. Because you placed the arm's base at the world origin in Phase 2, this end position is also a position in the world frame. @@ -62,6 +66,8 @@ You position the arm so the gripper's jaws sit where you want them, then read th With torque disabled, gently guide the gripper to the staging spot, the place where you will set down one cube at the start of every pick cycle in later phases. Hold the arm steady once it is in position, then read the **end position** off the arm's test card and record the x, y, and z. This is your staging pose. Move the arm slightly and watch the readout change, so you know it is tracking the live position, then guide it back and re-read if needed. + + ## Capture the pallet origin corner Still with torque disabled, guide the gripper to the bottom-layer corner of the pallet, cell [0, 0], the corner you treat as the origin of the pallet grid. Read the **end position** again and record the x, y, and z. This is your pallet origin pose. diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md index 5b4a50ebb9..43621aea6d 100644 --- a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md +++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md @@ -30,6 +30,8 @@ The project ships with a `pyproject.toml`, so `uv run` resolves and installs the Open the machine's **CONNECT** tab in the Viam app, select **Python SDK**, toggle **Include API key**, and copy the machine address and the application programming interface (API) key and key ID pair it shows you. `helpers.py` reads these from constants near the top of the file; paste your own values in before you run anything. + + Open `helpers.py` and set the two constants `STAGING_POSE` and `PALLET_ORIGIN` to the two 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` is provided for you as part of the companion project. You write `palletizer.py` yourself in this phase, starting from an empty file in the same directory. @@ -261,6 +263,8 @@ 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 >}} After four cycles, `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. {{< /checkpoint >}} diff --git a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md index ccac0edf98..881f604a0d 100644 --- a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md +++ b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md @@ -196,6 +196,7 @@ uv run palletizer.py pack Hand-feed a cube to the staging spot for each of the eight prompts, the same rhythm as Phase 4. Keep the **3D scene** tab open while it runs; each time `place` appends to `self.placed`, the next move's obstacles include one more cube, and you can watch the set of avoided geometries grow cell by cell as the pallet fills. + {{< checkpoint >}} After eight cycles, `pack` prints `packed 8 cubes` and both layers of the pallet are full, four cubes on the bottom and four stacked directly above them, with no collisions along the way. If the arm clips a placed cube, first confirm every call to `move_gripper` in `pick` and `place` passes a `world_state`, none should fall back to the `None` default; then confirm `self.placed.append(target)` runs after each successful `place`, so later cycles actually see the cubes placed before them. diff --git a/docs/tutorials/so-arm101-palletizing/06-inline-module.md b/docs/tutorials/so-arm101-palletizing/06-inline-module.md index 0bd6b8be95..d2c96021dc 100644 --- a/docs/tutorials/so-arm101-palletizing/06-inline-module.md +++ b/docs/tutorials/so-arm101-palletizing/06-inline-module.md @@ -131,12 +131,16 @@ Saving an inline Python module triggers a cloud build that takes about a minute. Save the module. The Viam app packages it and deploys it to the machine, and the **LOGS** tab shows the build progress the same way it showed the `so101` module downloading back in Phase 2. + + {{< checkpoint >}} The module finishes its cloud build and starts without errors in the **LOGS** tab, and its resource shows online on the **CONFIGURE** tab. If the build fails, read the build log for the specific error; a missing import or a syntax error carried over from `palletizer.py` is the most common cause. {{< /checkpoint >}} From the **CONTROL** tab, find your module's test card and send `{"action": "pack"}` to run a full eight-cube pack on the machine, no laptop script required. To compare your module against a finished one, read the complete [`palletizer.py` reference](https://github.com/viam-devrel/mini-palletizer/blob/main/reference/palletizer.py) in the companion repo. + + {{< checkpoint >}} Sending `{"action": "pack"}` from the test card runs the eight-cell pack loop on the machine, the same collision-free motion you drove from your laptop in Phase 5, now with no laptop in the loop and no hand-feed pause. If the command errors out partway through, check the LOGS tab for the raised exception, most likely a pose attribute that does not match what you taught in Phase 3, or the start-state collision from the held-cube geometry if you carried that over without a collision specification. {{< /checkpoint >}} diff --git a/docs/tutorials/so-arm101-palletizing/07-wrap-up.md b/docs/tutorials/so-arm101-palletizing/07-wrap-up.md index f7747c07ab..12fd76ed1e 100644 --- a/docs/tutorials/so-arm101-palletizing/07-wrap-up.md +++ b/docs/tutorials/so-arm101-palletizing/07-wrap-up.md @@ -14,6 +14,8 @@ draft: true Congratulations, you have finished the SO-ARM101 palletizing workshop! Starting from an empty machine, you configured a real desktop arm, taught it the cell by hand, and wrote the code that packs cubes onto a pallet. + + ## What you built - **Milestone one.** You drove the arm through a static bottom-layer pack from your own Python script, proving your connection, your configured resources, and the poses you taught by hand all hold up under real code. From 5b122cc4739fe882893e94fdcb08b918bce70a2a Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sun, 12 Jul 2026 15:57:00 -0400 Subject: [PATCH 26/29] docs(tutorials): publish the SO-ARM101 palletizing workshop (remove draft flags) Remove draft: true from the overview and all six phase pages plus the wrap-up so the workshop builds into production and appears in the Netlify preview. The headless _phase-template.md stays draft. --- docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md | 1 - docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md | 1 - docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md | 1 - docs/tutorials/so-arm101-palletizing/04-pack-from-python.md | 1 - docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md | 1 - docs/tutorials/so-arm101-palletizing/06-inline-module.md | 1 - docs/tutorials/so-arm101-palletizing/07-wrap-up.md | 1 - docs/tutorials/so-arm101-palletizing/_index.md | 1 - 8 files changed, 8 deletions(-) diff --git a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md index e595af2346..4ccf760724 100644 --- a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md +++ b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md @@ -11,7 +11,6 @@ phase: 1 phase_total: 6 next: "/tutorials/so-arm101-palletizing/configure-the-arm/" languages: ["python"] -draft: true --- Before you configure anything, this phase gives you a mental model of the cell you are about to build and the platform it runs on. Everything after this leans on the ideas introduced here. diff --git a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md index 2e60950200..4556aa43a4 100644 --- a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md +++ b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md @@ -12,7 +12,6 @@ phase_total: 6 prev: "/tutorials/so-arm101-palletizing/platform-mental-model/" next: "/tutorials/so-arm101-palletizing/teach-the-cell/" languages: ["python"] -draft: true --- In this phase you configure the arm and gripper, verify them with their test cards on the **CONTROL** tab, then place both in the frame system so the rest of the platform knows where they sit. diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md index 985234d19d..a1c0f3b580 100644 --- a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md +++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md @@ -12,7 +12,6 @@ phase_total: 6 prev: "/tutorials/so-arm101-palletizing/configure-the-arm/" next: "/tutorials/so-arm101-palletizing/pack-from-python/" languages: ["python"] -draft: true --- In this phase you map the physical cell into the arm's frame. Nothing in the cell is pre-measured: you find where the staging spot and the pallet actually sit, expressed in the arm's own coordinate frame, by moving the arm there yourself and reading its position back from the Viam app. You capture two anchor poses this way; the code you write in Phase 4 computes the rest of the pallet grid from them. diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md index 43621aea6d..4e08ab7158 100644 --- a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md +++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md @@ -12,7 +12,6 @@ phase_total: 6 prev: "/tutorials/so-arm101-palletizing/teach-the-cell/" next: "/tutorials/so-arm101-palletizing/avoid-placed-cubes/" languages: ["python"] -draft: true --- In this phase you write `palletizer.py`, a Python script that reads back the two anchor poses from Phase 3 and drives the arm through a packing routine for each of the four bottom-layer cells. Getting through this phase is milestone one: you drive the arm through a static pack from your own code. diff --git a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md index 881f604a0d..8edee4b976 100644 --- a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md +++ b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md @@ -12,7 +12,6 @@ phase_total: 6 prev: "/tutorials/so-arm101-palletizing/pack-from-python/" next: "/tutorials/so-arm101-palletizing/inline-module/" languages: ["python"] -draft: true --- In this phase you teach the motion service about the cubes already on the pallet and the cube in the gripper, so it plans around them instead of through them. Finishing this phase is milestone two: a full, collision-free two-layer pack. diff --git a/docs/tutorials/so-arm101-palletizing/06-inline-module.md b/docs/tutorials/so-arm101-palletizing/06-inline-module.md index d2c96021dc..d0e846369e 100644 --- a/docs/tutorials/so-arm101-palletizing/06-inline-module.md +++ b/docs/tutorials/so-arm101-palletizing/06-inline-module.md @@ -12,7 +12,6 @@ phase_total: 6 prev: "/tutorials/so-arm101-palletizing/avoid-placed-cubes/" next: "/tutorials/so-arm101-palletizing/wrap-up/" languages: ["python"] -draft: true --- This phase is optional. It takes the working pack loop from Phase 5 off your laptop and onto the machine, where it runs as a module and can be triggered on demand instead of from a script. diff --git a/docs/tutorials/so-arm101-palletizing/07-wrap-up.md b/docs/tutorials/so-arm101-palletizing/07-wrap-up.md index 12fd76ed1e..f0ebbaf457 100644 --- a/docs/tutorials/so-arm101-palletizing/07-wrap-up.md +++ b/docs/tutorials/so-arm101-palletizing/07-wrap-up.md @@ -9,7 +9,6 @@ workshop: "so-arm101-palletizing" toc_hide: true prev: "/tutorials/so-arm101-palletizing/inline-module/" languages: ["python"] -draft: true --- Congratulations, you have finished the SO-ARM101 palletizing workshop! Starting from an empty machine, you configured a real desktop arm, taught it the cell by hand, and wrote the code that packs cubes onto a pallet. diff --git a/docs/tutorials/so-arm101-palletizing/_index.md b/docs/tutorials/so-arm101-palletizing/_index.md index d567fd07a7..6548430dfd 100644 --- a/docs/tutorials/so-arm101-palletizing/_index.md +++ b/docs/tutorials/so-arm101-palletizing/_index.md @@ -19,7 +19,6 @@ hardware: - "Personal computer" companion_repo: "https://github.com/viam-devrel/mini-palletizer" no_list: true -draft: true --- Warehouse robots spend all day stacking boxes onto pallets. In this workshop you build the same thing in miniature, on your desk: an affordable SO-ARM101 arm that picks up cubes and stacks them neatly, two layers at a time. From fb3eb0a2b7407ed83e024875e2f101a6df91a40d Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sun, 12 Jul 2026 16:12:15 -0400 Subject: [PATCH 27/29] docs(tutorials): add the cell-layout diagram (SVG) to phase 1 Author diagram-cell-layout as a top-down SVG plan of the cell (arm base at the world origin, staging spot, 2x2 pallet grid with origin + x/y axes, pick-then-place flow, and a two-layer inset) and embed it via imgproc, matching the frame-system docs' SVG-diagram style. grid-iso (phase 4) was already produced; these are the two DIAGRAM assets from the shot list. --- .../diagram-cell-layout.svg | 82 +++++++++++++++++++ .../01-platform-mental-model.md | 2 + 2 files changed, 84 insertions(+) create mode 100644 assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg diff --git a/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg b/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg new file mode 100644 index 0000000000..eace88f7c8 --- /dev/null +++ b/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg @@ -0,0 +1,82 @@ + + + + + + The miniature palletizing cell (top-down) + + + + + + + + arm base + world origin + + + + + staging spot + feed one cube here + + + + pick, then place + + + + + + + + origin (cell 0) + pallet grid + 2 x 2, 30 mm pitch + + + + + + + x + y + + + + each cell holds two cubes, + stacked into two layers of four + + + + + + + + + + + + + + + The arm picks a cube from the staging spot and stacks eight cubes, two layers of four, onto the pallet. + + + + + + + + + + diff --git a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md index 4ccf760724..b2251e228f 100644 --- a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md +++ b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md @@ -21,6 +21,8 @@ You are building a miniature palletizing cell. An SO-ARM101 arm picks cubes, one +{{}} + ## The three layers A Viam machine is made of three layers that each do one job: From eb30ebe0c6546140382e5f0bbd85a73fa1c1c3ac Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sun, 12 Jul 2026 16:33:08 -0400 Subject: [PATCH 28/29] docs(tutorials): draw a realistic articulated arm in the cell-layout diagram Replace the two-stick arm with a base, articulated upper-arm and forearm (joint circles, tube outline+fill), and a two-finger gripper, matching the style of the frame-system docs' arm SVGs. --- .../diagram-cell-layout.svg | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg b/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg index eace88f7c8..4393ecc072 100644 --- a/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg +++ b/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg @@ -14,14 +14,36 @@ The miniature palletizing cell (top-down) - - - - - - - arm base - world origin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + arm base + world origin From 34364c82bde52866148e68ed7a2c9d247fbcd02a Mon Sep 17 00:00:00 2001 From: HipsterBrown Date: Sun, 12 Jul 2026 20:49:29 -0400 Subject: [PATCH 29/29] docs(tutorials): fix label overlaps in the cell-layout diagram Reroute the arm lower so the forearm clears the 'feed one cube here' label, and move the two-layer inset caption beside the cube stack instead of above it. --- .../diagram-cell-layout.svg | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg b/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg index 4393ecc072..7ce90586e9 100644 --- a/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg +++ b/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg @@ -14,30 +14,30 @@ The miniature palletizing cell (top-down) - + - - + + - - + + - - + + - - - - + + + + - + @@ -73,22 +73,22 @@ x y - - - each cell holds two cubes, - stacked into two layers of four + + - + - + + each cell holds two cubes, + stacked into two layers of four The arm picks a cube from the staging spot and stacks eight cubes, two layers of four, onto the pallet.