Files
dora-ulite6/README.md
cristhianandClaude Opus 5 8cd4db93f1
validate / markdownlint (pull_request) Successful in 58m43s
validate / validate (pull_request) Successful in 58m48s
validate / test (pull_request) Successful in 58m55s
docs: update the test count
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:32:43 -05:00

13 KiB

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

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:

{"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.

# 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.

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:

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 builds the node from this checkout and wires examples/caller.py, which sends a non-blocking motion, follows it on motion_state, and then preempts it with a second one:

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.

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 The controller's own expiry for one jog command

Development

uv sync --group dev
uv run pytest      # 86 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.