Monitor a running plan

MoveOnMap and MoveOnGlobe return immediately with an ExecutionID and run the actual motion in the background. Your code has to poll that execution to learn when it completes, fails, or stops. This page gives the polling pattern in Go and Python. For the built-in Move, which blocks until the motion finishes, the pattern collapses to a try/except: covered at the end.

Before you start

  • MoveOnMap and MoveOnGlobe are not implemented by the built-in motion service. You need the navigation service (which calls them internally) or a motion-service module that provides them. The built-in motion service returns “not supported” for both.
  • For background on the methods and states, see Plan monitoring.

The polling pattern

  1. Start a non-blocking motion. Save the returned ExecutionID.
  2. Poll GetPlan on an interval, passing the component name and the ExecutionID.
  3. Inspect the returned current_plan_with_status.status.state.
  4. Exit the loop on any terminal state: SUCCEEDED, STOPPED, or FAILED.
import asyncio
from viam.services.motion import MotionClient
from viam.proto.common import GeoPoint
from viam.proto.service.motion import PlanState

motion_service = MotionClient.from_robot(machine, "my-motion-service")

# Start a non-blocking motion.
destination = GeoPoint(latitude=40.6640, longitude=-73.9387)
execution_id = await motion_service.move_on_globe(
    component_name="my-base",
    destination=destination,
    movement_sensor_name="my-gps",
)

# Poll until the plan reaches a terminal state.
terminal = {
    PlanState.PLAN_STATE_SUCCEEDED,
    PlanState.PLAN_STATE_STOPPED,
    PlanState.PLAN_STATE_FAILED,
}

while True:
    response = await motion_service.get_plan(
        component_name="my-base",
        execution_id=execution_id,
    )
    status = response.current_plan_with_status.status
    print(f"state={status.state} reason={status.reason or '-'}")
    if status.state in terminal:
        break
    await asyncio.sleep(1)

if status.state == PlanState.PLAN_STATE_SUCCEEDED:
    print("Arrived at destination")
else:
    print(f"Motion did not complete: {status.state}")

Go callers can either poll directly, or use the PollHistoryUntilSuccessOrError helper that wraps the loop and returns on the first terminal state.

import (
    "context"
    "time"

    geo "github.com/kellydunn/golang-geo"
    "go.viam.com/rdk/services/motion"
)

// Start a non-blocking motion.
executionID, err := motionService.MoveOnGlobe(ctx, motion.MoveOnGlobeReq{
    ComponentName:      "my-base",
    Destination:        geo.NewPoint(40.6640, -73.9387),
    MovementSensorName: "my-gps",
})
if err != nil {
    logger.Fatal(err)
}

// Block until the plan reaches a terminal state.
err = motion.PollHistoryUntilSuccessOrError(
    ctx,
    motionService,
    time.Second,
    motion.PlanHistoryReq{
        ComponentName: "my-base",
        ExecutionID:   executionID,
    },
)
if err != nil {
    logger.Warnf("motion did not complete: %v", err)
} else {
    logger.Info("Arrived at destination")
}

Stop a running plan

Call StopPlan with the component name to cancel an in-progress motion. The plan’s final state becomes STOPPED.

await motion_service.stop_plan(component_name="my-base")
err = motionService.StopPlan(ctx, motion.StopPlanReq{ComponentName: "my-base"})

Replanning and ExecutionID

If the motion service replans during execution (for example, after the robot deviates from the path), it creates a new Plan but keeps the same ExecutionID. This means:

  • GetPlan with the ExecutionID returns the latest plan in current_plan_with_status and earlier plans in replan_history.
  • Polling the execution_id continues to work across replans.
  • The plan state transitions you observe reflect the most recent plan.

List plans across components

ListPlanStatuses returns every plan currently running or recently completed on the machine, keyed by component name. Two common uses: a dashboard that shows all in-progress motion at once, and a shutdown routine that stops every component without having to track the ExecutionID of each one.

statuses = await motion_service.list_plan_statuses()
for s in statuses:
    print(f"{s.component_name}: {s.status.state}")
statuses, err := motionService.ListPlanStatuses(ctx, motion.ListPlanStatusesReq{})
if err != nil {
    logger.Fatal(err)
}
for _, s := range statuses {
    logger.Infof("%s: %v", s.ComponentName, s.Status.State)
}

ListPlanStatuses has a 24-hour retention window. Plans from executions that reached a terminal state more than 24 hours ago are dropped from the list, and any restart of viam-server clears the history regardless of age. If you need longer-lived plan audit, log the ExecutionID and plan state to your own store at each poll.

If you are using the builtin Move instead

The builtin motion service’s Move call blocks until the motion completes or fails. Polling is not needed; handle the return value directly.

try:
    await motion_service.move(
        component_name="my-arm",
        destination=destination,
    )
    print("Motion succeeded")
except Exception as e:
    print(f"Motion failed: {e}")
_, err = motionService.Move(ctx, motion.MoveReq{
    ComponentName: "my-arm",
    Destination:   destination,
})
if err != nil {
    logger.Warnf("motion failed: %v", err)
} else {
    logger.Info("motion succeeded")
}

What’s next