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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user