# Robots

> Register robots, import their models and give them a gripper with the Qualia SDK

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.

## Register a robot and import its model

```python
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:

```python
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:

```python
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 (templates and UMI
rigs), 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)`.

## Edit the model

```python
model = client.robots.model(robot.name)
client.robots.update_model(
    robot.name,
    solver="dls",  # "pyroki" only on a robot that holds its solver file
    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.

## Give a robot a gripper

Copy a gripper design (a gripper template or a UMI rig), or another robot's
own gripper:

```python
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):

```python
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:

```python
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**

```json title="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": "…"
}
}
```

### Set the TCP

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`:

```python
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):

```json
{
  "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]
  }
}
```

### Make a UMI rig

A handheld (UMI) recording is converted at a UMI rig: a gripper made from a gripper
template (New rig, UMI in the dashboard makes the same rig). A workspace has the rigs
someone made and none otherwise (nothing makes one when you list grippers), and an
upload names the one it was recorded on:

```python
rig = client.robots.make_umi_rig("KIWI pair", "Kitchen UMI")  # a copy of the template
client.robots.import_gripper_calibration(rig.name, "tcp_calibration.json")
client.robots.umi_rigs()  # every UMI rig of the workspace
client.data.upload("/data/session", name="kitchen", rig="Kitchen UMI")
```

A template with a tracker-to-TCP calibration gives a rig that resolves uploads at
once. The rig is a copy that shares nothing afterwards, and deleting it
(`delete`) removes it for good. Making one needs `rigs:write` on the organization.

### Line it up and set the jaw

```python
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.

## Try another gripper: copy the robot

```python
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.

## Every method

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.

| Method                                                                                                 | Does                                                                |
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
| `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`                         |
| `make_umi_rig(gripper, name=, request_id=)`                                                            | A UMI rig from a gripper template                                   |
| `umi_rigs()`                                                                                           | The workspace's UMI rigs                                            |

## Errors

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](https://junction-docs.qualiastudios.dev/sdk/error-handling.md).
