docs: rewrite README and AGENTS for the service node
validate / validate (pull_request) Successful in 58m45s
validate / test (pull_request) Successful in 58m51s
validate / markdownlint (pull_request) Successful in 58m44s

Documents the eleven services, the wait flag, motion_state and
preemption, and the lifecycle guarantees around restart and shutdown.

AGENTS.md records the two rules that are easy to break by accident: the
stopping event must stay set-to-stop, because an event that is set while
running makes every wait() return at once and turns each cadence into a
busy loop; and every request gets exactly one response, since a caller
has no other way to know its request finished.
This commit is contained in:
2026-09-12 11:47:06 -03:00
parent 4ff584e9b5
commit 412aeac955
2 changed files with 416 additions and 115 deletions
+166 -28
View File
@@ -4,56 +4,194 @@ This file provides guidance to AI coding assistants when working with code in th
## What this is ## What this is
A dora-rs Python node that interfaces with a UFactory Lite6 robot arm. On each tick it reads joint angles A dora-rs Python node that drives a UFactory Lite6 arm. Every operation is a **service**
and TCP pose from the robot and publishes them as dora outputs. It also accepts JSON commands — one request, one response — and the arm's joint angles, tool pose and connection state
(home, move_to, move_to_pose, vacuum_on/off) via a `command` input. A FastAPI REST server with a web UI are published as ordinary topics.
runs in a background thread for manual control. The robot control wrapper lives in `dora_ulite6/main.py`
as the `ULite6Helper` class, and the dora event loop is the `main()` function in the same file. There is deliberately **no action pattern** here: no goals, no feedback, no cancel. A
motion is a service that takes a `wait` flag, and its lifecycle is published as **the
arm's state** (`motion_state`) rather than as a per-goal stream. That is the right model
because the arm is one resource: there is never more than one motion, so a new one
preempts the old, and a state describes that exactly where an action pattern would be
modelling concurrency that cannot physically exist. Do not reintroduce
goal/feedback/result.
The service pattern is **dora's own**, not a ROS2 port. The correlation key `request_id`
is defined in `dora-message`, and dora's runtime treats a port carrying it as
pattern-correlated: several Arrow schemas may legitimately flow through it, one per
service, and runtime type checks are skipped for those messages. That is why `request`
and `response` in `dora-node.yml` are deliberately untyped, and annotating them would be
a lie.
dora's `ros2:` service bridge is one consumer of the same convention, so a ROS2 front end
is a dataflow change rather than a code change. Do not add a ROS2 dependency here.
It served a `command` JSON input and a `status` output through `v0.2.0`, and ran a
FastAPI REST server with a web UI through `v0.1.2`. Both remain reachable at those tags.
The driver layer is ported from the
[littlehand](https://gitea.liria.cl/Littlehand/monolitic-littlehand-python) robot layer
(`littlehand/robot/`), which was written against this same controller. Fixes made there
belong here, and vice versa.
## Build ## Build
```bash ```bash
uv pip install -e . uv sync # runtime deps
uv sync --group dev # plus pytest and ruff
``` ```
Requirements: Python >=3.8, uv, and network access to the UFactory Lite6 robot. `dora build --uv` / `dora run --uv` rewrites the manifest's `build: pip install .` to
`uv pip install .` and gives the node its own managed venv under
`.dora/python-envs/<node-id>/`, so what it imports at runtime is what was installed at
build time.
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and
network access to the arm's controller.
## Run ## Run
```bash ```bash
dora up dora run dataflow.yml --uv
dora start dataflow.yml
``` ```
Robot behavior is configured through environment variables (see README.md for the full list: Everything is configured through environment variables — see README.md for the full list.
ROBOT_IP, DEFAULT_SPEED, DEFAULT_UNITS, API_HOST, API_PORT, VACUUM_ENABLED, INIT_MODE, etc.).
## Test
```bash
uv run pytest # 83 tests, no robot and no dataflow needed
uv run ruff check .
```
## Architecture ## Architecture
- **`dora_ulite6/main.py`** -- Single-file implementation containing: Two things carry the design. **Who may touch the SDK**, and **why the arm gets threads of
- `ULite6Helper`: Wraps the xArm Python SDK for motion control (go_home, move_to_pose, its own**.
get_current_position, get_joint_angles_deg, get_pose_matrix, vacuum gripper, status, reset).
- `MoveToRequest` / `MoveToPoseRequest`: Pydantic models for API validation. The xArm SDK blocks: a move waits out the motion, and a read from a controller whose
- `create_api()`: FastAPI app factory with REST endpoints and embedded HTML/JS web UI for manual control. cable was pulled blocks until its own socket timeout. One thread would therefore have to
- `main()`: Dora event loop. Starts FastAPI in a daemon thread, then processes `tick` (publishes choose between carrying out a motion and reporting where the arm is, and since a motion
joint_pose and tcp_pose) and `command` (executes robot actions and publishes status) inputs. service does not answer until it finishes, telemetry is the only thing a caller can watch
- **`dora_ulite6/__init__.py`** -- Package marker. while it runs. So:
- **`pyproject.toml`** -- Package metadata, dependencies, and entry point.
- **`dataflow.yml`** -- Example dora dataflow using the git: source format. - **`dora_ulite6/main.py`** — The dora event loop, and nothing else. Routes `request`,
`jog` and `tick`; reads the `request_id` out of the event metadata; publishes what the
arm threads produced. **It never calls anything that can block**, `get_state` included —
that one is allowed here only because it reads the report cache. A request it cannot
parse is refused here, under its own id: a caller waiting on a request_id must get
exactly one response, never silence. It builds the `Node` before starting the arm
threads, so `INIT_MODE` cannot move a robot before this process has joined the
dataflow, and so the arm can be told whether this is a restart.
- **`dora_ulite6/control.py`** — `Control`: both arm threads and the mailboxes the loop
drains. The **control thread** runs one job at a time and is allowed to block: startup,
the `INIT_MODE` move, and every service including the motions that take seconds. The
**reporter thread** never blocks: it reads the report cache at the telemetry cadence,
so `tcp_pose` keeps flowing right through a move. **Nothing outside this file may call
the driver**, except `stop` and `read_report`, both written for another thread. Same
lesson, same shape as `zed::Capture` in the ZED node.
The **stopper thread** exists because a stop fits nowhere else: it must not queue
behind the motion it aborts, and it must not run on the event loop either, since the
SDK's `emergency_stop` re-commands the controller for up to three seconds. Requests to
it coalesce.
`start_motion` and `start_jog` are the two preemption doors, both over
`_preempt_motions`: mark the running motion and any still waiting, ask the stopper to
halt the arm, publish `accepted`, and acknowledge the caller when `wait` is false.
`_stopped_for_preempt` is only ever *set* there and cleared by the motion that consumes
it — preempting a motion that had not started stops nothing, and must not swallow a
clear owed by an earlier preemption that did.
`shutdown` halts the arm **first** and then joins, returning whether the control thread
really came back. A motion lives in the controller, not in this process.
- **`dora_ulite6/driver.py`** — `ULite6`: the arm over the vendor SDK. Ported from
littlehand's `UFactoryRobot`. Its whole point is that a command either moved the arm or
raised: the SDK returns a code for a motion the controller quietly refused, `wait_move`
calls that refusal finished, and `arm.error_code` is the report stream's stale copy — so
errors are read live (`_live_error_code`), arrival is confirmed (`_check_arrived`) and a
move is refused before it is commanded if the straight-line path leaves the joint
envelope (`_check_path_reachable`). `read_report` is the deliberate exception: a cache
read, no round trip, the only read that works while a motion blocks.
- **`dora_ulite6/services.py`** — Every operation the node serves, and the two sets that
matter. `MOVES_THE_ARM` is what preempts, is preemptable, and reports `moving`.
`IMMEDIATE` is served by the event loop rather than the control thread, so it answers
*during* a motion — adding to it is a promise that the service never blocks and is safe
off the control thread, which is why `get_state` reads the report cache rather than the
wire.
- **`dora_ulite6/payload.py`** — Reading arguments out of the one-row Arrow struct a
request arrives as. Assumes no fixed schema, because there isn't one. Arguments are
read before the arm is touched, so a malformed request never half-moves it.
- **`dora_ulite6/errors.py`** — What the driver raises. The distinctions decide the
response: `UnreachablePoseError` is not a `RobotUnavailableError` because the arm is fine
and the target is not, and `ArmNeedsHomingError` is separate because nothing but a
motion of ours clears it and nobody should drive an arm home without warning the room.
- **`dora_ulite6/effectors.py`** — What is bolted to the flange: which driver method
switches it and how far past the flange its tip sits. A mismatch is refused when the arm
is built, not mid-motion.
- **`dora_ulite6/pose.py`** — `RobotPose` and `with_tool_offset`, which slides a pose along
its own Z axis. The offset inverts exactly, which is what lets goals and telemetry share
a frame.
- **`dora_ulite6/controller.py`** — Facts about the xArm controller: its modes, the codes
that mean the command never ran, and the Euler maths. Nothing tunable.
- **`dora_ulite6/config.py`** — Every environment variable, parsed once at startup. An
unusable `TOOL_TYPE` or `INIT_MODE` raises before the node joins the dataflow.
- **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these
types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does
**not** pick it up, which is why `dataflow.yml` builds from the checkout.
- **`examples/caller.py`** and **`examples/dora-node.yml`** — The convention in one file:
one instant request, one that drives the arm, and `tcp_pose` arriving while the latter
is still open.
The manifest next to it exists because dora resolves a node's manifest by walking up
from its path: without one, the caller would be matched against the arm node's manifest
and `dora validate --strict` would fail on the entrypoint mismatch.
- **`.gitea/workflows/validate.yml`** — `dora validate`, ruff, pytest and markdownlint.
None of them need a robot.
## Two manifest rules worth knowing
A manifest applies to a node iff `<manifest dir>/<entrypoint>` names the same file as
`<working dir>/<path>`. That is why `examples/dora-node.yml` says `entrypoint: caller.py`
and not `examples/caller.py`.
`dora validate --strict` injects a contract type for *every* typed manifest input and then
warns `input_types key "..." not found in inputs list` for any the dataflow does not wire.
A typed optional input therefore forces the example dataflow to wire it. `request` and
`response` dodge this only because they are untyped for the reason given above.
## Key APIs ## Key APIs
- **dora Python API** (`dora`): `Node()`, `node.send_output()`, event iteration via `for event in node`. - **dora Python API** (`dora`): `Node(name)`, `node.send_output(id, data, metadata)`,
- **xArm SDK** (`xarm.wrapper.XArmAPI`): `connect()`, `motion_enable()`, `set_mode()`, `set_state()`, event iteration via `for event in node`. The `request_id` rides in `event["metadata"]`
`set_position()`, `move_gohome()`, `get_position()`, `get_servo_angle()`, `set_vacuum_gripper()`. and in the `metadata` dict passed to `send_output`.
- Output data is sent as PyArrow arrays with metadata dicts. - **xArm SDK** (`xarm.wrapper.XArmAPI`): `connect()`, `motion_enable()`, `set_mode()`,
`set_state()`, `set_position()`, `move_gohome()`, `get_position()`, `get_servo_angle()`,
`get_err_warn_code()`, `get_inverse_kinematics()`, `is_joint_limit()`,
`set_vacuum_gripper()`, `open_lite6_gripper()`, `vc_set_cartesian_velocity()`, and the
cached `position` / `angles` properties the reporter thread reads.
## Tools ## Tools
- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage issues, - **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage
pull requests, and other repository operations. issues, pull requests, and other repository operations.
## Rules ## Rules
- Do not mention AI tools in commit messages or documentation. - Do not mention AI tools in commit messages or documentation.
- This controls a real robot. Test changes carefully and preserve safety checks. - This controls a real robot. Test changes carefully and preserve the safety checks: the
reachability check before a move, the arrival confirmation after it, and the live error
reads that make both of them mean anything.
- `_stopping` is set to ask the threads to finish, and it is what they sleep on. Never
invert it back to a flag that is set *while running*: `Event.wait(timeout)` returns at
once on an event that is already set, which turns every cadence in this file into a
busy loop. That bug shipped once and cost a core and 1.4 million arm reads a second,
while behaving correctly in every visible way. `test_the_reporter_honours_the_telemetry_interval`
is there to catch it coming back.
- Every request gets exactly one `response`, under its own `request_id`. A path that can
drop one, or send two, is a bug however unlikely it looks — a caller has no other way
to know its request finished. A `wait: false` motion spends its response on the
acknowledgement, so its outcome goes on `motion_state` and nowhere else.
- `motion_state` is keyed by `request_id` and publishes `accepted` before the control
thread reaches the motion. Both exist to close one race: a caller polling a bare
moving/idle flag right after its request would read "not moving" and conclude the
motion had finished. Do not reduce it to a two-valued flag.
+250 -87
View File
@@ -1,117 +1,280 @@
# Dora ULite6 Node # dora-ulite6
Dora node for controlling a UFactory Lite6 robot via REST API, web UI, and publishing joint/TCP state. Dora node for a UFactory Lite6 arm. Every operation is a **service** — dora's own
request/response pattern — and the arm's joint angles, tool pose and connection state
are published as ordinary topics.
## Installation One request, one response, correlated by the `request_id` you put in the message
metadata. That is the node's whole control surface: there is no second protocol for the
slow operations. A motion takes a `wait` flag — hold me until the arm stops, or answer me
now and I will follow it on `motion_state`.
```bash Correlation lives in the message metadata because that is dora's convention, not a ROS2
uv pip install -e . transliteration: `request_id` is defined in dora's own message crate, and the runtime
treats a port carrying it as one where several Arrow schemas legitimately flow — one per
service.
The arm runs on threads of its own, so a move that takes ten seconds — or a controller
whose cable was pulled — never stalls the dora event loop, and `tcp_pose` keeps arriving
throughout.
The driver is ported from the
[littlehand](https://gitea.liria.cl/Littlehand/monolitic-littlehand-python) robot layer,
which was written against this same controller: typed failures, a reachability check
before a move is commanded, and arrival confirmed rather than assumed.
## The services
| Service | Arguments | Moves the arm | Description |
| ------- | --------- | ------------- | ----------- |
| `move_to` | `x`, `y`, `z`, optional `roll`, `pitch`, `yaw`, `speed`, `wait` | yes | Move the tool tip to a base-frame pose. Orientation defaults to tool-down (`180, 0, 0`). Refused before anything moves if the straight-line path leaves the joint envelope. |
| `move_joints` | `angles` (list, degrees), optional `speed`, `wait` | yes | Move the joints directly. |
| `home` | optional `wait` | yes | Joint-space home. |
| `home_and_clear` | optional `wait` | yes | Clear a latched error, then drive home. The answer to a joint-limit error, and only to be sent once the area is clear. |
| `stop` | — | no | Halt the arm. Served by a thread of its own, so it neither queues behind the motion it aborts nor holds up the dataflow; the response says the stop was commanded, and whether it took shows on `motion_state`. |
| `clear` | — | no | Clear a latched controller error. Never moves the arm. |
| `recover` | — | no | Reconnect if needed, clear, re-enable motion. Never moves the arm. |
| `freedrive` | `enabled` (bool, default `true`) | no | Enter or leave manual teach mode. |
| `grasp` | — | no | Take hold with whatever `TOOL_TYPE` names. |
| `release` | — | no | Let go. |
| `get_state` | — | no | Adds `connected`, `ready`, `tool_engaged`, `freedrive`, `pose`, `joints` to the response. Served on the event loop and read from the controller's report cache, so it answers *during* a motion. |
The four that move the arm report `moving` on `robot_state` while they run, and so does
a jog — an arm that is travelling never looks idle. `stop` interrupts whatever is moving;
the interrupted request comes back `ok: false` saying it was aborted.
## Calling a service
Send a one-row struct on `request`, with your own id in the metadata:
```python
node.send_output(
"request",
pa.array([{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0, "speed": 40.0}]),
metadata={"request_id": "move-1"},
)
``` ```
## Dataflow The answer arrives on `response` under the same `request_id`:
```python
{"ok": True, "service": "move_to", "message": "the tool is at (250, 0, 200 mm)"}
```
Exactly one response per request, always — including for a request that was refused
before the arm was touched. Because the answer is matched by the id in the metadata and
not by arrival order, several requests can be in flight at once.
Poses are the **tool tip**, the same frame `tcp_pose` reports, so the arm can be sent
back to a pose that was read off the dataflow. The tool's reach is taken back off before
the flange is commanded.
## Blocking and non-blocking motions
`wait` decides only *when* the one response is sent.
```python
# wait: true (the default) — the response arrives when the arm has stopped.
{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0}
# wait: false — the response arrives at once, as an acknowledgement.
{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0, "wait": False}
```
Either way it is **one response per request**. With `wait: false` that response is the
acknowledgement, and how the motion actually ended is reported on `motion_state` — never
as a second response, which would break the id a caller correlates by.
### Following a motion: `motion_state`
Published on every change, as `{request_id, service, state, message}`:
| State | Meaning |
| ----- | ------- |
| `accepted` | Taken, not started. |
| `moving` | The arm is being driven. |
| `done` | Finished; the arm arrived. |
| `failed` | The arm refused it, or never got there. |
| `preempted` | A newer motion took its place. |
`accepted` is not decoration. Without it, a caller that sends a motion and then polls a
two-valued `moving`/`idle` state reads "not moving" in the window before the control
thread picks the request up, and concludes the motion it just asked for had already
finished. Keying the state by `request_id` and publishing `accepted` closes that window:
the caller can always tell *"mine has not started"* from *"mine has finished"*.
The `request_id` is in the fields rather than the metadata, because this is a broadcast of
the arm's state rather than an answer to a request — anyone may read it, and
`get_state` sees the same value.
`tcp_pose` also keeps arriving throughout, at `TELEMETRY_INTERVAL_MS`: the thread that
publishes it is not the thread inside the motion.
### A new motion preempts the one in play
The arm is one resource — there is never more than one motion. A new motion cuts the
running one short (through the same stop a caller could send) and takes its place; a
motion still waiting its turn is dropped without ever moving anything. Each one is still
answered exactly once: by its response if a caller is still waiting, and on
`motion_state` as `preempted` either way.
So a sequence of positions is driven by the caller, one at a time — send the next when
you see `done`. Sending them all at once does not queue them; the last one wins.
## Jogging
`jog` is a topic, not a service: a velocity command expires on the controller's own TTL,
so a sender keeps sending and nobody waits for an answer.
```python
node.send_output("jog", pa.array([{"velocity": [20.0, 0, 0, 0, 0, 0]}]))
```
It still drives the arm, so it behaves like a motion where that matters: the first
non-zero jog preempts a motion in play and `robot_state` turns to `moving`. A zero
velocity stops it, and so does simply going quiet — the node returns to `ready` once
`VELOCITY_COMMAND_TTL_S` has passed, because that is when the controller has expired the
command.
## Telemetry
| Output | Type | Description |
| ------ | ---- | ----------- |
| `joint_pose` | `Float32` | The six joint angles in degrees, base to wrist. |
| `tcp_pose` | `Float32` | `[x, y, z, roll, pitch, yaw]` in mm and degrees: the tool tip in the base frame. |
| `robot_state` | `String` | `connecting`, `ready`, `moving`, `recovering` or `unavailable`, on every change. |
| `motion_state` | struct | Where the current motion stands — see above. |
`tick` sets how often the newest reading is *published*; `TELEMETRY_INTERVAL_MS` sets how
often the arm is *read*. A tick with nothing new publishes nothing rather than repeating
a stale pose.
## Usage
Add to your dataflow:
```yaml ```yaml
nodes: nodes:
- id: ulite6 - id: ulite6
git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git
git_checkout: v0.1.0 tag: v0.3.0
build: uv pip install -e . build: pip install .
path: dora_ulite6/main.py path: dora-ulite6
inputs: inputs:
tick: dora/timer/millis/10 tick: dora/timer/millis/100
outputs: [status, joint_pose, tcp_pose] request: planner/request
inputs: [command] outputs:
- response
- motion_state
- joint_pose
- tcp_pose
- robot_state
env: env:
ROBOT_IP: "192.168.1.192" ROBOT_IP: "192.168.1.192"
DEFAULT_SPEED: "30" TOOL_TYPE: "vacuum"
DEFAULT_UNITS: "mm"
API_HOST: "0.0.0.0"
API_PORT: "8080"
VACUUM_ENABLED: "false"
``` ```
Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own
managed environment.
### Runnable example
[`dataflow.yml`](dataflow.yml) builds the node from this checkout and wires
[`examples/caller.py`](examples/caller.py), which sends a non-blocking motion, follows it
on `motion_state`, and then preempts it with a second one:
```bash
dora run dataflow.yml --uv
```
Building from the checkout (`path:` + `build:`) rather than from a tag is what lets
`dora validate` pick up the manifest in [`dora-node.yml`](dora-node.yml).
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and
network access to the arm's controller.
## Lifecycle
The node connects to the dataflow **before** it touches the arm, so a startup that never
joins cannot have moved a robot first.
**On a restart** (`restart_policy` in your dataflow), `INIT_MODE` is skipped: the arm is
left wherever it was. Driving it home because the process came back is a motion nobody
asked for.
**On shutdown**, the arm is halted before the node exits. A motion lives in the
controller, not in this process — dropping the connection does not abort it — so exiting
without commanding a stop would leave an arm travelling with nobody left to stop it. If
the control thread is still inside a blocking SDK call afterwards, the node leaves
without it rather than hanging until dora loses patience.
## Configuration ## Configuration
| Env Variable | Default | Description | | Env Variable | Default | Description |
| ------------ | ------- | ----------- | | ------------ | ------- | ----------- |
| `ROBOT_IP` | `192.168.1.192` | Robot IP address | | `ROBOT_IP` | `192.168.1.192` | Controller address |
| `DEFAULT_SPEED` | `30` | Movement speed (mm/s) | | `TOOL_TYPE` | `none` | What is mounted: `none`, `vacuum`, `gripper` |
| `DEFAULT_UNITS` | `mm` | Position units (mm or m) | | `TOOL_OFFSET_MM` | `0.0` | Extra reach past the flange, on top of the tool's own |
| `API_HOST` | `0.0.0.0` | API server host | | `DEFAULT_SPEED` | `30` | Cartesian speed (mm/s) for a `move_to` with no speed |
| `API_PORT` | `8080` | API server port | | `DEFAULT_JOINT_SPEED` | `20` | Joint speed (deg/s) for a `move_joints` with no speed |
| `VACUUM_ENABLED` | `false` | Enable vacuum gripper controls | | `TELEMETRY_INTERVAL_MS` | `100` | How often the arm is read, and how often telemetry is produced |
| `TOOL_TYPE` | `none` | Mounted tool: `none`, `vacuum`, `gripper` | | `RECONNECT_INTERVAL_S` | `5.0` | How long to wait before retrying an unreachable arm |
| `TOOL_OFFSET_MM` | `0.0` | Additional Z-offset in mm added on top of the tool preset | | `INIT_MODE` | `none` | Startup positioning: `none`, `home`, `pose` |
| `INIT_MODE` | `none` | Startup positioning: `home`, `pose`, or `none` | | `INIT_X/Y/Z` | `300/0/250` | Startup pose position (mm, tool tip) |
| `INIT_X/Y/Z` | `300/0/250` | Initial pose coordinates (mm) | | `INIT_ROLL/PITCH/YAW` | `180/0/0` | Startup pose orientation (deg) |
| `INIT_ROLL/PITCH/YAW` | `180/0/0` | Initial pose orientation (deg) | | `INIT_SPEED` | `50` | Startup move speed (mm/s) |
| `INIT_SPEED` | `50` | Startup movement speed (mm/s) |
## Web UI The motion limits the driver enforces are tunable too, and the defaults are the ones
littlehand runs with. The arrival tolerances are wide on purpose: whether a motion
happened is decided by the controller's error, and these only catch an arm that never
left where it stood.
Access at `http://localhost:8080/` | Env Variable | Default | Description |
| ------------ | ------- | ----------- |
| `ARRIVAL_TOLERANCE_MM` | `25.0` | How far off the target still counts as arrived |
| `ARRIVAL_TOLERANCE_DEG` | `15.0` | The same for orientation, and for homing |
| `ARRIVAL_TIMEOUT_S` | `2.0` | How long to keep asking before calling a move interrupted |
| `JOINT_LIMIT_MARGIN_DEG` | `1.0` | How clear of its bound a joint must stay for a pose to be commanded |
| `PATH_SAMPLES` | `4` | How many points along the straight line are checked for reachability |
| `READY_TIMEOUT_S` | `3.0` | How long `recover` waits for a movable state |
| `HOME_TIMEOUT_S` | `20.0` | How long homing may take |
| `TOOL_SETTLE_S` | `2.0` | How long to wait for suction to build or fingers to close |
| `VELOCITY_COMMAND_TTL_S` | `0.5` | The controller's own expiry for one `jog` command |
- Live status, TCP pose, and joint angles ## Development
- Home and Reset buttons
- Move to position form
- Vacuum gripper controls (when enabled)
## REST API
Interactive docs at `http://localhost:8080/docs`
| Method | Endpoint | Description |
| ------ | -------- | ----------- |
| GET | `/` | Web control interface |
| GET | `/api/status` | Robot status (connected, errors) |
| GET | `/api/pose` | Current TCP pose |
| GET | `/api/joints` | Current joint angles |
| GET | `/api/config` | API configuration |
| POST | `/api/home` | Go to home position |
| POST | `/api/reset` | Clear errors and reset state |
| POST | `/api/move_to` | Move to position |
| POST | `/api/move_to_pose` | Move to full pose |
| POST | `/api/disconnect` | Disconnect from robot |
### Vacuum Gripper (when `VACUUM_ENABLED=true`)
| Method | Endpoint | Description |
| ------ | -------- | ----------- |
| GET | `/api/vacuum` | Get vacuum status |
| POST | `/api/vacuum/on` | Turn vacuum on |
| POST | `/api/vacuum/off` | Turn vacuum off |
## CLI Examples
```bash ```bash
# Get pose uv sync --group dev
curl http://localhost:8080/api/pose uv run pytest # 83 tests, no robot needed
uv run ruff check .
# Go home
curl -X POST http://localhost:8080/api/home
# Move to position
curl -X POST http://localhost:8080/api/move_to \
-H "Content-Type: application/json" \
-d '{"x": 200, "y": 0, "z": 300}'
# Move with orientation
curl -X POST http://localhost:8080/api/move_to_pose \
-H "Content-Type: application/json" \
-d '{"x": 200, "y": 0, "z": 300, "roll": 180, "pitch": 0, "yaw": 0}'
``` ```
## Dora Outputs ## Bridging to ROS2
- `status` - JSON: `{ok, action, message, timestamp_ns}` Nothing here depends on ROS2, and no ROS2 message definition is shipped. It is worth
- `joint_pose` - 6 joint angles in degrees knowing anyway that dora's `ros2:` service bridge speaks exactly this convention — it
- `tcp_pose` - `[x, y, z, roll, pitch, yaw]` in mm/deg — reflects tool tip when `TOOL_TYPE` is set emits `request` and takes a response under the same `request_id`. So exposing these
services to a ROS2 graph later is a matter of adding bridge nodes to a dataflow and
mapping the struct fields, with no change to this code.
## Dora Inputs ## Migrating from v0.2.x
- `command` - JSON string: `{id, action, payload}` - **The `command` input and the `status` output are gone**, replaced by `request` and
- `id`: unique command id (required) `response`. A command's `{id, action, payload}` JSON becomes a service request, and the
- `action`: `home`, `move_to`, or `move_to_pose` id moves from the payload into the message metadata, where dora's own pattern puts it.
- `payload`: fields for move (`x`, `y`, `z`, `roll`, `pitch`, `yaw`, `speed`) - **Payloads are Arrow structs, not JSON strings.** `pa.array([{...}])` instead of
`json.dumps`.
- **`action` is now `service`**, and `vacuum_on`/`vacuum_off` are `grasp`/`release`.
- **`jog` is its own input** rather than a command.
- **There is no `started` message.** A request produces exactly one response. Follow a
motion on `motion_state`, which reports `accepted`, `moving` and then how it ended.
- **Telemetry no longer stops while the arm is moving**, thanks to the reporter thread.
- **`stop` answers "commanded" rather than "done."** It is handed to a thread of its own
instead of being run inline, because the SDK spends up to three seconds confirming a
stop and a dataflow router cannot go away that long.
`status` will include `command_id` for command responses. ## Migrating from v0.1.x
See the v0.2.0 notes in the git history for the removal of the FastAPI REST server and
web UI (still reachable at tag `v0.1.2`), the `DEFAULT_UNITS` removal, and the move to
tool-tip poses.