Skip to content

Robots

The robots resource keeps the robots your episodes are recorded on and played on: register one, import its URDF, give it a gripper and line the gripper up on its arms. It acts in the API key’s own account, and every method takes a robot as an id, a full accounts/{a}/robots/{r} name or its display name. A display name is looked up among robots, then gripper designs. get, update, delete, get_model, import_model, update_model, set_gripper_tcp, import_gripper_calibration and jaw_holder take kind="robot" or kind="gripper" to look among that kind only, so pass kind="robot" when you want a robot. A gripper a robot owns is a copy named like its source: a display name never reaches it, so name it by its id, and so is a display name with a / in it. copy(gripper=) takes ids and full names only.

A robot owns its gripper. Attaching one copies a gripper’s URDF, TCP and jaw into a gripper of the robot’s own, so lining it up or editing its TCP changes that robot only, and the copy is a snapshot: recalibrating the handheld later does not change it. A robot that carries a gripper never swaps it. To try another gripper, copy the robot with that gripper and keep the original.

from qualia import Qualia
client = Qualia()
robot = client.robots.create("Lab arm", "franka_fr3") # name, robot type key
# A .urdf, a .zip of the URDF and its meshes, or a directory (zipped for you).
model = client.robots.import_model(robot.name, "lab_arm.urdf")
print(model.import_state, model.solver, [arm.hand_link for arm in model.arms])

import_model asks for a signed upload, PUTs the file (the API key never goes to storage), starts the import and polls until the server has converted the meshes. The file is at most 256 MiB. With wait=False it returns the PENDING model at once; follow it with wait_import(robot). A URDF the server cannot use (no link reached through three turning joints, for a robot) ends FAILED and raises QualiaError with the server’s reason. A wait that runs out raises TimeoutError and leaves the import running.

A directory is zipped for you: a file symlinked within it goes in with its content, and a link to a file outside it, a link to a directory, a broken link or a special file is refused by name (ValueError), never dropped or sent. A path that cannot be imported (a .xacro, a directory without a .urdf, an empty or oversize file) is refused before anything is sent.

create_from_urdf does both in one call, as the dashboard’s New embodiment does:

robot = client.robots.create_from_urdf("lab_arm.zip", name="Lab arm")
design = client.robots.create_from_urdf("parallel_gripper.urdf", kind="gripper")

The name defaults to the file’s; a taken name is refused, never renamed. When the file never reaches an import (the upload or the import call is refused), the record made for it is deleted again. An import that starts and then fails keeps the record, as the dashboard does, and the error names it. With request_id= the call is safe to run again: a retry follows the import its first call started, or has finished, without uploading anything, and it never deletes the record. It imports the file only when no import ran or the last one failed. With kind="gripper" it makes a gripper design, which robots copy; a gripper of one robot’s own is attach_gripper(urdf=).

list, get, update and delete do what they say:

page = client.robots.list() # oldest first: the first is the default target
robot = client.robots.get(robot.name) # carried_gripper: its own gripper and mounts
client.robots.update(robot.name, name="Lab arm 2", serial_number="SN-7")
client.robots.delete(robot.name, force=True) # force: even with a model

list(kind="gripper") lists the workspace’s gripper designs, such as the handheld’s, and the copies robots own (each named like its source, its owner the robot that carries it). A robot’s own is also robot.carried_gripper.gripper, or client.robots.gripper(robot.name). Delete a copy by its id and it comes off its robot, as detach_gripper does.

To play a dataset on a robot, set its target robot: client.datasets.update(dataset_id, target_robot=robot.name).

model = client.robots.model(robot.name)
client.robots.update_model(
robot.name,
solver="pyroki", # or "dls"
reach_base=[0.0, 0.0, 0.0], # [] clears it
arms=[
{
"side": "left",
"hand_link": "left_hand",
"tool": {"xyz": [0, 0, 0.1], "rpy": [0, 0, 0]},
},
{"side": "right", "hand_link": "right_hand"},
],
etag=model.etag, # a stale etag is refused (409)
)

Only the fields you pass change. arms replaces all of them, and arms read from model.arms can be passed back as they are. get_model is model for a caller that wants None instead of a 404 when no import ever started.

clear_gripper=True clears the model’s jaw. The jaw lives in the model of the gripper the robot carries, not always the robot’s: jaw_holder(robot) names the one to write. Moving an arm’s hand link leaves a gripper mount on the old link where it is: set the mounts again. qualia robot configure does that for you.

Copy a gripper design (the handheld’s, or a library gripper), or another robot’s own gripper:

handheld = client.robots.list(kind="gripper").items[0]
robot = client.robots.attach_gripper(robot.name, copy_from=handheld.name)
print(robot.carried_gripper.display_name, robot.carried_gripper.mounts)

Without mounts the copy is mounted at the hand link of every arm whose hand it has a TCP for, with no offset and guessed set. Or upload a URDF of the robot’s own (a file, .zip or directory):

robot = client.robots.attach_gripper(robot.name, urdf="parallel_gripper/")

That makes an empty gripper the robot owns and imports the URDF into it. It has no TCP yet, so set one before you mount it. The path is checked first (the same checks as import_model), so a bad one creates nothing. If a step after the gripper is created fails, the error is that step’s own (the same QualiaError with its status, or a ValueError or TimeoutError of the same class; one that cannot carry the note, such as a UnicodeDecodeError, becomes a plain ValueError or TimeoutError with the original as its cause; a file error becomes a QualiaError) with a note naming the gripper that stays attached: import into it again with import_model(gripper, ...), or detach_gripper(robot) to remove it.

set_gripper(robot, gripper) says the same in one call, for a caller that wants the robot to end up carrying gripper whatever it carries now:

robot = client.robots.set_gripper(robot.name, handheld.name) # the copy, as above
robot = client.robots.set_gripper( # the gripper it carries: only its mounts
robot.name, robot.carried_gripper.gripper, mounts=[client.robots.mount("left", "link7")]
)
client.robots.set_gripper(robot.name, None) # detach_gripper: deletes its own gripper

On a robot that carries none it is attach_gripper(copy_from=). Naming the gripper the robot carries sets its mounts; without mounts each arm keeps a mount that still hangs on its hand link and the rest start from the guess, and when nothing moves nothing is written. A mount with no pose has no offset. Any other gripper is refused: a robot never swaps its gripper, so copy the robot with it.

Response

Robot
{
"name": "accounts/11111111-1111-4111-8111-111111111111/robots/22222222-2222-4222-8222-222222222222",
"displayName": "Lab arm",
"hasModel": true,
"kind": "ROBOT",
"etag": "…",
"carriedGripper": {
"gripper": "accounts/11111111-1111-4111-8111-111111111111/robots/33333333-3333-4333-8333-333333333333",
"mounts": [
{
"side": "SIDE_LEFT",
"parentLink": "left_link7",
"matrix": { "m": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] },
"guessed": true
}
],
"etag": "…"
}
}

Per hand, a 4x4 matrix in a link of the gripper’s URDF ({side} stands for the hand’s side), from xyz and rpy (radians, URDF convention) or a matrix:

client.robots.set_gripper_tcp(
robot.name, # the robot that carries it, or the gripper itself
hands=[
{"side": "left", "link": "gripper_{side}_ee_link", "xyz": [0, 0, 0.12]},
{"side": "right", "link": "gripper_{side}_ee_link", "xyz": [0, 0, 0.12]},
],
)
# or a tcp_calibration.json, which also sets what handheld uploads resolve with
client.robots.import_gripper_calibration(robot.name, "tcp_calibration.json")

set_gripper_tcp(file=...) takes a JSON file instead (at most 64 KiB, like the calibration file):

{
"left": {
"link": "gripper_{side}_ee_link",
"matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0.12, 0, 0, 0, 1]
},
"right": {
"link": "gripper_{side}_ee_link",
"matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0.12, 0, 0, 0, 1]
}
}
client.robots.set_mounts(
robot.name,
[
client.robots.mount("left", "left_hand", xyz=[0, 0, 0.02]),
client.robots.mount(
"right", "right_hand", xyz=[0, 0, 0.02], rpy=[0, 0, 3.14159]
),
],
)
client.robots.set_jaw(robot.name, "{side}_finger_joint", open=0.04, shut=0.0)

A mount places the gripper’s hand link in a link of the robot (parent_link), which must be joined to the arm’s hand link by fixed joints only, and the hand needs a TCP in a link already. A mount with no pose sits on parent_link with no offset. baked=True says the robot’s own URDF has the gripper built in. set_mounts replaces all mounts, one per side at most.

set_jaw names the joint that opens the gripper and its position open and shut (and optionally home, never inferred). Without home it keeps the home the model already has for the same joint, and sends the etag it read so a concurrent edit is refused (409). It edits the model the jaw lives in: the carried gripper’s, or the robot’s own when its URDF has the gripper built in or it carries none.

On a workspace that has not been migrated yet, a robot may still carry a gripper shared with other robots. set_jaw, set_gripper_tcp and import_gripper_calibration refuse to edit it by way of a robot (ValueError, naming the gripper), because the edit would change every robot that carries it; pass the gripper itself to edit it for all of them. set_mounts is not affected: mounts are the robot’s own.

detach_gripper(robot.name) takes the gripper off, which deletes it: its URDF, meshes, TCP and jaw.

other = client.robots.copy(robot.name, "Lab arm, parallel gripper", gripper=gripper.name)

A copy takes the robot’s type, URDF and meshes, arms, solver, jaw, reach base and capsules, and a gripper of its own. It shares nothing with the original afterwards, and its serial number, datasets, reach results and history are not copied. gripper is "keep" (the default: a copy of the gripper the robot carries, with its mounts), "none", or a gripper or robot to copy a gripper from (a second call, so line it up with set_mounts). A copy is refused while the model is being imported or the capsules are being fitted: try again in a minute. request_id makes a retry return the first copy. "keep" and "none" are matched in any case, and any other gripper must be a robot id (a UUID) or resource name (accounts/.../robots/...): it is checked before the copy is made.

Copying with another gripper is two calls, the copy and then the attach. If the attach fails, the gripper-less copy stays, and the error is the attach’s own (same class, status and code) with a note naming the copy: give it a gripper with attach_gripper(copy, copy_from=...), or delete it. Retrying with the same request_id returns that copy and attaches again; a copy that already carries a gripper is returned as it is.

The first name in a row is the one this page teaches. Where a row names a second method, the two do the same action.

MethodDoes
list(kind=, limit=, cursor=)Robots oldest first (the first is the default target), or designs
get(robot, kind=)One robot or gripper
create(name, type, kind=, owner=, serial_number=, request_id=)A record with no model
update(robot, name=, type=, serial_number=, etag=, kind=)Name, type or serial; "" clears type or serial
delete(robot, force=False, kind=)The record, with the gripper it owns
copy(robot, name, gripper=, request_id=)A new robot from this one
model(robot), get_model(robot, kind=)Import state, arms, solver, jaw; a 404, or None if never imported
import_model(robot, path, wait=, timeout=, interval=, upload_timeout=, on_progress=, kind=)Import a URDF, .zip or directory, replacing the model
wait_import(robot, timeout=, interval=, on_progress=)Wait for an import under way
create_from_urdf(path, kind=, name=, type=, serial_number=, request_id=, wait=, timeout=, interval=)create and import_model in one
update_model(robot, arms=, solver=, gripper=, clear_gripper=, reach_base=, etag=, kind=)Arms, IK solver, jaw, reach base
gripper(robot)The gripper a robot carries, or None
attach_gripper(robot, urdf= | copy_from=, mounts=, name=, wait=, etag=)Give a robot that carries none a gripper of its own
detach_gripper(robot, etag=)Take it off, which deletes it
set_mounts(robot, mounts, etag=)Where it sits on each arm
set_gripper(robot, gripper | None, mounts=, etag=)attach_gripper, set_mounts or detach_gripper in one call
jaw_holder(robot, kind=), set_jaw(robot, joint, open, shut, home)The model the jaw lives in; set the jaw
set_gripper_tcp(gripper, hands= | file=, filename=, etag=, kind=)A gripper’s TCP as a matrix per hand
import_gripper_calibration(gripper, path, filename=, etag=, kind=)A gripper’s TCP from tcp_calibration.json

A name the account has is a 409 (ALREADY_EXISTS); a robot of another workspace is a 404; an edit made on a stale etag is a 409 (ABORTED); a gripper write without robots:write is a 403. A refused rule (a robot that already carries a gripper, a mount whose hand has no TCP) is a 400 whose message names it. See Error handling.