docs: rewrite README and AGENTS for the service node
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:
@@ -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
|
||||
uv pip install -e .
|
||||
Correlation lives in the message metadata because that is dora's convention, not a ROS2
|
||||
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
|
||||
nodes:
|
||||
- id: ulite6
|
||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git
|
||||
git_checkout: v0.1.0
|
||||
build: uv pip install -e .
|
||||
path: dora_ulite6/main.py
|
||||
tag: v0.3.0
|
||||
build: pip install .
|
||||
path: dora-ulite6
|
||||
inputs:
|
||||
tick: dora/timer/millis/10
|
||||
outputs: [status, joint_pose, tcp_pose]
|
||||
inputs: [command]
|
||||
tick: dora/timer/millis/100
|
||||
request: planner/request
|
||||
outputs:
|
||||
- response
|
||||
- motion_state
|
||||
- joint_pose
|
||||
- tcp_pose
|
||||
- robot_state
|
||||
env:
|
||||
ROBOT_IP: "192.168.1.192"
|
||||
DEFAULT_SPEED: "30"
|
||||
DEFAULT_UNITS: "mm"
|
||||
API_HOST: "0.0.0.0"
|
||||
API_PORT: "8080"
|
||||
VACUUM_ENABLED: "false"
|
||||
TOOL_TYPE: "vacuum"
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
| Env Variable | Default | Description |
|
||||
| ------------ | ------- | ----------- |
|
||||
| `ROBOT_IP` | `192.168.1.192` | Robot IP address |
|
||||
| `DEFAULT_SPEED` | `30` | Movement speed (mm/s) |
|
||||
| `DEFAULT_UNITS` | `mm` | Position units (mm or m) |
|
||||
| `API_HOST` | `0.0.0.0` | API server host |
|
||||
| `API_PORT` | `8080` | API server port |
|
||||
| `VACUUM_ENABLED` | `false` | Enable vacuum gripper controls |
|
||||
| `TOOL_TYPE` | `none` | Mounted tool: `none`, `vacuum`, `gripper` |
|
||||
| `TOOL_OFFSET_MM` | `0.0` | Additional Z-offset in mm added on top of the tool preset |
|
||||
| `INIT_MODE` | `none` | Startup positioning: `home`, `pose`, or `none` |
|
||||
| `INIT_X/Y/Z` | `300/0/250` | Initial pose coordinates (mm) |
|
||||
| `INIT_ROLL/PITCH/YAW` | `180/0/0` | Initial pose orientation (deg) |
|
||||
| `INIT_SPEED` | `50` | Startup movement speed (mm/s) |
|
||||
| `ROBOT_IP` | `192.168.1.192` | Controller address |
|
||||
| `TOOL_TYPE` | `none` | What is mounted: `none`, `vacuum`, `gripper` |
|
||||
| `TOOL_OFFSET_MM` | `0.0` | Extra reach past the flange, on top of the tool's own |
|
||||
| `DEFAULT_SPEED` | `30` | Cartesian speed (mm/s) for a `move_to` with no speed |
|
||||
| `DEFAULT_JOINT_SPEED` | `20` | Joint speed (deg/s) for a `move_joints` with no speed |
|
||||
| `TELEMETRY_INTERVAL_MS` | `100` | How often the arm is read, and how often telemetry is produced |
|
||||
| `RECONNECT_INTERVAL_S` | `5.0` | How long to wait before retrying an unreachable arm |
|
||||
| `INIT_MODE` | `none` | Startup positioning: `none`, `home`, `pose` |
|
||||
| `INIT_X/Y/Z` | `300/0/250` | Startup pose position (mm, tool tip) |
|
||||
| `INIT_ROLL/PITCH/YAW` | `180/0/0` | Startup pose orientation (deg) |
|
||||
| `INIT_SPEED` | `50` | Startup move 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
|
||||
- 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
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Get pose
|
||||
curl http://localhost:8080/api/pose
|
||||
|
||||
# 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}'
|
||||
uv sync --group dev
|
||||
uv run pytest # 83 tests, no robot needed
|
||||
uv run ruff check .
|
||||
```
|
||||
|
||||
## Dora Outputs
|
||||
## Bridging to ROS2
|
||||
|
||||
- `status` - JSON: `{ok, action, message, timestamp_ns}`
|
||||
- `joint_pose` - 6 joint angles in degrees
|
||||
- `tcp_pose` - `[x, y, z, roll, pitch, yaw]` in mm/deg — reflects tool tip when `TOOL_TYPE` is set
|
||||
Nothing here depends on ROS2, and no ROS2 message definition is shipped. It is worth
|
||||
knowing anyway that dora's `ros2:` service bridge speaks exactly this convention — it
|
||||
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}`
|
||||
- `id`: unique command id (required)
|
||||
- `action`: `home`, `move_to`, or `move_to_pose`
|
||||
- `payload`: fields for move (`x`, `y`, `z`, `roll`, `pitch`, `yaw`, `speed`)
|
||||
- **The `command` input and the `status` output are gone**, replaced by `request` and
|
||||
`response`. A command's `{id, action, payload}` JSON becomes a service request, and the
|
||||
id moves from the payload into the message metadata, where dora's own pattern puts it.
|
||||
- **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.
|
||||
|
||||
Reference in New Issue
Block a user