diff --git a/.flake8 b/.flake8 index ed4a6ff6f3..a1cd0aa7f8 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] -extend-ignore = F704, F821, F401, F841, E125, E117, F403, E999, E501, F405 +extend-ignore = F704, F706, F821, F401, F841, E125, E117, F403, E999, E501, F405 exclude = .git,build,dist max-complexity = 20 diff --git a/.htmltest.yml b/.htmltest.yml index aaac581364..9433ed7680 100644 --- a/.htmltest.yml +++ b/.htmltest.yml @@ -26,6 +26,7 @@ IgnoreURLs: - "instagram.com" - "twitter.com" - "github.com/viamrobotics/docs" + - "github.com/viam-devrel/pick-and-place" - "openai.com" - "espressif.com" - "pinout.xyz" diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss index e6c2f10ce9..63bd3de838 100644 --- a/assets/scss/_styles_project.scss +++ b/assets/scss/_styles_project.scss @@ -1376,10 +1376,10 @@ td > ol { .table .tab-content > div .code-toolbar { border: none; width: fit-content; - max-width: 100%;; + max-width: 100%; } -.table .tab-content .tab-pane { +.table .tab-content .tab-pane { max-width: none; } @@ -1431,6 +1431,10 @@ td > ol { } #navSearchBar { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); margin: 0px; min-width: 300px; max-width: 300px; @@ -3180,22 +3184,26 @@ nav { padding: 0.25rem 1rem; min-height: 50px; background-color: white; + position: relative; } .td-topbar-sections { margin-right: auto !important; - margin-left: auto !important; + margin-left: 0 !important; line-height: unset !important; } .td-topbar-sections > ul { + display: flex; + flex-direction: row; + align-items: center; padding: 0; - padding-left: 1.25rem; margin: 0; } .td-topbar-sections > ul > li { - display: inline-block; + display: flex; + align-items: center; text-transform: uppercase; font-family: Roboto Mono Variable, @@ -3206,19 +3214,28 @@ nav { line-height: 1.25rem; } -.td-topbar-sections > ul > li > a { +/* "|" separator before each topbar link; fixed height matches the brand divider */ +.td-topbar-sections > ul > li::before { + content: ""; + width: 1px; + height: 20px; + background-color: #e4e4e6; + margin: 0 0.5rem; +} + +/* No distinct hover/focus state — matches the brand link, which also stays static */ +.td-navbar .td-topbar-sections > ul > li > .nav-link, +.td-navbar .td-topbar-sections > ul > li > .nav-link.active, +.td-navbar .td-topbar-sections > ul > li > .nav-link:hover, +.td-navbar .td-topbar-sections > ul > li > .nav-link:focus { color: black; + text-decoration: none; padding-bottom: 4px; padding-top: 8px; padding-right: 0.75rem; padding-left: 0.75rem; } -.td-topbar-sections > ul > li > a:hover { - text-decoration: none; - background-color: rgb(232, 232, 234); -} - @media (min-width: 768px) { .td-main main { padding-top: 5rem; @@ -3229,14 +3246,12 @@ nav { min-height: 3.5rem; } - @media (min-width: 992px) { .d-lg-block { display: flex !important; } } - ul > li.nav-fold > span > span.link-and-toggle { display: flex !important; flex-direction: row; @@ -3247,7 +3262,6 @@ ul.ul-0 > li.nav-fold:last-child { } @media (min-width: 768px) { - #landing-page-sidebar { display: none; } @@ -3303,8 +3317,6 @@ ul.ul-0 > li.nav-fold:last-child { padding-left: 0.5rem; } - - .td-sidebar-nav__section .ul-1 ul.ul-2 { padding-left: 0.5rem; border-left: 1px solid #e4e4e6; @@ -4179,3 +4191,306 @@ footer { } /* API overview grid END */ + +/* Featured tutorial cards (tutorials landing) */ +.tutorial-card a { + display: block; + height: 100%; + text-decoration: none; + color: inherit; +} +.tutorial-card > a { + padding: 0; +} +.tutorial-card__body { + padding: 20px 22px; +} +.tutorial-card__title { + font-weight: 700; + font-size: 1.15rem; + line-height: 1.3; + margin-bottom: 8px; +} +.tutorial-card__desc { + color: #4a4a4f; + margin: 0 0 14px; +} +.tutorial-card__badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.tutorial-card__badge { + display: inline-block; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 3px 8px; + border-radius: 4px; + background: #f0f0f2; + color: #282829; +} +.tutorial-card__badge--workshop { + background: #282829; + color: #fff; +} +.tutorial-card__badge--lang { + background: #e6effa; + color: #1b4f8a; +} +.browse-all-tutorials { + margin-top: 24px; + font-weight: 600; +} + +/* ── Tutorials landing: featured hero + spec-sheet aesthetic ───────────── */ +.td-content.tutorials { + max-width: 1080px; +} +.tutorials-intro h1 { + margin-bottom: 0.35rem; +} +.tutorials-intro > p { + color: #4a4a4f; + font-size: 1.05rem; + max-width: 60ch; +} + +.tutorial-hero { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); + margin: 2rem 0 2.5rem; + border: 1px solid #e4e4e6; + border-radius: 12px; + overflow: hidden; + background: #fff; + box-shadow: + 0 1px 2px rgba(20, 20, 25, 0.04), + 0 18px 40px -22px rgba(20, 20, 25, 0.22); +} +.tutorial-hero--solo { + grid-template-columns: 1fr; +} + +.tutorial-hero__main { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 2.5rem 2.75rem; +} +.tutorial-hero__eyebrow { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-family: "Space Mono", ui-monospace, monospace; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #8a8a90; +} +.tutorial-hero__eyebrow::before { + content: ""; + width: 20px; + height: 1px; + background: currentColor; +} +.tutorial-hero__title { + margin: 0.85rem 0 0.7rem; + font-size: 2rem; + line-height: 1.08; + font-weight: 800; + letter-spacing: -0.01em; +} +.tutorial-hero__title a { + color: #1c1c1f; + text-decoration: none; +} +.tutorial-hero__title a:hover { + text-decoration: underline; + text-underline-offset: 3px; +} +.tutorial-hero__desc { + margin: 0 0 1.4rem; + max-width: 48ch; + color: #4a4a4f; + font-size: 1.02rem; +} +.tutorial-hero__spec { + display: flex; + flex-wrap: wrap; + margin: 0 0 1.6rem; + border-top: 1px solid #ececee; +} +.tutorial-hero__spec > div { + margin-top: 0.9rem; + padding: 0 1.6rem 0 0; +} +.tutorial-hero__spec dt { + margin: 0 0 0.2rem; + font-family: "Space Mono", ui-monospace, monospace; + font-size: 0.64rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #9a9aa0; +} +.tutorial-hero__spec dd { + margin: 0; + font-weight: 600; + font-size: 0.92rem; + color: #1c1c1f; + text-transform: capitalize; +} +.tutorial-hero__cta { + margin-top: auto; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.7rem 1.25rem; + border-radius: 6px; + background: #1c1c1f; + color: #fff; + font-family: "Space Mono", ui-monospace, monospace; + font-size: 0.8rem; + letter-spacing: 0.04em; + text-transform: uppercase; + text-decoration: none; + transition: + background 0.15s ease, + transform 0.15s ease; +} +.tutorial-hero__cta:hover { + background: #000; + color: #fff; + text-decoration: none; +} +.tutorial-hero__cta span { + transition: transform 0.15s ease; +} +.tutorial-hero__cta:hover span { + transform: translateX(3px); +} + +/* Dark phase panel — the one high-contrast, on-brand moment */ +.tutorial-hero__phases { + padding: 2.1rem 2.1rem 1.9rem; + background-color: #161618; + background-image: radial-gradient( + circle at 100% 0%, + rgba(255, 255, 255, 0.06), + transparent 55% + ); + color: #e8e8ea; +} +.tutorial-hero__phases-label { + display: block; + margin-bottom: 1rem; + font-family: "Space Mono", ui-monospace, monospace; + font-size: 0.66rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #7d7d86; +} +.tutorial-hero__phases ol { + margin: 0; + padding: 0; + list-style: none; +} +.tutorial-hero__phases li { + border-top: 1px solid rgba(255, 255, 255, 0.08); +} +.tutorial-hero__phases li:first-child { + border-top: 0; +} +.tutorial-hero__phases a { + display: flex; + align-items: baseline; + gap: 0.9rem; + padding: 0.7rem 0; + color: #c8c8ce; + text-decoration: none; + transition: + color 0.15s ease, + padding-left 0.15s ease; +} +.tutorial-hero__phases a:hover { + padding-left: 4px; + color: #fff; + text-decoration: none; +} +.tutorial-hero__phases .num { + flex: none; + font-family: "Space Mono", ui-monospace, monospace; + font-size: 0.78rem; + color: #6e6e78; + transition: color 0.15s ease; +} +.tutorial-hero__phases a:hover .num { + color: #fff; +} +.tutorial-hero__phases .label { + font-size: 0.92rem; + line-height: 1.3; +} + +.tutorials-grid { + margin-top: 0.5rem; +} + +.browse-all-tutorials { + margin-top: 1.75rem; +} +.browse-all-link { + font-family: "Space Mono", ui-monospace, monospace; + font-size: 0.8rem; + letter-spacing: 0.04em; + text-transform: uppercase; + text-decoration: none; + color: #1c1c1f; + border-bottom: 2px solid #1c1c1f; + padding-bottom: 2px; +} +.browse-all-link:hover { + text-decoration: none; + color: #1c1c1f; +} +.browse-all-link span { + display: inline-block; + transition: transform 0.15s ease; +} +.browse-all-link:hover span { + transform: translateX(4px); +} + +/* One orchestrated page-load reveal; respects reduced-motion */ +@media (prefers-reduced-motion: no-preference) { + .tutorial-hero { + animation: tutorial-hero-rise 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both; + } + .tutorial-hero__phases li { + animation: tutorial-hero-rise 0.4s cubic-bezier(0.2, 0.7, 0.2, 1) both; + animation-delay: calc(140ms + var(--i) * 60ms); + } + @keyframes tutorial-hero-rise { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: none; + } + } +} + +@media (max-width: 800px) { + .tutorial-hero { + grid-template-columns: 1fr; + } + .tutorial-hero__main { + padding: 1.9rem 1.6rem; + } + .tutorial-hero__phases { + padding: 1.6rem; + } +} diff --git a/docs/tutorials/_index.md b/docs/tutorials/_index.md index e2a79a4651..89413c3710 100644 --- a/docs/tutorials/_index.md +++ b/docs/tutorials/_index.md @@ -10,6 +10,9 @@ toc_hide: true # do not remove hide children - it causes a layout issue no_list: true hide_children: true +menu: + main: + weight: 10 sitemap: priority: 1.0 outputs: diff --git a/docs/tutorials/all/_index.md b/docs/tutorials/all/_index.md new file mode 100644 index 0000000000..abc18d0c5b --- /dev/null +++ b/docs/tutorials/all/_index.md @@ -0,0 +1,12 @@ +--- +title: "All Tutorials" +linkTitle: "All Tutorials" +type: "docs" +layout: "tutorials-all" +weight: 1 +toc_hide: true +no_list: true +hide_children: true +outputs: + - html +--- diff --git a/docs/tutorials/pick-and-place/01-platform-mental-model.md b/docs/tutorials/pick-and-place/01-platform-mental-model.md new file mode 100644 index 0000000000..d7e03c9244 --- /dev/null +++ b/docs/tutorials/pick-and-place/01-platform-mental-model.md @@ -0,0 +1,104 @@ +--- +title: "Phase 1: Platform mental model" +linkTitle: "1. Platform mental model" +type: "docs" +slug: "platform-mental-model" +weight: 10 +description: "Understand how the Viam cloud, agent, and server fit together before you configure anything." +workshop: "pick-and-place" +toc_hide: true +phase: 1 +phase_total: 6 +time_estimate: "15 minutes" +next: "/tutorials/pick-and-place/configure-resources/" +languages: ["python"] +--- + +Before you configure a single resource, this phase gives you the mental map you need to understand what happens when you make an API call, why config changes appear instantly on the robot, and how Python code on your laptop talks to hardware on the other side of the room. + +{{< workshop-phases >}} + +## Three questions to answer first + +By the end of this phase you should be able to answer three questions. Keep them in mind as you read, and check yourself against them again at the end. + +1. What are the three layers of a Viam machine, and which one does your Python code actually talk to? +2. What is the difference between a component and a service? +3. Why does adding the arm in Phase 2 trigger a download? + +You will not write any code or change any configuration in this phase. Instead, you will open your own machine in the Viam app and look at what is already there, so that the concepts below have something real to point at. + +## The three layers + + + +A Viam machine is made of three layers that each do one job: + +- **The Viam cloud app** is the source of truth for configuration. When you add a component, change an attribute, or wire up a service, you are editing a JSON document stored in the cloud. The app never runs your robot directly; it describes what should run. +- **viam-agent** runs on the machine itself. It manages the install: it installs, updates, and keeps `viam-server` running, restarts it if it crashes, and provides the initial bootstrap credentials viam-server needs to reach the cloud. Think of viam-agent as the process supervisor, not as the source of your resource config, and not as something you interact with directly during this workshop. +- **viam-server** is the process that does the actual work. It pulls its resource config from the cloud app, starts every component and service that config describes, and exposes them over an API. This is the layer that drives the arm, reads the camera, and runs the vision pipeline. + +Open your machine's overview 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. + +## How your SDK connects + +In Phase 4 you write a Python script that imports the Viam SDK and connects to your machine. That 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 goes directly to `viam-server` on the robot: moving the arm, reading the camera, calling the vision service. + +This matters because it explains why your script keeps working even if your laptop briefly loses its connection to the internet at large but keeps a path to the robot: the cloud app is not in the request path for control, only for discovery and configuration delivery. + +Open the **CONNECT** tab on your machine's page in the Viam app and look at the code sample it generates. The address and API key shown there are exactly what your Phase 4 script will use to reach `viam-server`. + +## Configuration is the source of truth + +Whatever you set in the app's **CONFIGURE** tab is what the machine runs. There is no separate step to "deploy" a change: when you save an edit, the cloud app updates the config document, and `viam-server` picks up the new config and applies it, typically within seconds, without a restart for most changes. + +This is why the workshop asks you to make changes in the app rather than by editing a file on the robot directly. The app's CONFIGURE tab is the only place you need to look to know what a machine will do. + +Open the **CONFIGURE** tab now and find the JSON view toggle near the top of the panel. Switching to JSON shows you the exact document that `viam-server` receives, the same resources you see as cards in the builder view, expressed as the config it consumes. + +## Resources: the universal abstraction + + + +Everything a Viam machine does, hardware and software alike, is modeled as a **resource**. Each resource has a name you choose (like `arm-1`), an API that describes what kind of thing it is (an arm, a camera, a vision service), and a model that identifies the specific implementation. + +Open the **CONFIGURE** tab and find `arm-1`. Next to its name you will see a triplet in the form `namespace:family:model`, for example `viam:ufactory:xArm6`. That triplet tells `viam-server` exactly which code to run for this resource: who published it (`namespace`), what family of hardware it belongs to (`family`), and the specific model (`model`). + +The same idea applies to `gripper-1` and `cam-1`: each is a resource with a name, an API, and a model, even though one drives a gripper and the other reads a depth camera. + +## Components and services + +Resources split into two kinds: + +- **Components** represent physical hardware. `arm-1`, `gripper-1`, and `cam-1` are all components: each one wraps a piece of hardware and exposes a standard API for it (an arm API with move commands, a camera API that returns images, and so on). +- **Services** represent software capabilities that consume other resources rather than hardware directly. A motion service plans collision-free paths for an arm. Later, in Phase 5, you configure a `shape-detector` vision service that reads frames from `cam-1` and finds blocks by shape, and a `vision-segment` service (model `detections-to-segments`) that takes those detections and turns them into point cloud segments the motion planner can grasp. Neither service is a physical thing; each one composes other resources into a new capability. + +Open the **CONTROL** tab and look at the cards laid out for your machine. Each card corresponds to one resource. The arm, gripper, and camera cards let you jog hardware directly; any service card lets you exercise a capability that is built on top of that hardware. + +{{< alert title="Foreshadowing" color="note" >}} +You will not configure `shape-detector` or `vision-segment` until Phase 5. For now, just notice the pattern: a service is defined by what other resources it depends on, not by hardware it owns. +{{< /alert >}} + +## Builtin resources and modules + +Some resources are **builtin**: `viam-server` (also called the RDK, the robot development kit) ships with support for common APIs and a handful of default models out of the box. Most of the interesting functionality on a real machine, though, comes from **modules**: packages that `viam-server` downloads and runs to add support for a specific model. + +The `viam:ufactory:xArm6` arm you saw in the CONFIGURE tab is module-provided. In Phase 2, the moment you add that arm to your config, `viam-server` recognizes it does not have `viam:ufactory:xArm6` built in, downloads the module that provides it from the Viam registry, and starts it. You will be able to watch that download and start happen live in the LOGS tab. + +Open the **CONFIGURE** tab again and compare `arm-1`'s namespace to the namespace on any resource marked `rdk` (if you see one). A namespace of `rdk` means the model ships inside `viam-server` itself; any other namespace, like `viam` or `erh`, means the model arrives as a module. + +## The resource dependency graph + + + +Resources can depend on each other. A gripper attaches to an arm, so `gripper-1`'s config points at `arm-1`. A vision service reads from a camera, so it depends on `cam-1`. `viam-server` reads these dependencies out of your config and builds a graph, then starts resources in an order that respects it: a resource cannot start until everything it depends on has started. + +This is the same pattern you will see again in Phase 5: `vision-segment` depends on `shape-detector`, which depends on `cam-1`. Neither can produce meaningful output until the resource beneath it is running. + +Open the **LOGS** tab and look at the startup sequence the next time the machine restarts (you do not need to trigger one now). The order resources come online in the log follows the dependency graph, not the order they appear in the config file. + +{{< alert title="Check yourself" color="note" >}} +Before moving to Phase 2, make sure you can answer the three questions from the top of this page: name the three layers and which one your code talks to, what separates a component from a service, and why adding the arm triggers a module download. If any answer feels shaky, re-skim the relevant section above. +{{< /alert >}} + +{{< workshop-nav >}} diff --git a/docs/tutorials/pick-and-place/02-configure-resources.md b/docs/tutorials/pick-and-place/02-configure-resources.md new file mode 100644 index 0000000000..27657d4b22 --- /dev/null +++ b/docs/tutorials/pick-and-place/02-configure-resources.md @@ -0,0 +1,127 @@ +--- +title: "Phase 2: Configure resources" +linkTitle: "2. Configure resources" +type: "docs" +slug: "configure-resources" +weight: 20 +description: "Configure the arm, gripper, and camera by hand, then verify each one from the control and 3D scene tabs." +workshop: "pick-and-place" +toc_hide: true +phase: 2 +phase_total: 6 +time_estimate: "20 minutes" +prev: "/tutorials/pick-and-place/platform-mental-model/" +next: "/tutorials/pick-and-place/static-positions/" +languages: ["python"] +--- + +In this phase you configure the three hardware resources every pick-and-place machine needs: an arm, a gripper, and a camera. Your machine starts with none of them. You add each one by hand, watch it come online, and confirm it works from the CONTROL tab before moving to the next. + +{{< workshop-phases >}} + +## The target state + +By the end of this phase your CONFIGURE tab holds three components: + +| Name | API | Model | +| ----------- | ----------------------- | ----------------------- | +| `arm-1` | `rdk:component:arm` | `viam:ufactory:xArm6` | +| `gripper-1` | `rdk:component:gripper` | `viam:ufactory:gripper` | +| `cam-1` | `rdk:component:camera` | `viam:camera:realsense` | + +You will not touch the vision service in this phase. The `shape-detector` and `vision-segment` services that let the machine find blocks by shape come later, in Phase 5, right before you write the code that calls them. For now, focus on the hardware. + +## Configure the arm + + + + + +On the **CONFIGURE** tab, add a new component. In the add-component dialog, search for `xArm6` and select the `viam:ufactory:xArm6` result. Name the component `arm-1`. + +Set the following attributes: + +- `host`: the xArm controller's IP address, provided by your workshop facilitator +- `port`: `502` + +You can leave `speed_degs_per_sec` and `acceleration_degs_per_sec_per_sec` unset for now; both are optional and default to safe values. + +Save the config. This is the moment from Phase 1 made concrete: `viam-server` does not have `viam:ufactory:xArm6` built in, so it fetches the `viam:ufactory` module from the Viam registry and starts it. Open the **LOGS** tab and watch it happen: a log line for the module download, then one for the module starting, then `arm-1` itself coming online. The whole sequence usually takes well under a minute. + +{{< alert title="Two components, one module" color="note" >}} +The `viam:ufactory` module provides both the arm model and the gripper model you configure next. You only pay the download cost once. +{{< /alert >}} + +## Configure the gripper + + + +Add another component. In the add-component dialog, search for the `viam:ufactory:gripper` model and select it. This model comes from the same `viam:ufactory` module as the arm. Name it `gripper-1`. + +Set one attribute: + +- `arm`: `"arm-1"` + +This attribute is also a dependency: `gripper-1` cannot start until `arm-1` is running, because the gripper is physically mounted on the arm and controlled through the same connection. Save the config and check the **LOGS** tab again. Because `viam-server` already has the `viam:ufactory` module running from the previous step, `gripper-1` comes online immediately with no second download. + +## Configure the camera + + + +Add a third component. In the add-component dialog, search for `realsense` and select the `viam:camera:realsense` result. Name it `cam-1`. + +Set the following attributes: + +- `sensors`: `["color", "depth"]` +- `width_px`: `640` +- `height_px`: `480` + +Save the config and confirm in the **LOGS** tab that `viam-server` downloads the `viam:camera-realsense` module and starts `cam-1`. This is a separate module from `viam:ufactory`, since it comes from a different publisher and family. + +## Test each resource from the CONTROL tab + + + + + +Open the **CONTROL** tab. You should now see a test card for each of the three components you just added. + +On the camera card, confirm you get a live feed: + +{{< checkpoint >}} +The camera card shows a live color stream from `cam-1`. If depth is available as a separate view, confirm that stream updates too. If the card is blank, check the LOGS tab for a camera error before moving on. +{{< /checkpoint >}} + +On the arm card, jog a joint with the sliders and confirm the arm moves. Then select **Get end position** and confirm it returns x, y, and z coordinates. + +{{< checkpoint >}} +Jogging a joint slider moves the physical arm, and **Get end position** returns a coordinate rather than an error. If jogging does nothing, confirm `arm-1` shows as online in the CONFIGURE tab and that the LOGS tab has no connection errors for it. +{{< /checkpoint >}} + +On the gripper card, place a block between the gripper fingers by hand, then select **Grab**. The fingers should close and hold the block. Select **Open** and confirm the fingers release it. + +{{< checkpoint >}} +With a block between the fingers, **Grab** closes the fingers and the gripper holds the block without dropping it. **Open** releases the block. This grab-and-release is the same action your Python code performs later in the workshop when it picks a block and drops it in a bin. If your gripper card also shows a holding status indicator, it now reads true while the block is held and false once the gripper is open and empty. +{{< /checkpoint >}} + +## The 3D scene tab + + + +Open the **3D scene** tab. You should see a rendered model of the arm and the `cam-1` frame positioned at the end of its wrist. + +Jog joint 1 with the arm card's sliders and watch the 3D scene as the arm turns. The `cam-1` frame moves with the arm, because the camera is mounted on the wrist rather than fixed in the workspace. + +{{< alert title="Why this matters later" color="note" >}} +Because the camera is wrist-mounted, every detection it makes is relative to wherever the arm happens to be pointed at that moment. In Phase 5 you will detect from a single known pose so that "camera space" always means the same thing. Notice that pose here; you will meet it again as the "detect from home" rule. +{{< /alert >}} + +The exact camera mounting offset used to transform its frame into the arm's frame comes from your hardware setup guide or the pre-provisioned config, not from anything you configure in this phase. For now, just confirm the relationship: the frame exists, and it tracks the arm. + +## Check your work + +If you want to compare your configuration against a known-good version, the companion repo has a reference copy at [machine-fragment.json](https://github.com/viam-devrel/pick-and-place/blob/main/config/machine-fragment.json). Use it to check your work, not to import over what you just built by hand. + +With `arm-1`, `gripper-1`, and `cam-1` all live and verified, you are ready for Phase 3, where you save a set of fixed arm poses and prove the full hardware sequence works before perception enters the picture. + +{{< workshop-nav >}} diff --git a/docs/tutorials/pick-and-place/03-static-positions.md b/docs/tutorials/pick-and-place/03-static-positions.md new file mode 100644 index 0000000000..13e9f823df --- /dev/null +++ b/docs/tutorials/pick-and-place/03-static-positions.md @@ -0,0 +1,234 @@ +--- +title: "Phase 3: Static positions and obstacles" +linkTitle: "3. Static positions" +type: "docs" +slug: "static-positions" +weight: 30 +description: "Save the arm's key poses and configure obstacle components, proving the hardware and motion planning work before you add perception." +workshop: "pick-and-place" +toc_hide: true +phase: 3 +phase_total: 6 +time_estimate: "20 minutes" +prev: "/tutorials/pick-and-place/configure-resources/" +next: "/tutorials/pick-and-place/control-the-robot-from-python/" +languages: ["python"] +--- + +In this phase you move the arm through the full pick-and-place sequence using saved poses, with no perception yet. The goal is to prove that the hardware and motion planning work before you add detection, so that any bug you encounter later has only one new cause. + +{{< workshop-phases >}} + +## Why static positions first + +When you add perception and motion planning at the same time, a failure could live in detection, the frame transform, the pose math, the motion planner, or gripper timing, and there is no straightforward way to tell which. Saving fixed poses lets you run the full hardware loop first. In Phase 4 you drive this same proven sequence from a Python script, and perception does not enter the picture until Phase 5. Once the arm reliably travels through every stage of the sequence, perception becomes the only new variable when you reach it. + +This is not just a classroom shortcut. Pose-to-pose motion without perception is a real production workcell pattern: any time a part always lands in the same spot, a fixed sequence of saved poses is simpler and more reliable than running detection on every cycle. + +The table below shows what each step in the static sequence validates: + +| Step | What it validates | +| ------------------------- | ------------------------------------------------- | +| Arm reaches home pose | Observation position is safe and repeatable | +| Arm reaches approach pose | Arm can get above the workspace without collision | +| Arm reaches grasp pose | Descent distance is correct | +| Gripper opens and closes | Finger timing is right | +| Arm reaches travel pose | Safe carrying height clears obstacles | +| Arm reaches place pose | Bin position is correct | + +## The key poses + + + +Each saved pose has a specific role in the sequence: + +| Pose | Purpose | +| ------------- | ----------------------------------------------------------------------------------------- | +| home-pose | Observation position above the workspace; the wrist camera has a clear view of the blocks | +| approach-pose | Standoff directly above the pick zone, roughly 80 to 100 mm above the highest block | +| grasp-pose | At the block, gripper open and ready to close; fingertips are level with the block top | +| travel-pose | Safe carrying height that clears obstacles while holding a block | +| place-pose | Above the bin where blocks are dropped | + +The approach pose and the grasp pose share the same x and y coordinates. The only motion between them is straight down the z axis, so if the arm drifts sideways during the descent you have a frame or calibration issue to investigate. + +## Save each pose with the arm position saver + + + + +You configure pose saving by hand, the same way you configured the arm, gripper, and camera in Phase 2. + +Add the `erh:vmodutils` module from the Viam registry to your machine. This module provides the `erh:vmodutils:arm-position-saver` switch model you use to save and recall poses, and the `erh:vmodutils:obstacle` model you use later in this phase. + +Add a **switch** component for `home-pose`: + +- API: `rdk:component:switch` +- Model: `erh:vmodutils:arm-position-saver` +- Attribute: `arm`: `"arm-1"` + +This attribute is also a dependency, the same way `gripper-1` depends on `arm-1`: the switch cannot save or recall a pose until the arm it points at is running. + +With the `home-pose` switch added, save and verify it: + +1. Open the **CONTROL** tab and find the arm test card. Use the joint sliders to jog the arm into position. +2. Select **Get end position** on the arm card and note the x, y, and z values to confirm the arm is where you expect it. +3. On the switch test card, set the switch to **position 1** to save the current joint positions. +4. Set the switch to **position 2** to confirm the arm returns to the saved pose from any starting position. + +Now that `home-pose` is saved, open its resource card on the **CONFIGURE** tab and use the **Duplicate** feature to create a copy. Rename the copy to `approach-pose`, and its `arm` attribute carries over automatically since it is already set to `"arm-1"`. Duplicate three more times for `grasp-pose`, `travel-pose`, and `place-pose`. This is faster than adding five switches from scratch and less error-prone, since you only type the `arm` attribute once. + +Run the same four save-and-verify steps for each of the four new poses: jog the arm into position, confirm it with **Get end position**, set the switch to position 1 to save, and set it to position 2 to confirm the arm returns. + +{{< alert title="Switch positions" color="note" >}} +On an `arm-position-saver` switch, position 1 saves the current joint positions, position 2 moves the arm to the saved pose, and position 0 clears any saved data. Always save with position 1 before you attempt position 2. Setting position 2 on an unsaved switch does nothing. +{{< /alert >}} + +{{< checkpoint >}} +Set each saved switch to position 2 in turn. The arm should move to each pose you saved. If a switch does nothing when you set it to position 2, you have not saved it yet. Set position 1 first, then try position 2 again. +{{< /checkpoint >}} + +## Teach the planner about obstacles + +The Viam motion planner is collision-aware, but it can only avoid geometry it knows about. Without any obstacle configuration, the planner avoids self-collisions only. Once you add obstacle geometry, the planner treats the table surface and the workspace boundary as hard obstacles it cannot plan through. + +This matters both for correctness and for safety. Without the table obstacle, the planner might find a path that swings the arm through the table surface. Virtual safety walls at the workspace boundary also prevent the arm from swinging into people standing nearby. This is not just a classroom convenience: it is the same pattern you would use to keep a production workcell's motion planner honoring the real boundaries of its cell. + +In this workshop you configure two categories of obstacle: the table surface and two safety walls at the workspace boundary. Bin geometry is out of scope for this phase. + +## Obstacles are components + +Obstacles are not a separate WorldState file you import. Each obstacle is an `erh:vmodutils:obstacle` component you add on the **CONFIGURE** tab, the same way you added the arm, gripper, and camera. The obstacle model uses the gripper API, so once configured, each obstacle shows up in `resource_names` as a gripper. That is expected; the model reuses the gripper API purely as a resource container for geometry, it does not add a real gripper to your machine. + +### Measure your workspace + + + +Before you can fill in the obstacle geometry, measure your own table and workspace boundary. You need two kinds of measurement, and each one feeds a different part of the config: + +- **Tape-measure dimensions** for the box sizes: the table's length, width, and thickness go into the table obstacle's `x`, `y`, and `z`. Use the tape measure for how big each box is, not for where it sits. +- **Arm-relative positions** for the box translations: jog the arm to a landmark, such as the front edge of the table or the side boundary, and select **Get end position** on the arm's CONTROL card to read the x and y coordinates in the arm's coordinate frame. These are the numbers that fill the `REPLACE_WITH_MEASURED_FRONT_Y` and `REPLACE_WITH_MEASURED_SIDE_X` placeholders in the safety walls below. + +All obstacle geometry is expressed against the world origin, which in this setup sits at the arm base, so the x and y coordinates you read from **Get end position** drop straight into the `translation` fields without any conversion. + +Each obstacle geometry is a box defined by its `x`, `y`, and `z` dimensions (the box's full size in millimeters) and a `translation` (the box's center point in the parent frame). Two details trip people up: + +- The `z` value in a box's dimensions is its height, but the `translation.z` you provide is the box's **center**, not its top or bottom surface. A 30 mm thick table sitting flush with the world origin at `z = 0` needs a `translation.z` of `-15`, half the thickness, because the box extends from `-30` to `0` and its center is at `-15`. +- The safety walls are thin, tall boxes. A wall that is 600 mm tall has `z: 600` for its dimension, but its `translation.z` is `300`, half the height, for the same reason. + +### Add the table obstacle + + + +Add a component: + +- API: `rdk:component:gripper` +- Model: `erh:vmodutils:obstacle` + +```json +{ + "name": "table", + "api": "rdk:component:gripper", + "model": "erh:vmodutils:obstacle", + "attributes": { + "geometries": [ + { + "label": "table", + "type": "box", + "x": 1200, + "y": 800, + "z": 30, + "translation": { "x": 0, "y": 0, "z": -15 }, + "parent": "world" + } + ] + } +} +``` + +Replace the `x`, `y`, and `z` dimensions with your own measured table length, width, and thickness. If your table is not centered on the arm base in x and y, adjust `translation.x` and `translation.y` to match, using the values you read from **Get end position** when jogging to the table edges. + +### Add the safety walls + + + +Add two more `erh:vmodutils:obstacle` components, one per boundary you want to wall off: + +```json +{ + "name": "safety-wall-front", + "api": "rdk:component:gripper", + "model": "erh:vmodutils:obstacle", + "attributes": { + "geometries": [ + { + "label": "safety-wall-front", + "type": "box", + "x": 1200, + "y": 20, + "z": 600, + "translation": { + "x": 0, + "y": "REPLACE_WITH_MEASURED_FRONT_Y", + "z": 300 + }, + "parent": "world" + } + ] + } +} +``` + +```json +{ + "name": "safety-wall-side", + "api": "rdk:component:gripper", + "model": "erh:vmodutils:obstacle", + "attributes": { + "geometries": [ + { + "label": "safety-wall-side", + "type": "box", + "x": 20, + "y": 800, + "z": 600, + "translation": { + "x": "REPLACE_WITH_MEASURED_SIDE_X", + "y": 0, + "z": 300 + }, + "parent": "world" + } + ] + } +} +``` + +Replace `REPLACE_WITH_MEASURED_FRONT_Y` and `REPLACE_WITH_MEASURED_SIDE_X` with the coordinates you measured for the front and side boundaries of your workspace. Both walls are 600 mm tall, so their `translation.z` is 300, half the height. + +You can check your obstacle configuration against the companion repo's [obstacles-template.json](https://github.com/viam-devrel/pick-and-place/blob/main/config/obstacles-template.json), which has the full set with example measurements filled in. The full machine configuration, including all pose switches, is in [machine-fragment.json](https://github.com/viam-devrel/pick-and-place/blob/main/config/machine-fragment.json). Treat both as references to check your work against, not as files to import over what you configured by hand. + +## Test the full static sequence + + + + +From the **CONTROL** tab, trigger the pose switches in this order: + +```text +home-pose (2) -> approach-pose (2) -> Open gripper -> +grasp-pose (2) -> Grab -> travel-pose (2) -> +place-pose (2) -> Open gripper -> home-pose (2) +``` + +The **Open** and **Grab** buttons are the same gripper controls you used in Phase 2: **Grab** closes the fingers on a block and **Open** releases it. + +As the arm moves, open the **3D scene** tab to watch its path alongside the table surface and the safety walls. The planner refuses to plan through configured geometry, so an obstacle conflict shows up as a planning failure in the logs, not as the arm passing through the obstacle. Open the **LOGS** tab alongside the 3D scene to catch any such planning failure in real time. + +{{< checkpoint >}} +The arm completes the full sequence without stopping, the gripper opens and closes at the correct moments, and the LOGS tab shows no collision errors. If planning fails at a step, open the 3D scene tab to see what geometry the planner sees. Adjust the pose or the obstacle dimensions and retry. A common cause is an obstacle that is positioned slightly off from its physical counterpart, causing the planner to see the arm path as intersecting geometry that the physical arm actually clears. +{{< /checkpoint >}} + +You now have a working static sequence. In Phase 4 you drive this same sequence from a Python script, replacing the manual switch triggers with code. + +{{< workshop-nav >}} diff --git a/docs/tutorials/pick-and-place/04-control-the-robot-from-python.md b/docs/tutorials/pick-and-place/04-control-the-robot-from-python.md new file mode 100644 index 0000000000..4696f304f1 --- /dev/null +++ b/docs/tutorials/pick-and-place/04-control-the-robot-from-python.md @@ -0,0 +1,157 @@ +--- +title: "Phase 4: Control the robot from Python" +linkTitle: "4. Control from Python" +type: "docs" +slug: "control-the-robot-from-python" +weight: 40 +description: "Connect from your laptop and drive the saved static pick-and-place sequence from a Python script." +workshop: "pick-and-place" +toc_hide: true +phase: 4 +phase_total: 6 +time_estimate: "15 minutes" +prev: "/tutorials/pick-and-place/static-positions/" +next: "/tutorials/pick-and-place/perception-guided-picking/" +aliases: + - /tutorials/pick-and-place/local-python-script/ +languages: ["python"] +--- + +In this phase you write and run a Python script on your laptop that connects to the robot and executes the static pick-and-place sequence from Phase 3. This proves your connection, environment, and named positions work end to end before you add perception in Phase 5. + +{{< workshop-phases >}} + +## Why a script before a module + +Everything you did in Phase 3 happened by clicking test cards on the **CONTROL** tab. That is a fine way to verify hardware, but it does not scale: you cannot loop, branch on a sensor reading, or retry a failed grasp from a button. A local Python script gives you those things, plus fast iteration, a local debugger, and the ability to sprinkle in `print` statements wherever you need visibility into what the robot is doing. + +A script is also the right starting point before you package anything as a module. A module has to satisfy a defined lifecycle and run inside `viam-server`, which makes it slower to iterate on and harder to debug. In Phase 6 you package this same logic as a module. For now, a script you run from your own terminal, that you can stop, edit, and rerun in seconds, is the faster path to a working pick-and-place loop. + +This phase is worth the detour because of what it buys you. Every control you clicked in Phases 2 and 3 maps directly onto an SDK method: the arm card's joint sliders become `Arm` methods, **Grab** and **Open** on the gripper card become `gripper.grab()` and `gripper.open()`, and setting a switch to position 2 becomes `switch.set_position(2)`. Once you can call those methods from code, you can compose them into logic no UI card lets you express by clicking. + +## Get the companion project + +The workshop's companion repository, [viam-devrel/pick-and-place](https://github.com/viam-devrel/pick-and-place), has a `scripts/` project with a starter script already set up for this phase. Clone or download the repository, then work from the `scripts/` directory: + +```sh +git clone https://github.com/viam-devrel/pick-and-place.git +cd pick-and-place/scripts +``` + +Your environment was already validated in the workshop prerequisites, so this phase is about connecting and running, not debugging Python installs. Run the starter script with `uv`, the primary path for this workshop: + +```sh +uv run python starter-script.py +``` + +`uv` reads the project's `pyproject.toml` and `.python-version` and resolves `viam-sdk` for you automatically, so there is no separate install step. If you are not using `uv`, pip works as a fallback once you have installed the project's dependencies yourself: + +```sh +pip install viam-sdk +python3 starter-script.py +``` + +## Connect to your robot + + + +Open `starter-script.py` and find the `connect()` function. It mirrors the boilerplate the Viam app generates for you on the machine's **CONNECT** tab, under **Python SDK**: + +```python +MACHINE_ADDRESS = "" +API_KEY = "" +API_KEY_ID = "" + + +async def connect() -> RobotClient: + return await RobotClient.at_address( + MACHINE_ADDRESS, + options=RobotClient.Options.with_api_key( + api_key=API_KEY, + api_key_id=API_KEY_ID, + ), + ) +``` + +Open the **CONNECT** tab on your machine's page in the Viam app, select **Python SDK**, and copy the three values it shows you: the machine address and an API key and key ID pair. Paste them into `MACHINE_ADDRESS`, `API_KEY`, and `API_KEY_ID` at the top of the script. You are reading and understanding this boilerplate rather than writing it from scratch, the same connection code every Viam Python script starts with. + +{{< alert title="Handle your API key like a secret" color="note" >}} +Your API key grants control of the robot to anyone who has it. Do not commit it to version control. The companion repo's `.gitignore` already excludes the starter script's typical edit locations, but the safer pattern is to read the key from an environment variable instead of pasting it directly into the file, for example `API_KEY = os.environ["VIAM_API_KEY"]`. +{{< /alert >}} + +## Get typed resource handles + +After the connection opens, the script builds typed handles for each resource you drive in this phase: + +```python +arm = Arm.from_robot(machine, "arm-1") +gripper = Gripper.from_robot(machine, "gripper-1") + +home = Switch.from_robot(machine, "home-pose") +approach = Switch.from_robot(machine, "approach-pose") +grasp = Switch.from_robot(machine, "grasp-pose") +travel = Switch.from_robot(machine, "travel-pose") +place_pose = Switch.from_robot(machine, "place-pose") +``` + +Phase 4 drives only the arm, the gripper, and these pose switches. The starter script also declares two more handles right next to these: a `motion` handle for the `builtin` motion service, which always exists on a machine, and a `vision` handle for the `vision-segment` service. You do not configure `vision-segment` until Phase 5, and `VisionClient.from_robot` raises a `ResourceNotFoundError` when the service it names is not present. Because of that, the `vision = VisionClient.from_robot(...)` line must not run yet. + +Before you run the script, make sure that line is not active. If it is uncommented, comment it out for now: + +```python +# vision = VisionClient.from_robot(machine, "vision-segment") +``` + +You enable it in Phase 5 once the vision service exists. The `motion` handle is safe to leave as it is, since the `builtin` motion service is always present, but nothing in this phase calls it either. + +## Run the script + + + +Run the script now with `uv run python starter-script.py`. It happens in a single run: `connect()` opens the connection, the script prints every resource on the machine, and then it immediately drives the arm through the static sequence. Watch the printed resource list scroll past in your terminal before the arm starts moving. + +The first thing printed is the full resource list: + +```python +print(machine.resource_names) +``` + +{{< checkpoint >}} +`machine.resource_names` prints a list that includes at least `arm-1`, `gripper-1`, and `cam-1`, the five poses (`home-pose`, `approach-pose`, `grasp-pose`, `travel-pose`, `place-pose`) as switches, and the three obstacles from Phase 3 as grippers. The list also contains the `builtin` motion service and other `erh:vmodutils` entries, so expect more names than just these. Seeing the obstacles listed as grippers is expected: the `erh:vmodutils:obstacle` model reuses the gripper API purely as a resource container for geometry. +{{< /checkpoint >}} + +Right after the print, the script runs the static sequence. This is the same sequence you tested by hand from the **CONTROL** tab at the end of Phase 3, now expressed as code instead of button clicks. On a switch, `set_position(2)` executes the pose it has saved: + +```python +await home.set_position(2) +await approach.set_position(2) +await gripper.open() +await grasp.set_position(2) +await gripper.grab() +await asyncio.sleep(0.3) # finger gripper settle +await travel.set_position(2) +await place_pose.set_position(2) +await gripper.open() +await home.set_position(2) +``` + +The short sleep after `gripper.grab()` gives the finger gripper time to settle its grip on the block before the arm starts moving again; without it, the arm can begin the travel move before the fingers have finished closing. + +Notice that nothing in this code mentions the table or the safety walls. The obstacles you configured in Phase 3 live in the machine config, not in this script, and the motion system applies them automatically wherever planning happens. There is no runtime `WorldState` to build or pass in here. In this static phase, movement comes entirely from the saved-pose switches, so obstacle-aware planning is not something you will see kick in yet; it becomes visible once Phase 5 introduces planned moves toward a detected block. + +{{< checkpoint >}} +After the resource list prints, the same run drives the arm through the full sequence end to end: home, approach, open, grasp, grab, travel, place, open, home. The arm should complete every step without stopping, in the same order you validated manually in Phase 3. +{{< /checkpoint >}} + +## Debugging guide + +Most Phase 4 problems fall into one of a few categories: + +- **`ResourceNotFoundError: vision-segment`** (or a similar not-found error for the vision service) means you tried to build the vision handle before configuring the vision service. That service is not added until Phase 5. Comment out the `vision = VisionClient.from_robot(...)` line for now, as described above, and rerun. +- **Connection failures.** If `connect()` raises an error or hangs, double-check the `MACHINE_ADDRESS`, `API_KEY`, and `API_KEY_ID` values against the **CONNECT** tab. A stale or mistyped API key produces an authentication error immediately; a wrong address usually times out instead. Also confirm the machine shows the green **Live** indicator in the Viam app. A machine that is not live cannot accept a connection no matter how correct your credentials are. +- **Resource-name mismatches.** If `Arm.from_robot(machine, "arm-1")` or a similar call raises a not-found error, the name in your script does not match the name on the **CONFIGURE** tab. Names are exact strings, not approximations, so `arm-1` and `arm_1` are different resources as far as the SDK is concerned. Open `resource_names` from the connection checkpoint above and compare it character for character against the names your script uses. +- **A switch does nothing on `set_position(2)`.** This means the pose was never saved. Go back to the **CONTROL** tab and set that switch to position 1 to save the current arm position, as you did in Phase 3, then rerun the script. + +With `resource_names` printing everything you expect and the static sequence running end to end from your own code, you have working proof that your connection, your named resources, and your saved poses all hold up under real code, not just button clicks. In Phase 5 you replace the fixed `approach-pose` and `grasp-pose` in this sequence with positions computed from live perception, so the arm picks whichever block the camera actually detects instead of always reaching for the same spot. + +{{< workshop-nav >}} diff --git a/docs/tutorials/pick-and-place/05-perception-guided-picking.md b/docs/tutorials/pick-and-place/05-perception-guided-picking.md new file mode 100644 index 0000000000..0a4d8eb3f6 --- /dev/null +++ b/docs/tutorials/pick-and-place/05-perception-guided-picking.md @@ -0,0 +1,257 @@ +--- +title: "Phase 5: Perception-guided picking" +linkTitle: "5. Perception-guided picking" +type: "docs" +slug: "perception-guided-picking" +weight: 50 +description: "Add the vision pipeline and write the perception loop that detects a block, transforms it to the world frame, and picks it with motion planning." +workshop: "pick-and-place" +toc_hide: true +phase: 5 +phase_total: 6 +time_estimate: "22 minutes" +prev: "/tutorials/pick-and-place/control-the-robot-from-python/" +next: "/tutorials/pick-and-place/inline-module/" +languages: ["python"] +--- + +In this phase you replace the fixed approach and grasp poses from Phase 4 with live perception: the vision service detects a block, the frame system transforms its position into world space, and the motion service plans a collision-free pick. + +{{< workshop-phases >}} + +## Configure the vision pipeline + + + + +Phase 2 previewed two vision services and asked you to hold off configuring them. Add both now, in order, since the second depends on the first. + +Add a **vision** service: + +- API: `rdk:service:vision` +- Model: `devrel:shape-finder:detector` +- Name: `shape-detector` + +```json +{ + "name": "shape-detector", + "api": "rdk:service:vision", + "model": "devrel:shape-finder:detector", + "attributes": { + "camera_name": "cam-1" + } +} +``` + +The `camera_name` attribute is also a dependency: `shape-detector` cannot run until `cam-1` is online, the same dependency pattern you have already seen with `gripper-1` and `arm-1`. This service reads color frames from `cam-1` and finds blocks by shape, in two dimensions, with no depth information yet. + +Add a second **vision** service: + +- API: `rdk:service:vision` +- Model: `viam:vision:detections-to-segments` +- Name: `vision-segment` + +```json +{ + "name": "vision-segment", + "api": "rdk:service:vision", + "model": "viam:vision:detections-to-segments", + "attributes": { + "detector_name": "shape-detector", + "confidence_threshold_pct": 0.5, + "mean_k": 50, + "sigma": 1.5 + } +} +``` + +`vision-segment` depends on `shape-detector`, the same graph relationship as before: it takes each 2D shape detection, pulls the matching depth points from `cam-1`, filters noisy points out with the `mean_k` and `sigma` attributes, and fuses the result into a 3D object point cloud per detected block. The `confidence_threshold_pct` value is a fraction from 0 to 1, not a percentage figure, so `0.5` means keep detections at 50 percent confidence or higher. A 2D detection alone cannot tell you how far away a block is or where it sits in space; `vision-segment` is what turns "a block-shaped region of pixels" into "a block at this point in three dimensions." + +Save the config and open the **CONTROL** tab. Find the `vision-segment` test card and run it against `cam-1`. You should see one or more segmented objects, each drawn as a point cloud with a bounding box, over the shapes sitting in front of the camera. + +{{< checkpoint >}} +The `vision-segment` test card returns at least one object when a block is in view. If it returns nothing, confirm a block actually sits in the camera's field of view, then check the `shape-detector` card on its own: if that also returns nothing, the problem is upstream in shape detection, not in the depth fusion step. +{{< /checkpoint >}} + +With the service live, go back to `starter-script.py` and uncomment the vision handle you commented out in Phase 4: + +```python +vision = VisionClient.from_robot(machine, "vision-segment") +``` + +`VisionClient.from_robot` raised a `ResourceNotFoundError` in Phase 4 because `vision-segment` did not exist yet. Now that it is configured and online, the same line resolves. + +## The frame system and transform_pose + + + +Every pose that `vision-segment` returns is expressed in the `cam-1` frame. That is the only frame the vision service knows about: it looked at pixels and depth values coming out of one camera, so the coordinates it hands back describe where a block sits relative to that camera's own origin and orientation. + +The motion service does not think in camera coordinates. It plans in the `world` frame, the same frame your obstacle geometry in Phase 3 was defined against. To hand a detected pose to the motion service, you first have to express it in `world` instead of `cam-1`. + +This is the frame system you already saw in Phase 2's 3D scene tab, when the `cam-1` frame visibly moved as you jogged joint 1. `viam-server` maintains that same relationship as a graph: the camera's offset from the wrist, the wrist's offset from the next joint, and so on, all the way down to the arm's base at the world origin. `RobotClient.transform_pose` walks that graph for you. Give it a pose tagged with its source frame and a destination frame, and it returns the equivalent pose in the destination frame: + +```python +obj_in_cam = PoseInFrame(reference_frame="cam-1", pose=geometry.center) +obj_in_world = await machine.transform_pose(obj_in_cam, "world") +``` + +`PoseInFrame` pairs a `Pose` with the name of the frame it is expressed in. `transform_pose` reads that source frame, reads the destination frame you passed as the second argument, and returns a new `PoseInFrame` with the same physical point re-expressed in `world` coordinates. You will wire this into the full detection code in the next section, once there is an actual `geometry.center` to transform. + +## Detect from home (the wrist-camera rule) + + + +The `cam-1` frame is only a fixed thing to reason about when the arm is in a fixed pose. Because the camera is wrist-mounted, jogging any joint changes where `cam-1` sits in space, which changes what `transform_pose` reports for the exact same physical block. If you detect once with the arm near the bin and again with the arm hovering over the table, `transform_pose` gives two different world answers for two different arm positions, even if no block has moved at all. + +The fix is a rule, not a calculation: always move to `home-pose`, the observation position from Phase 3, before you call the vision service. Every detection in this workshop starts from the same known arm position, so `cam-1` always means the same thing when `transform_pose` reads it. + +Enforce the rule structurally, as a guard clause at the top of every pick attempt, rather than trusting yourself to remember it: + +```python +# Observe from home so the wrist-mounted camera frame is in a known position. +await home.set_position(2) + +objects = await vision.get_object_point_clouds("cam-1") +if not objects: + print("No objects detected") + return False + +# Largest object by point count. Use len(point_cloud), NOT .size. +obj = max(objects, key=lambda o: len(o.point_cloud)) +geometry = obj.geometries.geometries[0] +label = geometry.label +print(f"Detected: {label}") + +# The object pose is in the camera frame; the planner needs world frame. +obj_in_cam = PoseInFrame(reference_frame="cam-1", pose=geometry.center) +obj_in_world = await machine.transform_pose(obj_in_cam, "world") +``` + +`get_object_point_clouds` returns one entry per object `vision-segment` fused together, each carrying its own point cloud and geometry. A workspace with several blocks in view returns several entries, so you need a rule for which one to pick this cycle. `max(objects, key=lambda o: len(o.point_cloud))` picks the object with the most points, ordinarily the block closest to the camera or most fully in view. Use `len(o.point_cloud)` here, not a `.size` attribute; a point cloud in the Python SDK is a plain sequence of points, and `len()` is how you count them. + +Add a `print(obj_in_world.pose)` after the transform and run the script. Watch the x, y, and z values it prints as you move a block around the table. + +{{< checkpoint >}} +`obj_in_world.pose` prints coordinates that make physical sense: a `z` roughly at the table surface plus the block's height, and `x`/`y` values that land somewhere over the table rather than off in empty space or underneath it. If the numbers look physically wrong, the most common cause is a detection that was not taken from `home-pose`. Confirm the guard clause runs before every `get_object_point_clouds` call. +{{< /checkpoint >}} + +## Compute the approach and grasp poses + + + +Before you turn `obj_in_world.pose` into a place to move the gripper, it matters exactly what `motion.move` moves. Two motions that sound similar are not the same thing: + +- The CONTROL tab's arm card, and a direct `Arm` method, move the arm's own end frame: the flange at the end of the last joint. +- `motion.move(component_name="gripper-1", ...)` moves the `gripper-1` frame instead, the gripper's own tool center point (TCP), which sits further down the kinematic chain than the arm's end because the gripper is bolted on past it. + +Every offset you compute in this section is an offset from `obj_in_world.pose` to wherever you want the `gripper-1` frame to end up, not the arm's end. Keep that distinction in mind or the math below will not make sense. + +The workshop's `offset_pose` helper raises or lowers a pose in `z` while leaving `x`, `y`, and orientation untouched: + +```python +def offset_pose(pose: Pose, z_offset_mm: float) -> Pose: + """Raise or lower a pose in z while keeping x/y/orientation fixed.""" + return Pose( + x=pose.x, + y=pose.y, + z=pose.z + z_offset_mm, + o_x=pose.o_x, + o_y=pose.o_y, + o_z=pose.o_z, + theta=pose.theta, + ) +``` + +The approach pose is a standoff directly above the block, high enough that the gripper can descend onto it without first colliding with it sideways. That offset is worked for you: + +```python +approach_pose = offset_pose(obj_in_world.pose, APPROACH_MM) +``` + +`APPROACH_MM` is 100. Since every offset here is applied to `obj_in_world.pose`, which is the block's bounding-box center, this places the standoff 100 mm above the block center: enough clearance for the gripper to descend cleanly, with room to spare for a small pose error. + +Now compute the grasp pose yourself. What you actually want at the block is the gripper's fingers, not its TCP: the fingers have to close around the block. The `gripper-1` TCP frame sits one gripper-length above the fingertip contact point, so to put the fingers at the block center you place the TCP one gripper-length (`GRIPPER_LENGTH_MM`) above it, not at it. Remember that `motion.move` is already driving the gripper's own TCP, not the arm's end, so this is the only offset you add here; you do not account for the whole arm reach again. Work out the offset before reading on. + +The offset is `GRIPPER_LENGTH_MM`, the depth from the gripper's TCP down to its fingertip contact point: + +```python +grasp_pose = offset_pose(obj_in_world.pose, GRIPPER_LENGTH_MM) +``` + +`GRIPPER_LENGTH_MM` is 60. If you used `APPROACH_MM` here by mistake, the gripper stops well above the block instead of at it; if you used zero, you would drive the TCP itself to the block center, sinking the fingers a full gripper-length past the block instead of closing them around it. + +## Run the full pick loop + + + +With `approach_pose` and `grasp_pose` computed, assemble the full cycle: + +```python +await motion.move( + component_name="gripper-1", + destination=PoseInFrame(reference_frame="world", pose=approach_pose), +) +await gripper.open() +await motion.move( + component_name="gripper-1", + destination=PoseInFrame(reference_frame="world", pose=grasp_pose), +) +await gripper.grab() +await asyncio.sleep(0.3) # finger gripper settle +await travel.set_position(2) +await place_pose.set_position(2) +await gripper.open() +await home.set_position(2) +``` + +Notice the shape of this cycle: it picks with `motion.move` and places with the saved-pose switches from Phase 3. That split is deliberate, not an inconsistency. The pick target moves every cycle, so it needs the Cartesian precision and obstacle-aware planning that `motion.move` provides against a freshly computed world pose. The place target never moves: it is the same bin in the same spot every time, so a pre-measured, pre-verified saved pose is simpler and just as reliable as planning a fresh path there. Use the right tool for each half of the cycle rather than forcing one approach to do both jobs. + +Run the script and watch the sequence in stages: the approach move first, then the grasp move, then the full cycle through to a placed block. + +{{< checkpoint >}} +The approach move completes without a planning error, positioning the gripper above the block. If it fails here, open the **3D scene** tab during the next run and check whether `approach_pose` lands inside the table or safety-wall geometry from Phase 3; a block detected very close to a boundary can push the standoff outside the planner's reachable space. +{{< /checkpoint >}} + +{{< checkpoint >}} +The grasp move completes and **Grab** closes the fingers around the block, holding it through the lift into `travel-pose`. If the gripper closes on empty air, the block likely shifted between detection and grasp, or the grasp offset is off; revisit the offset math above. +{{< /checkpoint >}} + +{{< checkpoint >}} +The full loop runs end to end: approach, open, grasp, grab, travel, place, open, home, with the block landing in the bin. This is the same sequence you drove by hand in Phase 3 and by fixed poses in Phase 4, now driven by a pose your code computed from a live detection. +{{< /checkpoint >}} + +{{< alert title="Optional: force a straight descent" color="note" >}} +The plan above lets the planner choose its own path from the approach pose down to the grasp pose, which is sometimes a shallow arc rather than a straight vertical drop. If you want the gripper to descend in a straight line instead, pass a `LinearConstraint` on the grasp move: + +```python +from viam.proto.service.motion import Constraints, LinearConstraint + +linear_down = Constraints( + linear_constraint=[LinearConstraint(line_tolerance_mm=5.0)] +) + +await motion.move( + component_name="gripper-1", + destination=PoseInFrame(reference_frame="world", pose=grasp_pose), + constraints=linear_down, +) +``` + +This is a refinement, not a requirement for a working pick loop. Try the unconstrained version first, and reach for this only if an arcing descent causes the gripper to clip a block on the way down. +{{< /alert >}} + +## Debugging guide + + + +Work through these in order. The first one causes most of the rest. If you get stuck, compare your loop against the complete [`reference-solution.py`](https://github.com/viam-devrel/pick-and-place/blob/main/scripts/reference-solution.py) in the companion repo. + +- **Did you detect from `home-pose`?** This is the first thing to check for nearly every perception symptom below. If the `await home.set_position(2)` guard is missing before a `get_object_point_clouds` call, or if you added a second detection somewhere that skips it, every downstream pose is computed against the wrong camera position. +- **No objects detected.** Open the **CONTROL** tab and run the `vision-segment` card by hand while a block sits in view. If that also returns nothing, check the `shape-detector` card on its own: a detector that finds nothing means the block is out of frame, or lighting has changed enough to affect the shape detection. If `shape-detector` finds the block but `vision-segment` does not, check that a block is close enough and clearly separated from the table surface for the depth fusion step to segment it. +- **The pick point drifts from cycle to cycle, even for a block that has not moved.** This is almost always the wrist-camera rule again: some code path is detecting from a pose other than `home-pose`. Print `obj_in_world.pose` on every cycle and confirm the arm is fully settled at `home-pose` before each detection call. +- **Motion planning fails, or the target looks unreachable.** Open the **3D scene** tab during the failing move and look at where `approach_pose` or `grasp_pose` lands relative to the table and safety-wall geometry from Phase 3. A detected pose near a workspace boundary can place the standoff or the grasp point outside the region the planner is allowed to move through. If you skipped or under-measured the obstacle configuration in Phase 3, this is where it bites: geometry that does not match your physical setup makes the planner reject moves that are perfectly safe, or, worse, accept ones that are not. Revisit [Teach the planner about obstacles](/tutorials/pick-and-place/static-positions/#teach-the-planner-about-obstacles) and recheck your measurements before assuming the pose math is wrong. + +With a full perception-guided pick loop running end to end, you have every piece of the workshop's core loop working from your own laptop: detection, the frame transform, planned motion, and a reliable place. Phase 6 is optional, and picks up from here to package this same script as a module that runs on the robot directly, with no laptop connection required once it is deployed. + +{{< workshop-nav >}} diff --git a/docs/tutorials/pick-and-place/06-inline-module.md b/docs/tutorials/pick-and-place/06-inline-module.md new file mode 100644 index 0000000000..822b2a8fcc --- /dev/null +++ b/docs/tutorials/pick-and-place/06-inline-module.md @@ -0,0 +1,159 @@ +--- +title: "Phase 6: Inline module" +linkTitle: "6. Inline module" +type: "docs" +slug: "inline-module" +weight: 60 +description: "Optional: package your working script as an inline module that runs on the robot." +workshop: "pick-and-place" +toc_hide: true +phase: 6 +phase_total: 6 +time_estimate: "20 minutes" +prev: "/tutorials/pick-and-place/perception-guided-picking/" +languages: ["python"] +--- + +This phase is optional. Phase 5 already gave you a complete pick-and-place loop that runs from your own laptop: detection, the frame transform, planned motion, and a reliable place. This phase packages that same loop as a module so it runs on the robot directly, with no laptop connection required once it is deployed. + +{{< workshop-phases >}} + +## Why bother with a module + +A script you run from your laptop is a complete result. It is not a lesser version of a module, and nothing about Phase 5 was a placeholder waiting for this phase to finish it. Reach for a module only when one of these is true for your setup: + +- The cycle has to keep running after you close your laptop or walk away. +- The cycle has to restart on its own if it crashes or the robot reboots. +- You want to deploy an updated version to the robot without pushing code from a laptop by hand. +- You want the cycle to run on a schedule instead of a manual trigger. + +If none of those apply, stop here. You have already built the thing this workshop set out to teach. + +## Mostly packaging, plus one real change + +Set expectations before you start: this phase is not a rewrite. The detection, the frame transform, the pose math, and the motion calls are the pick-and-place logic from Phase 5, moved into a module's lifecycle methods with no change to what they do. + +One piece of that logic does genuinely change, and it is worth calling out up front so it does not surprise you partway through: how you reach `transform_pose`. In Phase 5, `transform_pose` was a method on the `machine` handle your script already held from `RobotClient.at_address`. A module does not automatically receive that same handle. The corrected pattern for reaching `transform_pose` from inside a module is in [The frame system from inside a module](#the-frame-system-from-inside-a-module) below. Everything else in this phase is packaging. + +## Tier the scope + +Two tiers, so you can stop at whichever one matches what you came here for: + +- **A minimal viable module.** Repackage the Phase 5 logic into a module, and add a `do_command` handler you trigger by hand to run one pick-and-place cycle. This is the core path for the rest of this phase. +- **Level 2: scheduled and autonomous operation.** Once the minimal module runs a cycle on command, wiring it to a timer or a fully autonomous loop is a small additional step, covered briefly at the end of this phase as a low-effort on-ramp, not a requirement. + +## Open the inline module editor + + + + +Before you start pasting code, know what to expect: saving an inline Python module triggers a cloud build, and that build takes about a minute. It is not instant the way rerunning a local script is, so give it that minute rather than assuming a save failed. + +Open your machine's **CONFIGURE** tab and add a new module. Choose to create a local module with an inline editor rather than pulling one from the registry, and select Python as the language. The Viam app opens a code editor in your browser with a generated module skeleton, so there is no local project setup to do first. + +Replace the skeleton's logic with your Phase 5 pick-and-place code: the connection to typed resource handles, the detection call, the frame transform, the pose math, and the motion calls, moved into the module's lifecycle methods as described below. Save the module. The Viam app packages your code and deploys it to the machine, and the **LOGS** tab shows the build progress the same way it showed module downloads 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 the script is the most common cause. +{{< /checkpoint >}} + +## Dependency injection + + + +A script builds its resource handles once, right after it connects, by calling `Arm.from_robot(machine, "arm-1")` and similar for each resource it needs. A module does not connect to itself, so it cannot call `from_robot` the same way. Instead, the module framework hands your module its dependencies. + +Two lifecycle methods carry this pattern: + +- `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, the same dependency ordering you already saw between `gripper-1` and `arm-1` in Phase 2. +- `reconfigure` receives the resolved dependencies as a mapping keyed by resource name, and this is where you build the typed handles your pick-and-place logic calls. + +A small illustrative sketch of that mapping, not a complete implementation: + +```python +from typing import cast +from viam.components.arm import Arm + + +def reconfigure(self, config, dependencies): + self.arm = cast(Arm, dependencies[Arm.get_resource_name("arm-1")]) +``` + +Keep the rest of your `reconfigure` close to this shape: look up each resource your Phase 5 script used, cast it to its typed client, and store it on `self` so your pick-and-place logic can call it later. + +## The frame system from inside a module + + + +This is the one genuine change from Phase 5, so read it carefully even if you skim the rest of this phase. + +No injected dependency gives you frame-system access the way `dependencies[Arm.get_resource_name("arm-1")]` gives you the arm; there is no such dependency to inject. `transform_pose` lives on the machine-management API, and a module reaches that API the same way a script does: through a `RobotClient`. The difference is that a module has to build that `RobotClient` itself, from credentials in its own environment, rather than receiving one as a dependency. + +Use this pattern exactly as written: + +```python +import os +from viam.robot.client import RobotClient + + +async def create_robot_client_from_module(): + opts = RobotClient.Options.with_api_key( + api_key=os.environ["VIAM_API_KEY"], + api_key_id=os.environ["VIAM_API_KEY_ID"], + ) + return await RobotClient.at_address(os.environ["VIAM_MACHINE_FQDN"], opts) + +# self.robot_client is initialized to None in __init__/reconfigure +# in logic, create once and reuse: +if not self.robot_client: + self.robot_client = await create_robot_client_from_module() +world_pose = await self.robot_client.transform_pose(obj_in_cam, "world") +``` + +Four rules go with this pattern: + +- Create exactly one `RobotClient` and reuse it. Do not open a new connection on every `do_command` call or every pick cycle; check `self.robot_client` first, the same way the snippet does, and only connect if it is not already set. +- Do not hardcode the API key, key ID, or machine address in your module's code. The operator sets `VIAM_API_KEY`, `VIAM_API_KEY_ID`, and `VIAM_MACHINE_FQDN` as environment variables in the module's configuration on the machine; they are not automatically injected the way component dependencies are. +- Close the connection on module shutdown by calling `await self.robot_client.close()`, the same cleanup discipline you would apply to any open connection. +- Everything else your module needs (the arm, the gripper, the vision service, the pose switches) still comes through the injected dependencies described above. `transform_pose` is the one exception, reached through this in-module `RobotClient` instead. + +See [Use the machine management API from a module](/build-modules/platform-apis/#use-the-machine-management-api-from-a-module) for the full reference on this pattern. + +{{< alert title="Same resource names, different retrieval" color="note" >}} +Do not let the change above convince you that everything is different inside a module. It is not. Compare how you got the arm handle in Phase 4 against how you get it inside the module: + +- Local script (Phases 4-5): `arm = Arm.from_robot(machine, "arm-1")`. +- Module: `arm = cast(Arm, dependencies[Arm.get_resource_name("arm-1")])`. + +The resource name, `"arm-1"`, is identical in both, and the same is true of `gripper-1`, `cam-1`, every pose switch, and `vision-segment`. Only the retrieval mechanism changes, from calling `from_robot` on a connected `machine` handle to looking the resource up in the `dependencies` mapping `reconfigure` received. `transform_pose` is the only resource access in this workshop that does not follow this pattern, for the reason described above. +{{< /alert >}} + +## do_command and a scheduled job + + + +With dependencies wired up and `transform_pose` reachable, assemble your Phase 5 pick-and-place logic into a single method on the module, the same detection, transform, pose math, and motion calls, unchanged. What differs is how that method gets triggered. + +For the minimal viable module, trigger it through `do_command`. `do_command` is a generic handler every module exposes for commands that do not fit the typed component or service APIs. A small illustrative sketch: + +```python +async def do_command(self, command, *, timeout=None, **kwargs): + if command.get("action") == "pick_cycle": + success = await self.run_pick_cycle() + return {"success": success} + return {} +``` + +From the **CONTROL** tab, find your module's test card and send a command such as `{"action": "pick_cycle"}` to run one full pick-and-place cycle on demand, the same cycle you watched run from your script in Phase 5, now running on the robot instead of your laptop. + +{{< checkpoint >}} +Sending a `do_command` trigger runs one complete pick-and-place cycle: detection, transform, approach, grasp, travel, and place, ending with a block in the bin. This is the same sequence from Phase 5's checkpoint, now triggered from the CONTROL tab instead of a script you ran locally. +{{< /checkpoint >}} + +That manual trigger is the whole minimal viable module. Level 2 is wiring the same trigger to something other than your own hand: an internal loop that sleeps between cycles and calls `run_pick_cycle` on a cadence, or an external scheduler that sends the same `do_command` on a timer. Either approach reuses everything you already built in this phase; only the thing that calls `run_pick_cycle` changes, from a person on the CONTROL tab to a clock. + +## Where you landed + +You now have the same pick-and-place loop running two ways: as a script you control from your own laptop, and as a module that keeps running on the robot without one. Phase 5 gave you the complete win. This phase gave you the option to deploy it. There is no next phase; the workshop ends here. + +{{< workshop-nav >}} diff --git a/docs/tutorials/pick-and-place/_index.md b/docs/tutorials/pick-and-place/_index.md new file mode 100644 index 0000000000..3cf5845a6c --- /dev/null +++ b/docs/tutorials/pick-and-place/_index.md @@ -0,0 +1,92 @@ +--- +title: "Vision-Guided Pick-and-Place with the xArm6" +linkTitle: "Pick-and-Place Workshop" +type: "docs" +weight: 50 +description: "Build a vision-guided robot that detects blocks by shape and sorts them into a bin with motion planning, from static arm poses to a Python perception loop." +authors: [] +level: "Intermediate" +languages: ["python"] +viamresources: + ["arm", "gripper", "camera", "vision", "motion", "frame_system", "switch"] +platformarea: ["mobility", "core"] +tags: ["tutorial", "workshop", "arm", "vision", "motion"] +workshop: "pick-and-place" +workshop_overview: true +time_estimate: "2 hours" +hardware: + - "uFactory xArm6" + - "Intel RealSense D435" + - "uFactory finger gripper" + - "System76 Meerkat" +companion_repo: "https://github.com/viam-devrel/pick-and-place" +no_list: true +--- + +In this workshop you build a vision-guided robot that finds blocks by shape and picks each one into a bin. A `shape-detector` vision service locates blocks in camera space and feeds a `vision-segment` service (model `detections-to-segments`) that turns each detection into a point cloud segment the motion planner can grasp. + +The workshop is structured as six sequential phases, each ending with a working system state you can verify before moving on. The workshop has two milestones: by the end of Phase 4 you drive the robot from your own code through a static, pre-planned sequence (milestone one, a real and bankable win), and by the end of Phase 5 you close the loop with live perception so the robot detects, picks, and places blocks on its own (milestone two). At minimum, aim to complete the Phase 4 script. + +## What you'll build + + + +You will configure an xArm6 robotic arm fitted with a finger gripper and a wrist-mounted Intel RealSense depth camera. A shape-detection vision service finds blocks in camera space, and the Viam motion service plans and executes collision-free picks that place each block in the bin. By the end of Phase 5 you have a Python script you run from your laptop that drives the full detect-pick-place loop. + + + +## Hardware + +- **uFactory xArm6**: the six-axis robotic arm that picks and places the blocks. +- **Intel RealSense D435**: the wrist-mounted depth camera that detects block positions by shape. +- **uFactory finger gripper**: the end-effector that grasps the blocks. +- **System76 Meerkat**: the on-robot mini-PC that runs the Viam machine server. + +## Phases + +Phases 1 through 5 are the core workshop. Phase 6 is optional. + +1. **[Platform mental model](/tutorials/pick-and-place/platform-mental-model/)** (~15 min) +2. **[Configure resources and explore the app](/tutorials/pick-and-place/configure-resources/)** (~20 min) +3. **[Static positions and obstacles](/tutorials/pick-and-place/static-positions/)** (~20 min) +4. **[Control the robot from Python](/tutorials/pick-and-place/control-the-robot-from-python/)** (~15 min, milestone one) +5. **[Perception-guided picking](/tutorials/pick-and-place/perception-guided-picking/)** (~22 min, milestone two) +6. **[Inline module](/tutorials/pick-and-place/inline-module/)** (~20 min, optional) + +## Prerequisites + +This is a self-serve workshop, so confirm each of the following before you start: + +- **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/). +- **The Viam Python SDK.** The companion `scripts/` project already declares `viam-sdk`, so `uv run` installs it for you in Phase 4. See the [Python SDK docs](https://python.viam.dev/) for reference. Pip works too if you prefer it. +- **A working terminal** on the machine you will run the Phase 4 and Phase 5 scripts from, typically your laptop rather than the robot's Meerkat. +- **A Viam account with an accessible machine.** Log in at [app.viam.com](https://app.viam.com), open your machine, and confirm the green **Live** indicator before you begin. + +### Validate your environment + + + +Before starting Phase 4, 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. + +### Where to start + + + +- **Physical hardware ready:** start at [Phase 1](/tutorials/pick-and-place/platform-mental-model/). +- **Provisioning your own hardware:** complete the hardware setup guide first (forthcoming), then return here for [Phase 1](/tutorials/pick-and-place/platform-mental-model/). + +Only the physical hardware, viam-agent, viam-server, and the frame calibration (the camera's mounting offset on the arm) may be pre-provisioned for you or come from the hardware setup guide. Configuring the arm, gripper, camera, and the vision and motion services is always your hands-on work in this workshop, starting in Phase 2. + +## Companion code + +All supporting files for this workshop live in the [viam-devrel/pick-and-place](https://github.com/viam-devrel/pick-and-place) repository. + +- `config/` holds a machine config fragment and an obstacles template. Use them to check your work after you configure resources by hand, not as something to import wholesale. +- `scripts/` holds the starter script for Phase 4 and the reference solution for Phase 5. diff --git a/docs/tutorials/pick-and-place/_phase-template.md b/docs/tutorials/pick-and-place/_phase-template.md new file mode 100644 index 0000000000..78471ca116 --- /dev/null +++ b/docs/tutorials/pick-and-place/_phase-template.md @@ -0,0 +1,32 @@ +--- +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: "pick-and-place" +toc_hide: true +phase: 0 +phase_total: 6 +time_estimate: "NN minutes" +prev: "/tutorials/pick-and-place/previous-slug/" +next: "/tutorials/pick-and-place/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 (it keeps only the template itself from rendering) +--- + + + +{{< workshop-phases >}} + +## Section heading + + + +{{< checkpoint >}} +What the learner runs, and the expected result that means "continue". +{{< /checkpoint >}} + +{{< workshop-nav >}} diff --git a/layouts/docs/tutorials-all.html b/layouts/docs/tutorials-all.html new file mode 100644 index 0000000000..f1a7da4f8a --- /dev/null +++ b/layouts/docs/tutorials-all.html @@ -0,0 +1,239 @@ + + + + {{ partial "head.html" . }} + + + {{- if hugo.IsProduction -}}{{- if .Site.Config.Services.GoogleAnalytics.ID + -}} + + + + {{- end -}}{{- end -}} +
{{ partial "navbar.html" . }}
+
+
+
+ {{/* No left sidebar on the archive: the filter UI is the navigation, + and a sidebar here would only repeat the page title. */}} +
+ {{ if not .Site.Params.ui.breadcrumb_disable }}{{ partial + "breadcrumb.html" . }}{{ end }} +
+

{{ .Title }}

+

Browse every Viam tutorial. Filter by language, component, service, level, or content type.

+ {{ if not hugo.IsProduction }} + {{ $pctx := .Site }} + + {{- $pages := (where (where $pctx.RegularPages ".Section" "tutorials") "Kind" "page") -}} + {{/* Full-catalog archive fallback for local/staging builds where Typesense is unavailable. Exclude sequential-workshop phase pages; they surface through their workshop overview on the main landing. */}} + {{- $phasePages := where $pages "Params.workshop" "!=" nil -}} + {{- $pages = complement $phasePages $pages -}} +
+
+ {{ range sort $pages ".Weight"}} {{ partial "card.html" (dict "link" (.Page.File.Path) "class" "" "customTitle" "" "customDescription" "" "customCanonicalLink" (.Page.Params.canonical) ) }} {{ end }} +
+
+ {{ else }} +
+
+ + + + + +
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+ {{ end }} + + +
+

+ Find more examples of how Viam is being used in the world by + reviewing + Customer Stories + or + blog posts. +

+ + + + + {{ partial "footer.html" . "tutorials" }} +
+
+
+
+ + {{ partial "scripts.html" . }} + + + + + + {{ $jsTutorials := resources.Get "js/tutorials.js" }} {{ if (eq (substr + .Site.BaseURL -1) "/" ) }} {{- $opts := dict "params" (dict "baseURL" + (substr .Site.BaseURL 0 -1 )) -}} {{- $jsTutorials = $jsTutorials | js.Build + $opts -}} {{ $jsTutorials := $jsTutorials | minify }} + + {{ else }} {{- $opts := dict "params" (dict "baseURL" .Site.BaseURL) -}} {{- + $jsTutorials = $jsTutorials | js.Build $opts -}} {{ $jsTutorials := + $jsTutorials | minify }} + + {{ end }} + + diff --git a/layouts/docs/tutorials.html b/layouts/docs/tutorials.html index 7477b2717f..9e1e51f438 100644 --- a/layouts/docs/tutorials.html +++ b/layouts/docs/tutorials.html @@ -23,10 +23,9 @@
- -
+ {{/* No left sidebar on the tutorials landing: it would only repeat the + page title. The full browsable list lives at /tutorials/all/. */}} +
-

{{ .Title }}

-

- Tutorials provide instructions to build small projects while - teaching you new skills. -

-

- Select a language, component, or service that you're interested - in and browse relevant tutorials. If you don't know where to - start, start by selecting the beginner level for introductory - tutorials. -

- {{ if not hugo.IsProduction }} - - {{ $pctx := .Site }} {{- $pages := (where (where - $pctx.RegularPages ".Section" "tutorials") "Kind" "page") -}} -
+ + + {{ $pctx := .Site }} + {{/* Featured set: opt-in `featured: true` singles OR workshop overviews. */}} + {{- $featured := where (where $pctx.RegularPages ".Section" "tutorials") "Params.featured" true -}} + {{- $workshops := where (where $pctx.Pages ".Section" "tutorials") "Params.workshop_overview" true -}} + {{- $cards := sort ($workshops | union $featured) ".Weight" -}} + + {{ with $cards }} + {{/* First featured item leads as a hero; any others flow into a grid below. */}} + {{ partial "tutorial-hero.html" (dict "page" (index . 0)) }} + {{ with (after 1 .) }} + - {{ else }} -
-
- - - - - -
-
-
-
-
-
-
-
-
-
- -
-
- -
{{ end }} - - + {{ end }} + +

+ Browse all tutorials +

Find more examples of how Viam is being used in the world by @@ -232,28 +112,5 @@

Javascript

{{ partial "scripts.html" . }} - - - - - - {{ $jsTutorials := resources.Get "js/tutorials.js" }} {{ if (eq (substr - .Site.BaseURL -1) "/" ) }} {{- $opts := dict "params" (dict "baseURL" - (substr .Site.BaseURL 0 -1 )) -}} {{- $jsTutorials = $jsTutorials | js.Build - $opts -}} {{ $jsTutorials := $jsTutorials | minify }} - - {{ else }} {{- $opts := dict "params" (dict "baseURL" .Site.BaseURL) -}} {{- - $jsTutorials = $jsTutorials | js.Build $opts -}} {{ $jsTutorials := - $jsTutorials | minify }} - - {{ end }} diff --git a/layouts/partials/sidebar-workshop.html b/layouts/partials/sidebar-workshop.html new file mode 100644 index 0000000000..c527da963d --- /dev/null +++ b/layouts/partials/sidebar-workshop.html @@ -0,0 +1,26 @@ +{{- $cur := . -}} +{{- $section := .CurrentSection -}} +{{- $phases := $section.RegularPages.ByWeight -}} +
+ +
diff --git a/layouts/partials/sidebar.html b/layouts/partials/sidebar.html new file mode 100644 index 0000000000..d2b0286d2a --- /dev/null +++ b/layouts/partials/sidebar.html @@ -0,0 +1,29 @@ +{{ if .Params.workshop -}} + {{ partial "sidebar-workshop.html" . }} +{{ else -}} + {{/* Verbatim copy of themes/docsy/layouts/partials/sidebar.html (Docsy v0.6.0). + Re-diff against the theme file whenever the Docsy module is bumped. */}} + {{/* The "active" toggle here may delay rendering, so we only cache this side bar menu for bigger sites. +*/}}{{ $sidebarCacheLimit := cond (isset .Site.Params.ui "sidebar_cache_limit") .Site.Params.ui.sidebar_cache_limit 2000 -}} +{{ $shouldCache := ge (len .Site.Pages) $sidebarCacheLimit -}} +{{ $sidebarCacheTypeRoot := cond (isset .Site.Params.ui "sidebar_cache_type_root") .Site.Params.ui.sidebar_cache_type_root false -}} +{{ if $shouldCache -}} + {{ $mid := printf "m-%s" (.RelPermalink | anchorize) }} + + {{ partialCached "sidebar-tree.html" . .FirstSection.RelPermalink }} +{{ else -}} + {{ partial "sidebar-tree.html" . }} +{{- end }} +{{- end }} diff --git a/layouts/partials/tutorial-card.html b/layouts/partials/tutorial-card.html new file mode 100644 index 0000000000..4c0fe3df30 --- /dev/null +++ b/layouts/partials/tutorial-card.html @@ -0,0 +1,18 @@ +{{- $p := .page -}} +{{- $isWorkshop := $p.Params.workshop_overview -}} +{{- $phaseCount := cond $isWorkshop (len $p.RegularPages) 0 -}} + diff --git a/layouts/partials/tutorial-hero.html b/layouts/partials/tutorial-hero.html new file mode 100644 index 0000000000..156247ddc1 --- /dev/null +++ b/layouts/partials/tutorial-hero.html @@ -0,0 +1,67 @@ +{{- /* Featured hero for the flagship tutorial/workshop on the tutorials +landing. For workshops, the right panel lists phases as a clickable numbered +stepper. */ -}} {{- $p := .page -}} {{- $isWorkshop := +$p.Params.workshop_overview -}} {{- $phases := cond $isWorkshop +$p.RegularPages.ByWeight slice -}} {{- $phaseCount := len $phases -}} {{- $start +:= cond (gt $phaseCount 0) (index $phases 0).RelPermalink $p.RelPermalink -}} +
+
+ {{ if $isWorkshop }}Featured workshop{{ else }}Featured tutorial{{ end + }} +

+ {{ $p.LinkTitle }} +

+ {{- with $p.Description }} +

{{ . }}

+ {{ end }} +
+ {{- with $p.Params.level }} +
+
Level
+
{{ . }}
+
+ {{ end }} {{- with $p.Params.time_estimate }} +
+
Time
+
{{ . }}
+
+ {{ end }} {{- if gt $phaseCount 0 }} +
+
Phases
+
{{ $phaseCount }}
+
+ {{ end }} {{- with $p.Params.languages }} +
+
Language
+
{{ delimit . ", " }}
+
+ {{ end }} +
+ {{ if $isWorkshop }}Start the workshop{{ else }}Start tutorial{{ end }} + +
+ {{- if gt $phaseCount 0 }} + + {{- end }} +
diff --git a/layouts/shortcodes/checkpoint.html b/layouts/shortcodes/checkpoint.html new file mode 100644 index 0000000000..cd43128573 --- /dev/null +++ b/layouts/shortcodes/checkpoint.html @@ -0,0 +1,20 @@ +{{/* checkpoint.html + A "verify before continuing" callout for sequential workshop tutorials. + CSS is injected once per page via a Scratch guard (mirrors notice.html). */}} +{{- $title := .Get "title" | default "Checkpoint" -}} +{{- $inner := .Inner | .Page.RenderString -}} +{{- if not ($.Page.Scratch.Get "checkpointcss") -}} + +{{- $.Page.Scratch.Set "checkpointcss" 1 -}} +{{- end -}} +
+

{{ $title }}

+
{{ $inner }}
+
diff --git a/layouts/shortcodes/workshop-nav.html b/layouts/shortcodes/workshop-nav.html new file mode 100644 index 0000000000..b97bd3a291 --- /dev/null +++ b/layouts/shortcodes/workshop-nav.html @@ -0,0 +1,28 @@ +{{/* workshop-nav.html + Progress indicator + prev/next links for sequential workshop phases. + Reads page frontmatter: phase, phase_total, prev, next. */}} +{{- $phase := .Page.Params.phase -}} +{{- $total := .Page.Params.phase_total -}} +{{- $prev := .Page.Params.prev -}} +{{- $next := .Page.Params.next -}} +{{- if not ($.Page.Scratch.Get "workshopnavcss") -}} + +{{- $.Page.Scratch.Set "workshopnavcss" 1 -}} +{{- end -}} + diff --git a/layouts/shortcodes/workshop-phases.html b/layouts/shortcodes/workshop-phases.html new file mode 100644 index 0000000000..3502911741 --- /dev/null +++ b/layouts/shortcodes/workshop-phases.html @@ -0,0 +1,40 @@ +{{/* workshop-phases.html + In-page phase list for a sequential workshop, with the current page + highlighted. Provides cross-phase navigation because the tutorials sidebar + is a minimal, cached skeleton and does not render a section tree. + Derives the phases from the current section's regular pages, ordered by + weight. CSS is injected once per page via a Scratch guard (like notice.html). */}} +{{- $cur := .Page -}} +{{- $section := .Page.CurrentSection -}} +{{- $phases := $section.RegularPages.ByWeight -}} +{{- if not ($.Page.Scratch.Get "workshopphasescss") -}} + +{{- $.Page.Scratch.Set "workshopphasescss" 1 -}} +{{- end -}} +