The new External Control software is in beta. Reach out to [email protected] for information about joining.
External control lets an outside program drive the arm directly. You open an external-control session over HTTP, stream setpoints to the arm for as long as that session lasts, and read the arm’s live state back the whole time.
There are three ways to speak it. All three open the same kind of session and are governed by the same safety rules:
RobotStream
client. No ROS2 required.The ROS2 bridge and the Python SDK are both thin adapters over the
UDP protocol, so everything in the Control and Safety Semantics section
applies whichever one you choose. The setpoints are the same on all
three, with one exception: only the Python SDK and direct UDP can attach
an optional twist to a pose setpoint, because
geometry_msgs/PoseStamped, the ROS2 pose message, has no
velocity field.
WARNING – Your Code Moves the Arm
External control streams motion commands straight from your program to the joints, with no routine, planner, or operator in between. Guard the cell and keep people clear while you are developing against it. External control is Beta and access is granted case by case, so contact [email protected] to have it enabled.
The ROS2 Setup and ROS2 Python Scripting sections earlier in this
chapter describe the ROS2 Realtime API: topics under
/<BOT_ID>/ro1/hardware/..., switched on and off with
sdk.ros.control.update_ros_control_state(), which wraps
GET and POST /api/v1/movement/ros/state.
External control is a newer, session-based interface. Its topics live
under /<robotId>/external/..., it is switched on and
off with POST /api/v1/external-control/start and
POST /api/v1/external-control/stop, and it adds two non-ROS
transports alongside the ROS2 one.
Which interface your robot exposes depends on the software it is
running. On a robot running the external-control stack, the older
movement/ros/state endpoint returns 500 with
ControlSourceManager is enabled. That response is the
signal to use external-control/start instead.
The topics listed in the Command Topics and State Topics sections
below are the complete /external/* set. The Jacobian and
wrist IMU topics described in the Topic Reference section belong to the
/ro1/hardware/... tree, not to this one.
POST /api/v1/external-control/start with the stream
type you want, for example joints for live joint streaming.
The robot takes control and, for a ROS2 session, brings up the ROS2
bridge./<robotId>/external/<modality> and read state
from /<robotId>/external/robot_*.POST /api/v1/external-control/stop when you are done.
The robot releases control and stops safely.Control is held for the lifetime of the session. There is no
per-message engage handshake, and control is held until you
call stop or the session ends on its own. If you stop
publishing, what happens depends on the stream type. On a robot with the
Streaming controller capability, described in the Prerequisites
section, a joints or pose session decelerates
the arm to rest after the command timeout, 500 ms by default, and the
session ends. A jointTrajectory session holds position.
A session that ends on its own leaves the robot in a failure state,
not idle, so you have to recover the robot before you can
start again. The What Ends a Session and How to Recover
section covers every case.
Every session has the same shape no matter how you drive it. One HTTP call opens the session and takes the control lease, you stream setpoints over one of three interchangeable transports, and a second HTTP call ends it.
Your machine Standard Bots control box
+-----------------+ HTTP start/stop +----------------------------+
| any HTTP client | ----------------> | api (port 3000) |
+-----------------+ | opens the session and |
| takes the control lease |
+-------------+--------------+
|
--- then stream setpoints over ONE of these --------+---------------
|
+-----------------+ +-------------v--------------+
| ros2 pub / sub | <--ROS2 topics--> | |
+-----------------+ | |
+-----------------+ | External Control service |
| Python SDK | <--UDP :30100---> | |
+-----------------+ | |
+-----------------+ | |
| your own client | <--UDP :30100---> | |
+-----------------+ +-------------+--------------+
|
v
motion control -> arm
These are alternatives, not layers you stack, so pick one per session. All three reach the same external control service, and the ROS2 and Python SDK front ends are thin clients over the same protobuf-over-UDP protocol that the third one speaks directly.
| Transport | Reach for it when |
|---|---|
| ROS2 topics | You already have a ROS2 stack to publish from. See the Command Topics and State Topics sections. |
| Python SDK | You want live control from Python, without ROS2. See the Streaming from Python without ROS2 section. |
| Direct UDP | You are working in C++ or another language. See the Direct UDP Interface section. |
The ROS2 front end is brought up per session by a start
with controlledBy set to rosHumble or
rosJazzy, and it is torn down by stop. The UDP
listener serves the other two transports. Either way, only one external
client can hold the writer slot at a time.
External control depends on robot-side capabilities that Standard
Bots configures, so contact [email protected] if your robot does
not have them turned on. The first three are required for any session.
The last two vary from robot to robot and change how the same API
behaves. start succeeds either way, so confirm with
Standard Bots which of them your robot has before you build on them.
Their italicized names are used throughout the External Control sections
that follow.
| Capability | What it enables |
|---|---|
| Control arbitration | Lets an external client hold the control lease that permits commanding the arm. |
| External-control sessions | Enables the session that start and stop
create. |
| ROS2 bridge | Publishes the /external/* topics and relays them to the
arm. Only needed for ROS2. |
| Streaming controller | Routes joints and pose streams through the
tracked streaming controller. servoConfig tuning, the
command watchdog, and the velocity and twist fields work
only with it. Without it, they are accepted and ignored. |
| Trajectory execution | Executes multi-point trajectories: the .../trajectory
and .../cancel topics, the UDP
trajectory_command and cancel_trajectory
messages, and the SDK’s send_trajectory() and
send_cancel(). Where it is off, they are accepted and
ignored. |
NOTE – Streaming Controller or Trajectory Execution, Not Both
A robot has either Streaming controller or Trajectory
execution, never both. start succeeds either way, so
ask Standard Bots which one your robot has.
The bridge supports ROS2 Humble and Jazzy. The
controlledBy field of start, set to
rosHumble or rosJazzy, selects which one a
session launches, so match it to the ROS2 distribution on your
machine.
Whichever transport you drive the arm with, you need:
start and stop call takes one. This is the
same per-robot Authorization Token used everywhere else in this chapter,
copied from Configure Developer API in the robot
UI.If you are using ROS2, you also need:
ROS2 Humble or Jazzy with the standard message
packages geometry_msgs, sensor_msgs,
trajectory_msgs, and std_msgs. All of them
come with a normal ROS2 install.
CycloneDDS as the RMW implementation, matching the robot:
export RMW_IMPLEMENTATION=rmw_cyclonedds_cppThe robot’s external ROS domain. The bridge
publishes the /external/* topics on the robot’s
external DDS domain, which defaults to 1. Your
robot may be configured differently. Match it:
export ROS_DOMAIN_ID=1A route to the robot’s wired customer Ethernet port,
which has to be up with an IP address. The external DDS domain
is bound to that port only. The robot’s Wi-Fi is not on it unless an
operator adds it in the robot’s ROS2 network settings. The bridge binds
to the wired port too, so if the port has no address, a ROS2 session
ends within a couple of seconds of start.
DDS discovery on that subnet. Multicast is used for participant discovery only, and unicast for everything else, so your machine has to be able to reach the robot’s wired IP address directly.
WARNING – Domain, RMW, or Network Mismatch Is the Most Common Failure
A mismatch is the single most common reason topics never appear. If
ros2 topic list shows nothing after start,
check ROS_DOMAIN_ID, RMW_IMPLEMENTATION, and
that you are on the robot’s wired subnet. If
/external_control_ros_bridge does appear in
ros2 node list but no /external/* topics do,
your machine can reach only one of the addresses the robot advertises.
Move to the wired subnet, or have the operator restrict the robot’s ROS2
interfaces to the one you can reach.
If you are using the Python SDK, you also need:
The standardbots package, installed
with pip install standardbots. It contains both the REST
client and the realtime client, and it needs Python 3.9 or
later, or 3.10 or later for the
servol_overshoot.py example. The realtime client is the
standardbots.realtime subpackage:
from standardbots import StandardBotsRobot, models
from standardbots.realtime import (
RobotStream, JointState, CartesianPose, CartesianTwist,
Point, Quaternion, Vector3,
)A package version that carries the API described
in the Streaming from Python without ROS2 section. Versions are numbered
2.<YYYYMMDD>.<n>, after the robot release they
ship with. twist, velocities, and per-joint
limits need the release that carries them, so ask Standard Bots which
version that is and pin it.
The realtime client speaks UDP directly, so none of the ROS2 or DDS setup above applies.
If you are writing your own client, you also need protoc
and the external_control.proto contract, and UDP port
30100 on the control box has to be reachable. The
contract is append-only, so fields that a robot does not know about are
simply absent. Again, no ROS2 or DDS is involved.
This walkthrough uses streamType: "joints", which is
live joint streaming. Pose streaming and jointTrajectory
are covered in the Command Topics section.
The robot has to be idle, meaning no routine is
running and it is not in Failure, and its brakes
disengaged before you start a session. You can do this from the
robot UI, with the Python SDK, or entirely over the same HTTP API. Every
call below takes the same
Authorization: Bearer <token> header as
start.
First, get the robotId. It namespaces every
external-control ROS2 topic in this section, and it is what
<robotId> stands for throughout:
curl http://<robot-ip>:3000/api/v1/identity/bot_identity \
-H "Authorization: Bearer <token>"The response identifies the robot:
{
"robotId": "...",
"alias": "...",
"robotOperationMode": "...",
"applicationUrl": "..."
}If ros2 topic list instead shows topics under
/local/external/*, the robot has no identity configured, so
contact Standard Bots.
Next, check that the robot is idle:
curl http://<robot-ip>:3000/api/v1/recovery/status \
-H "Authorization: Bearer <token>"
# => { "status": "Idle", "failed": false }status is the state that start tests.
Idle means the robot is ready, and anything else is refused
with 409 robot_not_idle. RoutineRunning means
a routine or another session is running, so stop it first.
Failure means a previous session ended on a fault, so
recover the robot, as described in the Recovering from a Failure
section, and then start.
Then check the brakes, and disengage them if needed:
curl http://<robot-ip>:3000/api/v1/movement/brakes \
-H "Authorization: Bearer <token>"
# => { "state": "engaged" } or { "state": "disengaged" }
curl -X POST http://<robot-ip>:3000/api/v1/movement/brakes \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"state":"disengaged"}'From Python, sdk.movement.brakes.get_brakes_state() and
sdk.movement.brakes.unbrake() do the same thing.
Re-check the brakes after an E-stop or a power loss.
GET /api/v1/status/health and
GET /api/v1/status/control-mode are the general health and
mode checks if you need to diagnose before starting.
curl -X POST http://<robot-ip>:3000/api/v1/external-control/start \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"controlledBy":"rosJazzy","streamType":"joints"}'The response carries the identifier of the session that was created:
{ "routineId": "abc123..." }The request body takes these fields:
controlledBy is required. It is rosHumble
or rosJazzy, matching the ROS2 distribution on your
machine. Use api instead for the Python SDK or for your own
UDP client.streamType is required. It is pose,
joints, or jointTrajectory, and it selects
which command topic is live for the session, as described in the Command
Topics section.servoConfig is optional tuning for joints
and pose sessions, and it is ignored on
jointTrajectory. Every field inside it is optional, so send
only what you want to change:curl -X POST http://<robot-ip>:3000/api/v1/external-control/start \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"controlledBy": "rosJazzy",
"streamType": "joints",
"servoConfig": {
"tauMs": 20,
"commandTimeoutMs": 500,
"rampInDurationMs": 300,
"positionSmoothingTimeConstantMs": 20
}
}'Full semantics, defaults, and ranges are in the Tuning the Stream with servoConfig section.
WARNING – start Returns Before the Session Is
Live
start returns before the control lease is taken and, for
a ROS2 session, before the bridge is up, which takes a few seconds.
Setpoints sent before then are dropped, never queued. On ROS2, wait
until the /external/* topics appear and
.../robot_joints is flowing, as shown in Step 3. On UDP or
the Python SDK, poll GET /api/v1/recovery/status a second
after start. RoutineRunning means the session
is up, and Failure means it died on the way up, so recover
the robot and then start again.
With ROS_DOMAIN_ID and RMW_IMPLEMENTATION
set to match the robot:
ros2 topic list | grep external
# /<robotId>/external/trajectory
# /<robotId>/external/pose
# /<robotId>/external/joints
# /<robotId>/external/gripper
# /<robotId>/external/cancel
# /<robotId>/external/robot_joints
# /<robotId>/external/robot_pose
# /<robotId>/external/robot_gripper
# /<robotId>/external/status<robotId> is your robot’s identity, which is also
the topic namespace. If you do not know it, ros2 topic list
reveals it.
The bridge node is /external_control_ros_bridge. If the
node is listed but no /external/* topics are, see the
mismatch warning in the On Your Machine section. If the node never
appears and the session is gone within seconds of start,
the robot could not launch the bridge, as described in the
Troubleshooting section.
Stream joint setpoints as positions in radians, with the joints
ordered joint0 through joint5. Note
--rate rather than --once: joint streaming is
a continuous stream of setpoints, and the arm tracks the latest
one.
ros2 topic pub --rate 100 \
/<robotId>/external/joints sensor_msgs/msg/JointState "{
name: ['joint0','joint1','joint2','joint3','joint4','joint5'],
position: [0.0, -1.57, 1.57, 0.0, 1.57, 0.0]
}"Leave this command running, and run Steps 5 and 6 in a second
terminal. Killing the publisher stalls the stream, and the session ends
after commandTimeoutMs, 500 ms by default. The robot is
then in Failure, so stop in Step 6 returns
stopped: false, and you have to recover the robot, as
described in the Recovering from a Failure section, before you can start
again.
Joint order is positional, and name is ignored, as
described in the Command Topics section. The arm eases from wherever it
is to this target over rampInDurationMs, 300 ms by default,
and then holds it for as long as the publisher keeps running. To
actually move the arm, republish with changing position
values at a steady rate. velocity is optional and is
covered in the Command Topics section.
WARNING – Keep the Deltas Between Setpoints Small
Sending a single setpoint far from the arm’s current position is safe here only because the ramp-in smooths the approach. Once you are streaming for real, keep the delta between consecutive setpoints small.
# live joint state
ros2 topic echo /<robotId>/external/robot_joints
# engaged=true/false, for the bridge's writer slot only
ros2 topic echo /<robotId>/external/status.../status says only whether the bridge holds the writer
slot. It prints nothing on a healthy session, and it does not change on
a protective stop or an E-stop, as described in the State Topics
section. To find out why a session ended, use
GET /api/v1/recovery/status, as described in the Reading
Why a Session Ended section.
From the second terminal, call stop while the publisher
from Step 4 is still running:
curl -X POST http://<robot-ip>:3000/api/v1/external-control/stop \
-H "Authorization: Bearer <token>"The response confirms the teardown:
{ "stopped": true }stop returns after teardown, once the arm has settled,
so an immediate start is safe. Once stop has
returned, you can stop the publisher. stopped is
false when there was nothing to stop: the robot was idle,
an operator’s routine is running, which stop never ends, or
the session already ended on its own and the robot is in
Failure. In that last case, recover the robot and then
start.
These are the topics you publish to. All names sit under the
namespace /<robotId>/external. Pick the
streamType at start that matches the topic you
will publish, as the warning below explains.
| Topic | Message type | Fields used |
|---|---|---|
.../trajectory |
trajectory_msgs/JointTrajectory |
joint_names, points[].positions in
radians, points[].time_from_start |
.../joints |
sensor_msgs/JointState |
position in radians, optional velocity in
rad/s |
.../pose |
geometry_msgs/PoseStamped |
pose.position in meters, pose.orientation
as a quaternion |
.../gripper |
std_msgs/Float64MultiArray |
data = [position] or
[position, velocity] |
.../cancel |
std_msgs/Empty |
none |
.../trajectory takes a multi-point
path, which the robot executes with the timing you supply.
joint_names, using joint0 through
joint5, and points are both required, and
every point needs one position per name, or the message is dropped. Only
positions and time_from_start are used. This
topic needs Trajectory execution. On other robots it is
accepted and ignored: nothing moves, and nothing appears on
.../status..../joints takes a single joint-space
setpoint. Stream these continuously. position is required
and velocity is optional, while effort,
name, and header are ignored. See the
Streaming Joint Positions, with Optional Velocity section below..../pose takes a Cartesian setpoint.
Only pose is read, header is ignored, and no
TF transform is applied. See the Streaming Tool-Point Poses, with
Optional Twist section below..../gripper is normalized to the range
0 to 1, from 0 closed to 1 open, and it works
in any session. See The Gripper Channel section below..../cancel aborts the in-flight
.../trajectory. It is a no-op otherwise, or on a robot
without Trajectory execution. It uses depth-1 volatile QoS, so
a cancel published before the bridge is up is never replayed.Joint order is positional: index 0 is
joint0 and index 5 is joint5, with positions
in radians. .../joints ignores name and does
not check the count. Only .../trajectory reads
joint_names.
WARNING – One Motion Modality per Session
Use one motion modality per session, matching
streamType. The control lease is granted for the modality
you pass to start, and publishing to another motion topic
is rejected by the robot’s control arbitration. If you start with
streamType: jointTrajectory but publish to
.../pose, those pose commands are rejected. To switch
modality, stop the session and start a new one
with the new streamType. Gripper and cancel are not gated
this way and work in any session.
For live joint control, use streamType: joints and
publish sensor_msgs/JointState messages carrying
position in radians, ordered joint0 through
joint5, streamed continuously at a steady rate of roughly
100 to 250 Hz.
With Streaming controller, your targets are low-pass
filtered, the arm ramps into the first one rather than jumping to it,
each arrival is blended over a smoothing window, and a stalled stream is
watchdogged. All of these behaviors are tunable per session through
servoConfig. Without the capability, positions are applied
as they arrive, with no tuning and no watchdog.
velocity is optional. With Streaming
controller, the arm advances the target at your velocity between
setpoints, a first-order hold, which removes the stepped motion you get
when streaming below the control rate. Omit the field and each setpoint
is held until the next one arrives. Without the capability,
velocity is ignored and the positions still apply.
velocity is not the same field as a
trajectory point’s. On .../trajectory, a point’s
velocities and accelerations are dropped
before the message leaves the bridge, because the wire format carries
only positions and timing, so populating them is inert rather than
dangerous. Shape a trajectory through its positions and
time_from_start instead.jointTrajectory of one
point with time_from_start = 0 asks the controller to
arrive instantly, which produces a velocity spike. Use
streamType: joints for live setpoints, and reserve
jointTrajectory for real multi-point paths.servoConfig.commandTimeoutMs.tauMs, 20 ms by default, sets how far the arm trails your
stream. Raise it if a jittery stream produces visible chatter, and lower
it if tracking feels laggy.
positionSmoothingTimeConstantMs, also 20 ms by default,
spreads each setpoint’s arrival correction over a window instead of a
single control cycle. Set it to about twice your stream period. At
0, each setpoint is applied at once, so setpoints that
arrive early or late become velocity spikes.For Cartesian control, use streamType: pose and stream
tool center point setpoints continuously. This section
assumes Streaming controller. Without it, a pose is an
unvalidated flange target in the base frame, held indefinitely with no
watchdog, and twist is ignored.
Frame and point. Poses are expressed in
servoConfig.referenceFrame, which is either
base, the default, or world, which also
applies the robot’s configured base placement. The point driven is the
robot’s Active Tooltip, or the flange if no tool is configured. The tool
offset is read at start, so set the tooltip before
starting.
WARNING – The Pose Readback Is the Flange
.../robot_pose and the UDP
ServerMessage.pose report the flange, as
described in the State Topics section. With a tool configured, do not
seed your stream from the readback plus offsets, because your first
target would be off by the tool length.
Rate and hold. Pose streaming is designed for rates
from 10 Hz to 1 kHz. Between setpoints, the target advances along the
setpoint’s twist, a first-order hold. With no twist the
target stands still, so a slow stream produces a staircase. Supply a
twist, or stream at 100 Hz or faster. A gap longer than
commandTimeoutMs ends the session.
Twist. linear is the tool point’s
velocity in m/s, and angular is the tool frame’s angular
velocity in rad/s, both expressed in the referenceFrame
axes. The twist has to be the derivative of the poses you stream, or the
motion becomes a sawtooth. With a tool, keep in mind that the flange’s
velocity is not the tool point’s velocity while the tool rotates. Twist
is available on UDP and the Python SDK only, because the ROS2
geometry_msgs/PoseStamped message has no velocity
field.
Ramp-in and smoothing. The arm ramps from its
current tool pose to your first setpoint over
rampInDurationMs, 300 ms by default and clocked from the
first setpoint. It then tracks the stream through a first-order low-pass
filter with the time constant tauMs, 20 ms by default. The
ramp is linear in time, so covering 0.3 m in 300 ms is an approach of
roughly 1 m/s, which is enough to trip the joint acceleration check.
Size the ramp to the distance, or start the stream at the arm’s current
tool pose. With rampInDurationMs: 0, a far first setpoint
ends the session at once with TRACKING_ERROR_EXCEEDED.
orientation as a unit quaternion.
Within 1e-3 of unit norm, it is normalized. Further off than that, or
unset, which reads as all zeros, the setpoint is dropped. A twist
containing a NaN or an infinity drops the setpoint too.orientation before
suspecting the network.STREAM_JUMP. The ramp-in
absorbs only the first setpoint.motionScalingLimits makes the arm lag rather
than jump. Once the lag exceeds the tracking bound, the session
ends with TRACKING_ERROR_EXCEEDED.stop decelerates the tool point along its current
direction, with orientation held. Stop publishing once you call
stop, because a large jump during the stop escalates it to
a joint-space stop.The bounds behind these fault codes are listed in the Controller Faults on a Pose Stream section.
WARNING – A Non-Zero Twist Keeps the Target Moving
A non-zero twist keeps the target moving after your last setpoint,
through the commandTimeoutMs window and the stop that
follows. Send a zero twist when you intend to hold.
.../gripper, and its UDP counterpart
gripper_command, is a jog-shaped channel, not a pick
command. It is gated on the writer slot, not on
streamType.
| Field | Range | Meaning |
|---|---|---|
position |
0 to 1 | 0 is fully closed and 1 is fully open, on
the gripper’s configured stroke. The same scale applies to Robotiq 2F,
OnRobot 2FG and 3FG, and Schunk EGx grippers. |
velocity |
0 to 1 | Fraction of the gripper’s speed range. 0, or omitting
the value, uses the gripper’s default. |
Force is the gripper’s configured default, and a setpoint never waits for grip detection. Setpoints coalesce, so you can stream at any rate.
NOTE – No Gripper, No Channel
With no enabled, connected gripper, or with a Robotiq gripper that is
still activating, commands are dropped silently and
.../robot_gripper never publishes, so confirm that it
produces data first. .../robot_gripper updates at 30 Hz
during a session and 10 Hz otherwise.
These are the topics the robot publishes and you subscribe to. They
are republished once per robot state frame: about 200
Hz on a real arm, up to about 400 Hz where pose and joint
updates arrive as separate frames, and 40 Hz in the
simulator. They use the default reliable, volatile QoS with a depth of
10, and nothing is latched. header.stamp is the bridge’s
receive time, not a robot timestamp.
| Topic | Message type | Contents |
|---|---|---|
.../robot_joints |
sensor_msgs/JointState |
Live name from joint0 to
joint5, position in radians,
velocity in rad/s, and effort in
newton-meters, which is a filtered torque estimate. |
.../robot_pose |
geometry_msgs/PoseStamped |
Live flange pose in the robot base
frame, with position in meters and orientation as a quaternion. No tool
offset and no base-placement transform are applied, regardless of
servoConfig.referenceFrame. header.frame_id is
the fixed label "world", but treat the data as
base-frame. |
.../robot_gripper |
std_msgs/Float64MultiArray |
data = [position], normalized to the range 0 to 1.
Republished with every frame, and the value refreshes at 30 Hz during a
session. Published only once the robot has reported a gripper. With
none, the topic exists but never publishes. |
.../status |
std_msgs/String |
engaged=<true/false> reason=<reason>,
telling you whether the bridge holds the writer slot. Published
on transitions only, and not latched.
reason is auto-engaged when the bridge takes
control, or send_<op>-failed when a command could not
be delivered and the bridge dropped control. The bridge re-takes control
on your next command. |
What the .../status topic does and does not tell
you:
engaged=true message goes out as the bridge comes up,
before any subscriber has matched, so judge liveness by
.../robot_joints flowing instead./external/* topics simply disappear. Read the cause from
GET /api/v1/recovery/status, as described in the Reading
Why a Session Ended section.The base URL is the robot’s control box on port
3000, the same base URL used by the rest of the API.
Both endpoints require an API token in the
Authorization: Bearer <token> header.
POST /api/v1/external-control/start starts an
external-control session. It takes the control lease and, for
rosHumble and rosJazzy, brings up the ROS2
bridge. It waits up to 2 seconds for the arm to be still, and then
returns once the session routine is running. If the routine is not
running within 15 seconds, the call fails with
500 cannot_play_routine. The call returns
before the lease is held and the bridge is up, as
described in Step 2 of the Getting Started with Joint Streaming
section.
A request body that sets every field looks like this:
{
"controlledBy": "rosJazzy",
"streamType": "joints",
"servoConfig": {
"tauMs": 20,
"commandTimeoutMs": 500,
"rampInDurationMs": 300,
"positionSmoothingTimeConstantMs": 20,
"referenceFrame": "base",
"motionScalingLimits": {
"jointVelocityMax": [1.0, 1.0, 1.0, 2.0, 2.0, 2.0],
"jointAccelerationMax": [4.0, 4.0, 4.0, 4.0, 4.0, 4.0]
}
}
}The request body has three top-level fields:
| Field | Required | Values |
|---|---|---|
controlledBy |
yes | api, rosHumble, or
rosJazzy |
streamType |
yes | pose, joints, or
jointTrajectory |
servoConfig |
no | Streaming tuning. Honored on joints and
pose, and ignored on jointTrajectory. Every
field inside it is individually optional, so omit what you do not want
to change. See the Tuning the Stream with servoConfig section. |
A missing or unknown value for controlledBy or
streamType is a 400 request_failed_validation
that names the field. The two required fields are checked before
servoConfig.
A successful call returns 200 with the session
identifier:
{ "routineId": "string" }Every error has the same body shape:
{ "error": "<code>", "message": "<detail>" }These are the errors you are most likely to see:
| Status | Meaning | Fix |
|---|---|---|
400 brakes_must_be_disengaged |
The brakes are engaged. This is checked first, so an E-stopped robot reports it. | Disengage the brakes, as shown in Step 1, then retry. |
400 request_failed_validation |
controlledBy or streamType is missing or
not an allowed value, or a servoConfig field is out of
range, the wrong type or shape, or null. The
message names every offending field. |
Correct the named field. |
409 robot_not_idle |
The message says which case applies: a routine or
another session is running (state: RoutineRunning), a
previous session ended on its own and the robot is in
Failure, the arm has not come to rest within 2 seconds, or
another session is already starting. |
If something is running, stop it. On Failure, recover
the robot and then start. Otherwise, wait a moment and
retry. |
500 cannot_play_routine |
The session routine could not start, because the robot was already
in Failure when play began, or the routine ended before it
ran. Nothing is left running. |
Check GET /api/v1/recovery/status, recover the robot if
needed, and retry. |
500 internal_server_error |
An unexpected robot-side error, or
Routines service is unavailable while the robot’s services
start. |
Retry, and if the error persists, contact Standard Bots. |
503 service_initializing |
The robot software is still starting. | Retry after a few seconds. |
A failed start leaves nothing running. A validation
400 names every offending field in its
message, separated by semicolons. The message
is prefixed with Invalid request: for the two required
fields, or with Invalid servoConfig: for the tuning. For
example, a message that names two servoConfig
fields reads as follows, wrapped here to fit the page:
Invalid servoConfig: tauMs: Number must be less than or equal to 1000;
motionScalingLimits.jointVelocityMax: must be 6 values, one per joint
servoConfig is optional tuning of the Streaming
controller. It is honored on joints and
pose, validated and then ignored on
jointTrajectory, and ignored entirely on robots without the
capability. Each field is optional and defaults independently, so you
can send just the one you want to change. A field that belongs to the
other stream type still has to be valid but has no effect, so one
request shape works for both.
| Field | Default | Range | Applies to |
|---|---|---|---|
tauMs |
20 ms | 1 to 1000 |
joints, pose |
commandTimeoutMs |
500 ms | 1 to 2147483647 |
joints, pose |
rampInDurationMs |
300 ms | 0 to 2147483647 |
joints, pose |
positionSmoothingTimeConstantMs |
20 ms | 0 to 1000 |
joints |
referenceFrame |
"base" |
"base" or "world" |
pose |
motionScalingLimits |
absent | Two six-value arrays | pose |
Each field works as follows:
tauMs is the time constant of the
first-order low-pass filter applied to your targets, which are joint
positions on joints or the tool pose on pose.
A smaller value is snappier and a larger one is smoother, and the arm
trails your stream by roughly the time constant you set.commandTimeoutMs is the
stream-staleness window, armed by your first setpoint. If no fresh
setpoint arrives inside it, the arm decelerates to rest and the session
ends.rampInDurationMs is the soft start.
The arm eases from its current position, or its current tool pose, to
your first setpoint over this window, clocked from that setpoint, so a
session that begins with the arm far from your first target does not
jump. Setting it to 0 disables the ramp. The Streaming
Tool-Point Poses, with Optional Twist section explains how to size
it.positionSmoothingTimeConstantMs is the
window over which each setpoint’s deviation from the
position-plus-velocity extrapolation is absorbed, instead of in a single
1 ms control cycle. 0 applies the deviation
immediately.referenceFrame is the frame of your
poses and twists. "world" also applies the robot’s
configured base placement. If no base placement is configured, the two
frames coincide.motionScalingLimits sets per-joint
speed and acceleration caps that the controller slows the stream to. It
takes a jointVelocityMax array in rad/s and a
jointAccelerationMax array in rad/s2, each
holding six values for joint0 through joint5,
and every value has to be finite and greater than 0. Both arrays are
required together. Omit the object for no per-joint cap.All millisecond fields are integers. Out-of-range, wrong-type,
wrong-length, and non-finite values are rejected with a 400
rather than clamped.
WARNING – Omit a Field to Get Its Default, and Never Send
null
A null is a validation error, not a fallback. The Python
SDK serializes every unset field, including servoConfig
itself, as null, as described in the Starting and Stopping
the Session from the SDK section.
NOTE – Misspelled Fields Fail Silently
Unrecognized keys inside servoConfig are dropped without
an error, so a misspelled field name fails silently and you get the
default instead. Check your spelling against the table above.
motionScalingLimits slows the stream rather than
rejecting it. Every cycle, the commanded joint velocity is scaled so
that no joint exceeds jointVelocityMax, and then
rate-limited so that no joint exceeds jointAccelerationMax.
The arm lags the stream until it catches up, and once the lag exceeds
the tracking bound, the session ends with
TRACKING_ERROR_EXCEEDED.
Independently of those caps, the arm’s own per-joint
commanded-acceleration limit, 5 rad/s2 on RO1, still applies,
and exceeding it ends the session with
JOINT_COMMAND_OUT_OF_LIMITS. If you hit it, raise
tauMs or tighten jointAccelerationMax. A small
tauMs combined with a tight
jointAccelerationMax can overshoot a stopped target. The
overshoot is bounded, and the servol_overshoot.py SDK
example predicts it.
While a session runs, the Settings > External control > Streaming page in the robot UI shows the effective tuning.
POST /api/v1/external-control/stop stops the running
external-control session, releases the lease, and tears down the bridge.
It takes no request body, and it returns 200 with:
{ "stopped": true }stopped is false when there was nothing to
stop: the robot was idle, an operator’s routine is running, which
stop never ends, or the session already ended on its own
and the robot is in Failure. In that last case, recover the
robot and then start.
stop returns after teardown and settle, capped at 2
seconds, so an immediate start is safe. It ends the running
session no matter who started it, whether that was you, another client,
or the robot UI. There is no session owner and no owner heartbeat, so a
session never ends just because its client went away. Watch for a
session ending under you, where the topics vanish and
GET /api/v1/recovery/status leaves
RoutineRunning, rather than assuming the session is yours
until you call stop.
An operator’s own routine is stopped from the robot UI or with
POST /api/v1/routine-editor/routines/{id}/stop, not with
this endpoint.
joints or pose stream is
fatal on robots with Streaming controller. Once your
first setpoint is accepted, a gap longer than
servoConfig.commandTimeoutMs, 500 ms by default,
decelerates the arm and ends the session. The deceleration is in joint
space on joints, and along a Cartesian ramp on
pose. Recover the robot, and then start again.
To idle mid-session, keep republishing the current setpoint, with a zero
twist on pose, or raise commandTimeoutMs at
start. jointTrajectory has no watchdog. The
failure reason reads CLIENT_STREAM_TIMEOUT, and nothing
else is sent, so watch your own publish rate.start, from any client or from the robot UI, gets
409 robot_not_idle. There is one writer per session. A ROS2
bridge holds the writer slot for the whole session, while in an
api session UDP clients pass the slot between them with
take_control and release_control, as described
in the Direct UDP Interface section.controlledBy,
streamType, and effective servoConfig,
read-only, on the Settings > External control >
Streaming page, with a Stop button that does
the same as stop. Pause ends the session
too. Gripper actuation from the UI is refused with “Streaming active”
while a session holds the gripper.stop, a fault, and a robot-side pause all release control
and bring the arm to a controlled stop. On pose it is a
Cartesian stop, at 1 m/s2 and 2 rad/s2 by default,
with orientation held. Stop publishing once you call stop.
A dropped client does not end the session. Only the watchdog, a fault,
or stop does..../status does not change, .../robot_joints
goes still, and the topics vanish. Read the cause from
GET /api/v1/recovery/status.Only stop ends a session cleanly. Every other end leaves
the robot in Failure, not Idle, and “recover”
below means the procedure in the Recovering from a Failure section. The
last case in this list does not end the session at all.
stop, whether yours, another token
holder’s, or the Stop button in the robot UI. The arm
makes a graceful stop, and stop returns once it has
settled, typically within a few seconds. The session ends,
stop returns { "stopped": true }, and the
robot is Idle, so you can start again.joints or pose stream that
stalls past commandTimeoutMs. The arm decelerates
to rest, along a Cartesian ramp on pose, and the session
ends. failure.reason reads
External Control Step failed: The command stream stopped, so the streaming session ended (code=CLIENT_STREAM_TIMEOUT ...).
Recover, then start.failure.reason embeds one of the codes
STREAM_JUMP, TRACKING_ERROR_EXCEEDED,
IN_SINGULARITY, COMPUTATION,
JOINT_COMMAND_OUT_OF_LIMITS,
TRAJECTORY_OVER_LIMITS, or
TC_CONTROLLER_ERROR. Recover, fix the stream, and
start. The faults that apply to a pose stream are detailed
in the Controller Faults on a Pose Stream section below.failure.kind is StepPlayFailure, and
failure.reason reads
External control session ended: <cause>. Start a new session to resume streaming.
Recover, then start. Whether you can recover the robot
yourself depends on recovery_type, described below.failure.kind is
OutOfJointLimitsFailure. A person has to recover the robot
at the robot itself, in guided mode, before you start
again.GET /api/v1/recovery/status. Release the E-stop or restore
power, with no recover call needed, re-check the brakes as
shown in Step 1, and then start.GET /api/v1/recovery/status, recover, and
start.release_control, or the bridge process dying.
Nothing happens to the arm directly: the writer slot is freed, or the
topics vanish. A watchdogged stream will already have hit the stall case
above, and a jointTrajectory session keeps holding. The
session continues. On UDP, you find out from the reply
to your next take_control, as described in the Direct UDP
Interface section. On ROS2, the /external/* publishers
disappear while REST still reports RoutineRunning. Send
take_control again, or stop and then
start for a fresh bridge.Each of these conditions stops the arm and then ends the session. The bounds are robot-side defaults, and the tuning fields are described in the Tuning the Stream with servoConfig section.
Code in failure.reason |
Condition and notes |
|---|---|
STREAM_JUMP |
Two consecutive setpoints are too far apart. The bound is 0.1 m or 0.5 rad. The first setpoint is exempt, because the ramp-in absorbs it. |
TRACKING_ERROR_EXCEEDED |
The target ran away from the arm. The bound is 0.4 m or 1.5 rad
between the target and the tracked pose. Causes include an unreachable
pose, motionScalingLimits set too tight, and a far first
setpoint with rampInDurationMs: 0. |
IN_SINGULARITY |
A singularity. A session may start at a singular pose and hold there, but once anything has moved, a target at or through a singularity faults. Plan clear of singularities, because the controller does not steer around them. |
COMPUTATION |
The stop ramp has no IK solution. The stream is integrated in joint space, and IK runs only on the deceleration ramp. If there is no solution there, the arm stops faster, in joint space. |
JOINT_COMMAND_OUT_OF_LIMITS |
The commanded joint acceleration is above the robot’s fixed
per-joint limit. The message reads “A commanded joint position is
outside the arm’s limits”, but on a pose stream this code means
acceleration. Raise tauMs or add
motionScalingLimits. |
| None | A commanded joint position is beyond a joint’s range, or more than
0.2 rad from the measured position. This is a control-system stop, not a
controller fault. The command is not scaled or clamped: the arm’s own
validator refuses it, the arm stops gracefully, and the session ends
with no code. The robot UI shows
Robot encountered a control system issue: Invalid trajectory point....
Stay inside the joint ranges yourself. |
While the robot is in Failure, stop returns
{ "stopped": false } and start returns
409 robot_not_idle with state: Failure.
Nothing recovers automatically. Read the failure, recover, and start
again:
curl http://<robot-ip>:3000/api/v1/recovery/status \
-H "Authorization: Bearer <token>"
# => { "status": "Failure", "failed": true,
# "failure": { "kind": "StepPlayFailure",
# "reason": "External Control Step failed: ...",
# "recovery_type": "Recoverable", ... } }
curl -X POST http://<robot-ip>:3000/api/v1/recovery/recover \
-H "Authorization: Bearer <token>"
# => { "status": "Idle", "failed": false }
curl -X POST http://<robot-ip>:3000/api/v1/external-control/start \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"controlledBy":"rosJazzy","streamType":"joints"}'recovery_type tells you whether recover
will succeed on its own:
recovery_type |
Typical causes | Who recovers |
|---|---|---|
Recoverable |
A controller fault, a stalled stream, a pause, a position or velocity protective stop, or a collision deviation. | You. Call recover, then start. |
GuidedMode, ManualRecoveryMode, or
NotRecoverable |
A collision, a torque protective stop, or a joint at its limit. | A person at the robot, first. recover alone does not
succeed. |
An E-stop or an arm power loss needs no recover: the
failure clears once the E-stop is released and power is back. The
Recover button in the robot UI and
sdk.recovery.recover.recover() in the Python SDK do the
same as recover.
GET /api/v1/recovery/status keeps the reason until you
recover:
{
"status": "Failure",
"failed": true,
"failure": {
"kind": "StepPlayFailure",
"reason": "External Control Step failed: ...",
"recovery_type": "Recoverable",
"is_recoverable_with_wrist_button": false
}
}The full reason in that example is a single line,
wrapped here to fit the page:
External Control Step failed: No new setpoint arrived in time, so the
arm stopped (code=STALENESS_TIMEOUT severity=FAULTED
clearableBy=CLEARABLE_BY_CLIENT)
failure.reason takes one of two shapes. Match on its
text, not on kind, which is StepPlayFailure
even for a collision.
When a controller fault or a stalled stream ended the session, the
reason has this shape, where a stalled stream reads
code=CLIENT_STREAM_TIMEOUT:
External Control Step failed: <message> (code=<ErrorCode>
severity=<Severity> clearableBy=<ClearableBy>)
When an operator pause or a pausing safety failure ended the session,
the reason has this shape, where a pause reads
the routine was paused:
External control session ended: <reason>. Start a new session to
resume streaming.
Both shapes are single lines in the actual response. Poll
status to detect the end of a session: it moves from
RoutineRunning to Failure or
Idle.
If you are not using ROS2, start the session with
controlledBy: "api" and stream setpoints with the Standard
Bots Python SDK’s realtime RobotStream client instead of
publishing ROS topics. The semantics in the Control and Safety Semantics
section apply unchanged. Install the standardbots package
as described in the On Your Machine section. The realtime client and its
value types import from one module:
from standardbots.realtime import (
RobotStream, CartesianPose, CartesianTwist, JointState,
Point, Quaternion, Vector3,
)Session tuning still happens on the HTTP start call, not
on the client. RobotStream.start() takes no configuration,
so servoConfig has to go in the
external-control/start body regardless of which of the
three transports you then use to stream. The SDK can make that HTTP call
for you, as shown in the Starting and Stopping the Session from the SDK
section below.
# Always UDP port 30100; the port is not configurable.
stream = RobotStream("<robot-ip>")
stream.start() # open the socket, subscribe as a reader
joints, ts_us = stream.get_joints() # block for the next state broadcast
# ... POST /api/v1/external-control/start with controlledBy "api" ...
stream.take_control() # become writer; RuntimeError if rejected
stream.send_joints(list(joints.position))
stream.release_control()
# ... POST /api/v1/external-control/stop ...
stream.stop() # unsubscribe; releases control if heldwith RobotStream(ip) as stream: calls
start() on entry and stop() on exit, but you
still call take_control() and
release_control() yourself.
The calls work as follows:
RobotStream(host) takes the host only.
It binds an ephemeral local port, and the robot replies to that
port.start(), stop(), and
is_running() handle the lifecycle and take no
arguments. stop() sends Unsubscribe, which
also releases control if you are the writer.take_control(timeout=2.0) promotes you
to writer. It waits up to timeout for the first state
broadcast, and then up to timeout again for the robot’s
answer. A rejection raises
RuntimeError("TakeControl rejected by server: <reason>"),
with the reasons listed in the Control Responses and Rejection Reasons
section.release_control() returns you to being
a reader. It raises RuntimeError if the robot rejects it
with not the current writer, and only warns if the stream
is not running or the robot does not reply.send_joints(positions, timestamp_us=0, velocities=None)
sends one setpoint in radians, ordered joint0 through
joint5. velocities are in rad/s, one per
position, as described in the Velocities and Twist section below.send_pose(pose, timestamp_us=0, twist=None)
sends one pose as
CartesianPose(position=Point(x, y, z), orientation=Quaternion(x, y, z, w))
in meters, with an optional
twist=CartesianTwist(linear=Vector3, angular=Vector3) in
m/s and rad/s. Which frame and which point it drives is described in the
Pose Readback Versus the Streamed Pose section below.send_trajectory(points, joint_names=(), timestamp_us=0)
sends points = [([q0, ..., q5], time_from_start_s), ...].
Always pass joint_names=("joint0", ..., "joint5"), as
described in the Trajectories and Cancel section below.send_gripper(position, velocity=0.0, timestamp_us=0)
is normalized to the range 0 to 1, where velocity=0 means
the gripper default. It works in any session.send_cancel() aborts the in-flight
trajectory, and is a no-op otherwise.get_joints(timeout=5.0) returns
(JointState, timestamp_us). It blocks for the
next broadcast, and raises TimeoutError if
none arrives within timeout. JointState
carries name, position in radians,
velocity in rad/s, and effort in
newton-meters.get_pose(timeout=5.0) returns
(CartesianPose, timestamp_us), which is the flange pose in
the base frame, in meters, as described in the Pose Readback Versus the
Streamed Pose section below.get_gripper(timeout=5.0) returns
(position, timestamp_us), normalized to the range 0 to 1.
It times out on a robot with no gripper.on_joints(cb), on_pose(cb), and
on_gripper(cb) call
cb(value, timestamp_us) on every broadcast, from the
receive thread.timestamp_us on every send_* call is an
optional client send-time in microseconds, where 0 means
unset. The timestamp_us returned with state is the robot’s
state timestamp, as a Python float.
The robot’s UDP listener runs without a session, as described in the
Direct UDP Interface section, so start(), the
get_* calls, and the on_* callbacks all work
before the HTTP start. Use them to read
the arm’s position and seed your first setpoint.
take_control() also succeeds without a session, but
setpoints sent outside a session are discarded with no error. Work in
this order:
RobotStream(ip) and call
stream.start(), then read the arm with
get_joints() or get_pose().POST /api/v1/external-control/start with
controlledBy: "api" and your streamType and
servoConfig, either with curl or from the SDK
as shown below.stream.take_control(), and then stream setpoints
at a steady rate.stream.release_control(), then
POST /api/v1/external-control/stop, and finally call
stream.stop().WARNING – Stop the HTTP Session Before Closing the Stream
Closing the stream first lets the setpoint stream go stale, so a watchdogged session ends with a stream-timeout failure instead of a clean stop. See the What Ends a Session and How to Recover section.
from standardbots import StandardBotsRobot, models
sdk = StandardBotsRobot(url="http://<robot-ip>:3000", token="<token>",
robot_kind=StandardBotsRobot.RobotKind.Live)
routine_id = sdk.external_control.start(models.StartExternalControlRequest(
controlledBy=models.ExternalControlControlledBy.Api,
streamType=models.ExternalControlStreamType.Joints,
# Populate every field; see the warning below.
servoConfig=models.ExternalControlServoConfig(...),
)).ok().routineId
# ... stream with RobotStream ...
# .stopped is False if no session was running
sdk.external_control.stop().ok().ok() returns the parsed body, or raises on any non-200
response with the robot’s error code. The error table in the Starting a
Session section applies unchanged.
WARNING – The SDK Sends Unset Fields as
null
The SDK sends every unset field as JSON null, and the
robot rejects null in servoConfig. Omitting
servoConfig, or any field inside it, fails
start with 400 request_failed_validation.
Either populate every
ExternalControlServoConfig field, including the ones your
stream type ignores, as servol_sphere_demo.py does, or make
the two HTTP calls with another client and use the SDK only for
RobotStream. Note that motionScalingLimits
takes six positive float values per array, not
int values.
start() runs one daemon receive thread that updates the
latest state and runs your on_* callbacks. Keep callbacks
short, and never call get_*, take_control(),
or release_control() from inside one, because they wait on
the very thread you are on. An exception raised in a callback is logged
and swallowed.
send_* is fire-and-forget. It raises
RuntimeError before start() or after
stop(), and OSError on socket errors, but
never for a dropped setpoint, because the robot reports none. Stream
only after take_control() returns. The get_*
calls raise TimeoutError after timeout, 5
seconds by default, which is the usual first symptom of a wrong IP
address or a blocked UDP port 30100.
Liveness is handled for you. Subscribe is re-sent every
second until the first broadcast arrives, and a Heartbeat
goes out after 2 seconds of silence, inside the robot’s 5-second
eviction window. Heartbeats keep the subscription alive, not
the setpoint stream, so commandTimeoutMs still
applies.
Frames arrive at about 200 Hz, with pose and
joints together. gripper is present only once
a connected gripper reports, so get_gripper() timing out
while everything else works means there is no gripper, not that there is
no connection. Broadcasts stop when the arm’s state stops, and there is
no substitute heartbeat.
on_* registrations are permanent. There is no way to
unregister one, and a function registered twice runs twice. Calling
stop() and then start() on the same object
gives you a clean slate with a new local port, so call
take_control() again. is_running() goes false
after stop() or after a socket failure.
send_joints(velocities=...) and
send_pose(twist=...) always put the field on the wire, but
the arm uses it only with Streaming controller, as a
first-order hold between setpoints, as described in the Command Topics
section. On those robots, a velocities list whose length
differs from positions, or any non-finite entry in
velocities or twist, drops the whole setpoint
silently. Sustained, that stalls the stream, and the session ends. On
other robots the field is ignored.
get_pose() and on_pose return the
wrist-flange pose in the robot base frame, on every robot. Without
Streaming controller, send_pose() targets that
same point. With it, send_pose() commands the
active tool center point in
servoConfig.referenceFrame, so seeding your stream from
get_pose(), as robot_stream_poses.py does,
moves the arm by the tool offset when the stream starts.
Seed from the REST tooltip pose instead,
sdk.movement.position.get_arm_position().ok().tooltip_position,
as servol_sphere_demo.py does, with
referenceFrame set to that pose’s frame.
send_trajectory() and send_cancel() act
only on robots with Trajectory execution. On other robots the
call returns normally and the arm holds, with nothing to tell the
client. Where they do act, pass
joint_names=("joint0", ..., "joint5") and exactly that many
positions per point, or the trajectory is dropped silently.
Runnable examples live in the examples/ directory of the
package source. They hard-code ROBOT_IP = "localhost", and
the demo also hard-codes ROBOT_URL and
ROBOT_TOKEN, so edit them before running.
| Example | What it shows |
|---|---|
robot_stream_joints.py |
A joints session that swings joint 0 with
send_joints at 50 Hz. For your own streams, use a steady
rate of roughly 100 to 250 Hz, as described in the Streaming Joint
Positions, with Optional Velocity section. |
robot_stream_poses.py |
A pose session that offsets the flange pose from
get_pose() along X, Y, and Z. See the Pose Readback Versus
the Streamed Pose section. |
robot_stream_trajectory.py |
A jointTrajectory session that sends one 20-point
send_trajectory out and back. Needs Trajectory
execution. |
robot_stream_gripper.py |
send_gripper from open to closed to open, with
on_gripper feedback. |
servol_sphere_demo.py |
A complete pose-streaming run: unbrake and home over REST, the HTTP
start from the SDK with every servoConfig
field, streaming targets, and the HTTP stop. |
servol_overshoot.py |
Runs offline and predicts the worst-case overshoot for the limits used in the sphere demo. |
Only servol_sphere_demo.py starts the HTTP session
itself. The robot_stream_* scripts need a session of the
matching streamType started first, as shown in the Getting
Started with Joint Streaming section, and the gripper script needs a
session of any type.
Not using Python either? Both the ROS2 bridge and the Python SDK sit on top of a documented protobuf-over-UDP protocol that you can speak directly from C++, or from any other language. That protocol is described next.
The ROS2 bridge and the Python SDK’s RobotStream are
both thin clients over one lower-level transport: a protobuf-over-UDP
stream served directly by the robot. If you are using neither ROS2 nor
the Python SDK, for example because you are writing a C++ client, you
talk to this interface directly.
The contract is external_control.proto, package
external_control. Ask Standard Bots for the file, because
the Python SDK ships only pre-generated bindings and not the source.
Compile it for your language with protoc. The Python SDK,
in standardbots/realtime/robot_stream.py and
protocol.py, is the reference implementation.
| Property | Value |
|---|---|
| Protocol | UDP |
| Server endpoint | <robot-ip>:30100, on the control box. |
| Client endpoint | Any local IPv4 port. Bind an ephemeral port with
bind(("", 0)), and the server replies to your datagram’s
source address. That IP address and port are your identity for the
subscription, the writer slot, and liveness, so use one socket
for the whole session. A new socket is a new, unsubscribed
client, and the old writer slot frees only when the old identity is
evicted, after 5 seconds. IPv6 is not supported. |
| Wire format | One serialized protobuf message per datagram:
ClientMessage from you to the robot,
ServerMessage from the robot to you. There is no length
prefix and no framing, so the UDP payload is the protobuf. |
| Datagram size | One setpoint or state snapshot per packet, comfortably under one
MTU. The reference client reads with recvfrom(65535). |
Reading needs no session. Subscribe
alone delivers state broadcasts whenever the robot’s external-control
service is up.
Moving the arm needs a session.
POST /api/v1/external-control/start with
controlledBy: "api" takes the control lease, and then
take_control claims the UDP writer slot. The two are
independent: take_control succeeds without a session, but
the robot then drops every arm setpoint and sends nothing back.
gripper_command is the exception, as described in the
Messages You Send section below. Call
POST /api/v1/external-control/stop when you are done.
A client is first a reader, and then optionally promotes itself to the single writer:
Subscribe makes you a reader and
starts the ServerMessage broadcasts.TakeControl requests exclusive write.
The server replies with a ControlResponse.JointCommand, PoseCommand, and the
other setpoints are applied, but only from the active
writer.ReleaseControl puts you back to being
a reader. You still receive state.Unsubscribe stops the broadcasts, and
releases control first if you are the writer.Two rules govern this:
TakeControl needs a prior
Subscribe, and Subscribe is not
acknowledged. The first broadcast is your only confirmation,
and none arrives while the arm’s state is not flowing. UDP may reorder
datagrams, so a TakeControl sent right after
Subscribe can be rejected with not subscribed.
Wait for one broadcast first, and on not subscribed,
re-send Subscribe and retry once.take_control is answered with
rejected by orchestrator (another transport has control).
In an api session the first take_control wins,
and after a release_control or an eviction the next one
wins, within the same session. Setpoints from a client that is not the
writer are dropped silently. Releasing control, unsubscribing, or being
evicted frees only the writer slot, and the HTTP session stays up.The server evicts any client whose last accepted
message is more than 5 seconds old. An accepted message
is a subscribe, heartbeat,
take_control, or release_control, or a
setpoint from the current writer. Setpoints from a
client that is not the writer, and malformed datagrams, refresh
nothing.
Heartbeat at least every 5 seconds. The
reference client sends one every 2 seconds when idle, which is a good
interval to match.take_control is answered with not subscribed.
Re-send Subscribe to come back.NOTE – Eviction Is Not a Stop
Eviction frees only the UDP writer slot, and the HTTP session and its
lease stay up. A watchdogged stream will already have ended the session
long before 5 seconds pass. A jointTrajectory session, or a
pose session without Streaming controller, holds
position and stays open until you stop.
ClientMessage is a oneof envelope, so set
exactly one field per datagram.
| Field | Purpose |
|---|---|
subscribe |
Register as a reader. Idempotent, and not acknowledged. |
take_control |
Request exclusive write. Requires a prior
subscribe. |
release_control |
Relinquish write and stay a reader. |
unsubscribe |
Stop broadcasts. Releases control first if you are the writer. |
heartbeat |
Keepalive, as described above. |
joint_command |
A joint-space setpoint. |
pose_command |
A Cartesian pose setpoint, with an optional twist. |
trajectory_command |
A whole multi-point path. Trajectory execution robots only. |
gripper_command |
A gripper setpoint. |
cancel_trajectory |
Abort the in-flight trajectory. Trajectory execution robots only, and a no-op otherwise. |
take_control and release_control are each
answered with a ControlResponse.
The command messages carry these payloads:
joint_command carries
position[] in radians, ordered joint0 through
joint5, an optional velocity[] in rad/s, and
timestamp_us. An empty position[] is dropped.
With Streaming controller, a velocity[] has to
carry one finite entry per position or the setpoint is dropped silently.
Without the capability, velocity[] is ignored.pose_command carries
pose, with position in meters and
orientation as a unit quaternion, an optional
twist, with linear in m/s and
angular in rad/s, and timestamp_us. With
Streaming controller, the pose is the tool center point in
servoConfig.referenceFrame and the twist is carried forward
between setpoints. The setpoint is dropped silently when
pose is unset, when any value is non-finite, or when the
orientation’s norm is more than 1e-3 away from 1. Without the
capability, the pose is the flange in the base frame and the twist is
ignored. ServerMessage.pose is always the flange.trajectory_command carries
joint_names[], points[] of
{ position[], time_from_start_seconds }, and
timestamp_us. Note that
time_from_start_seconds is a single double in
seconds, unlike the ROS2 sec and nanosec
split. It needs Trajectory execution, and on other robots it is
accepted and ignored. It is dropped silently when
joint_names or points is empty, when a point’s
position count differs from joint_names, or
when the arm’s executor is not ready. The arm owns execution timing and
does not re-interpolate your points.cancel_trajectory has no payload. It
cancels in-flight trajectories on Trajectory execution robots,
and is a no-op elsewhere. Session teardown issues the same cancel.gripper_command carries
position, from 0 closed to 1
open, and velocity, where 0 means the gripper
default, both in the range 0 to 1, plus timestamp_us. It is
applied from the current writer regardless of the session and
streamType, so it still actuates the gripper even
when no session is running. It is dropped silently when no
gripper is connected, as described in The Gripper Channel section.timestamp_us is an optional client send-time in
microseconds, and 0 means unset.
These mirror the ROS2 command topics described in the Command Topics
section. The one addition is the optional twist on
pose_command, which ROS2 cannot send, because
geometry_msgs/PoseStamped has no velocity field.
The server sends a ServerMessage to every subscribed
reader on each arm-state update, and a separate one as the direct reply
to take_control and release_control.
Broadcasts follow the robot’s state rather than a timer, at 200
Hz by default, which is a per-robot setting. Sends closer
together than 2 ms are coalesced into one frame that
carries the latest value of every field, so pose and
joints may be one tick apart.
WARNING – No Arm State, No Frames
The robot sends no idle heartbeat. If ServerMessage
broadcasts stop arriving while your subscription is alive, the robot’s
arm-state stream has stalled, so treat it as a link-down.
| Field | Contents |
|---|---|
pose |
Live flange pose in the robot base frame. It is not
the tool point, and it is not converted to
servoConfig.referenceFrame. |
joints |
Live JointState: name,
position in radians, velocity in rad/s,
effort in newton-meters. |
gripper |
GripperState.position, normalized to the range 0 to 1.
Present only once a connected gripper reports state. Updates at 10 Hz
when idle and 30 Hz during a session. |
timestamp_us |
Server send time in microseconds, on a monotonic clock. Use it for ordering and intervals only, and never compare it to your own clock. |
control_response |
Present only on a reply to take_control or
release_control, as { success, reason }. A
reply carries nothing else. |
Branch on has_control_response(): a reply answers your
request, and a broadcast carries state. Never update your cached pose or
joints from a reply.
take_control and release_control are
acknowledged with a ControlResponse carrying
success and reason:
success = true with an empty reason means
the request was granted. An idempotent re-take by the current writer
also returns success.success = false with not subscribed means
take_control arrived without a prior
subscribe, including after a silent eviction.success = false with
another client already has control means another UDP client
is the writer.success = false with
rejected by orchestrator (another transport has control)
means the ROS2 bridge, or another transport, holds control.success = false with
not the current writer means release_control
came from a client that is not the writer.NOTE – Older Servers May Not Reply at All
Older servers may not send a ControlResponse. If you get
no reply within a short timeout of roughly 2 seconds, treat the request
as best-effort succeeded, which is what the reference client does for
backward compatibility.
Generate the protobuf bindings with
protoc --cpp_out=. external_control.proto, then drive a
POSIX UDP socket. Error handling is elided here:
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include "external_control.pb.h" // protoc-generated
int fd = ::socket(AF_INET, SOCK_DGRAM, 0);
sockaddr_in robot{};
robot.sin_family = AF_INET;
robot.sin_port = htons(30100);
::inet_pton(AF_INET, "192.168.1.100", &robot.sin_addr);
auto send_msg = [&](const external_control::ClientMessage& m) {
std::string buf = m.SerializeAsString(); // no length prefix
::sendto(fd, buf.data(), buf.size(), 0,
(sockaddr*)&robot, sizeof(robot));
};
// After the HTTP start with controlledBy:"api":
// 1) Subscribe 2) TakeControl
{ external_control::ClientMessage m;
m.mutable_subscribe(); send_msg(m); }
// ...Subscribe is unacknowledged: wait for one state
// broadcast before sending TakeControl...
{ external_control::ClientMessage m;
m.mutable_take_control(); send_msg(m); }
// ...read ServerMessage.control_response().success() before streaming...
// 3) Stream joint setpoints (position-only; add velocity only if
// your robot has the Streaming controller capability)
{
external_control::ClientMessage m;
auto* jc = m.mutable_joint_command();
for (double q : {0.0, -1.57, 1.57, 0.0, 1.57, 0.0})
jc->add_position(q);
send_msg(m); // repeat at a steady rate
}
// Receive state and control responses
char rx[65535];
sockaddr_in from{}; socklen_t flen = sizeof(from);
ssize_t n = ::recvfrom(fd, rx, sizeof(rx), 0,
(sockaddr*)&from, &flen);
external_control::ServerMessage sm;
sm.ParseFromArray(rx, static_cast<int>(n));
// sm.has_control_response()
// ? reply to take_control / release_control (no state)
// : periodic state broadcast (pose, joints, gripper if any)
// On exit: ReleaseControl, then Unsubscribe, then the HTTP stop.Stream joint_command at a steady rate of roughly 100 to
250 Hz with small per-tick position deltas, exactly as you would on the
ROS2 .../joints path. The same safety and limit enforcement
applies.
start returns
409 robot_not_idle. The message says
which case applies. state: RoutineRunning means a routine
or another session is running, so stop it. state: Failure
means the previous session ended on its own, so recover the robot and
then start. Otherwise, the arm has not come to rest, so
retry after a moment.start returns
400 brakes_must_be_disengaged. Disengage the
brakes as shown in Step 1, and re-check them after an E-stop or a power
loss.movement/ros/state endpoint
fails with 500 and
ControlSourceManager is enabled. That endpoint
belongs to the older ROS2 Realtime API, and a robot running external
control rejects it. Use external-control/start
instead.POST /api/v1/recovery/recover returns
500 recovery_error. The E-stop is still pressed,
or the arm is still powering off. Release the E-stop, or wait for arm
power, and then retry.recover succeeded after a collision, but
start is refused. The arm is still against the
obstruction, and recover does not move it. Move the arm off
the obstruction under guided control from the robot UI, and then
start.ros2 topic list shows no
/external/* topics. Check
ros2 node list. If nothing from the robot appears,
ROS_DOMAIN_ID or RMW_IMPLEMENTATION does not
match the robot, or you are not on the robot’s wired subnet. If
/external_control_ros_bridge is listed but there are no
topics, your machine reaches only one of the robot’s advertised
addresses. Move to the wired subnet, or have the operator restrict the
robot’s ROS2 interfaces.start, no
topics ever appear, and stop returns
stopped: false. The robot could not launch the
bridge, either because the wired customer port has no IP address or
because the robot lacks the ROS2 distribution you asked for. Recover the
robot, check the wired link, and try the other controlledBy
value. If both fail, contact Standard Bots.streamType, or the robot is stopped, which
GET /api/v1/recovery/status shows. If
.../status is also silent, the bridge never obtained
control because another client holds the writer slot, typically a UDP
client that sent TakeControl. Release or stop that client,
or stop and start again..../status shows
engaged=false reason=send_<op>-failed. One
command could not be delivered, and the bridge re-takes control on your
next message. If it recurs, the robot is rejecting your commands, most
likely because you are publishing to a topic that does not match the
session’s streamType..../trajectory or .../cancel does
nothing, with no error anywhere. Your robot lacks
Trajectory execution. Use joints or
pose instead./external/* topic
vanishes. The bridge was torn down, and nothing is published at
teardown. Check GET /api/v1/recovery/status.
Failure means the session ended on a fault, a stall, or a
pause, and failure.reason names which, so recover the robot
and then start. RoutineRunning means the
bridge process died while the session stayed up, so stop
and then start for a fresh bridge. Idle means
someone called stop, so start again.jointTrajectory at
time_from_start = 0. Stream
sensor_msgs/JointState at a steady rate with small per-tick
deltas, and never use a one-point trajectory as a setpoint.orientation, a NaN or infinite position or twist, and a
velocity that is not one per position or is non-finite. If
every setpoint from the first one is dropped, the watchdog never arms
and the session stays open. If drops begin later, the stream stalls and
the session ends.STREAM_JUMP,
TRACKING_ERROR_EXCEEDED, or
IN_SINGULARITY. STREAM_JUMP means
consecutive setpoints are too far apart, so interpolate on your side.
TRACKING_ERROR_EXCEEDED means the target ran away from the
arm, because of an unreachable pose, motionScalingLimits
set too tight, or a far first setpoint with
rampInDurationMs: 0. IN_SINGULARITY means the
path crosses a singularity, so re-plan it. The bounds are listed in the
Controller Faults on a Pose Stream section.twist.
Send a zero twist when you intend to hold.commandTimeoutMs,
or the robot was paused from its UI. failure.reason in
GET /api/v1/recovery/status says which, reading either
code=CLIENT_STREAM_TIMEOUT or
the routine was paused. Check your publisher, recover the
robot, and then start.start keeps answering 409 with
state: Failure after recover.
recovery_type says who can recover the robot.
Recoverable means you can call recover
yourself. Anything else needs someone at the robot to release the
E-stop, restore arm power, or recover in the UI or with the wrist
button.On the direct UDP interface:
ServerMessage ever arrives. The
host or port is wrong, you never sent Subscribe, you did
not bind a local port for the reply, or the robot’s arm-state stream is
not flowing. A session is not required to receive state.
Subscribe is not acknowledged, so with no broadcasts you
cannot tell “not subscribed” apart from “robot not publishing”. Check
the robot first.take_control is answered
not subscribed right after Subscribe.
The TakeControl overtook the unacknowledged
Subscribe. Wait for one broadcast first, and on
not subscribed, re-send Subscribe and retry
once. After a long silence, the same answer means you were evicted, as
described below.ControlResponse.reason tells
you, or no session is running. The writer slot is granted independently
of the session, and without a session every arm setpoint is dropped
silently, except gripper commands. Call start with
controlledBy: "api", and then stream.take_control is answered not subscribed, you
were evicted after 5 seconds without an accepted message. Send a
Heartbeat at least every 5 seconds, remembering that
setpoints from a client that is not the writer do not count, and then
re-send Subscribe. If broadcasts stop while you are
heartbeating, the robot’s arm-state stream has stalled, so check the
robot UI.servoConfig is accepted and ignored and there is no command
watchdog. Without Trajectory execution,
.../trajectory and .../cancel, and their UDP
counterparts, are accepted and ignored.servoConfig.referenceFrame with Streaming
controller, and the flange in the base frame without it. The
readback is always the flange. No TF transform is applied, and
frame_id is ignored.streamType.
Switch by restarting the session. Gripper and cancel are available in
any session.GET /api/v1/recovery/status is the durable record of
why a session ended./<robotId>/external/<topic>, or at
/local/external/<topic> on a robot with no identity
configured. They cannot be remapped from your side, so remap on your own
nodes instead.