`set_cartesian_velocity` passed `duration=velocity_command_ttl_s` on every message, so each one re-armed a timed segment and the controller re-planned the same motion. On a real Lite6 that is not smooth travel: the arm moves in steps. Reducing how often the velocity is sent does not help — re-arming four times a second still steps — so it now uses `duration=0`, the SDK's "always effective, will not stop automatically", and skips a velocity that has not changed. One command stands until a different one replaces it. That removes the controller-side expiry, which `_expire_jog` was relying on: it flipped `_jogging` and reported `ready` without commanding anything, because the controller's own timer had already stopped the arm. With the velocity held indefinitely that would leave an arm travelling while the node reported idle, so `_expire_jog` now queues the stop itself. It goes through the job queue rather than the driver, since it runs on the reporter thread and the control thread owns the driver. `VELOCITY_COMMAND_TTL_S` is unchanged in value and meaning to a caller, but it is now enforced here rather than by the controller. Both halves are one commit on purpose: `duration=0` without the stop in `_expire_jog` is an arm that never stops when its sender dies. Adds tests/test_driver.py — the driver had no direct coverage, because FakeArm replaces it everywhere else, which is why this was invisible to the suite.
289 lines
14 KiB
Markdown
289 lines
14 KiB
Markdown
# dora-ulite6
|
|
|
|
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.
|
|
|
|
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`.
|
|
|
|
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"},
|
|
)
|
|
```
|
|
|
|
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 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 stops the arm and returns to
|
|
`ready` once `VELOCITY_COMMAND_TTL_S` has passed.
|
|
|
|
**The velocity is set once and held.** The controller is given a velocity with no
|
|
duration, so it keeps it until a different one arrives: re-sending an unchanged velocity
|
|
does not reach the arm at all. Sending it repeatedly re-armed a timed segment and made the
|
|
controller re-plan the same motion, which on a real arm is felt as stepping rather than
|
|
travel — even at four messages a second.
|
|
|
|
That also means **the controller no longer stops the arm on its own**, so
|
|
`VELOCITY_COMMAND_TTL_S` is enforced here instead. It is a real deadman now rather than a
|
|
description of one: a sender that dies mid-jog is what it exists for.
|
|
|
|
## 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
|
|
tag: v0.3.0
|
|
build: pip install .
|
|
path: dora-ulite6
|
|
inputs:
|
|
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"
|
|
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` | 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) |
|
|
|
|
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.
|
|
|
|
| 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` | How long a `jog` stream may go quiet before this node stops the arm |
|
|
|
|
## Development
|
|
|
|
```bash
|
|
uv sync --group dev
|
|
uv run pytest # 92 tests, no robot needed
|
|
uv run ruff check .
|
|
```
|
|
|
|
## Bridging to ROS2
|
|
|
|
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.
|
|
|
|
## Migrating from v0.2.x
|
|
|
|
- **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.
|
|
|
|
## 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.
|