Files
dora-ulite6/AGENTS.md
T
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

12 KiB

AGENTS.md

This file provides guidance to AI coding assistants when working with code in this repository.

What this is

A dora-rs Python node that drives a UFactory Lite6 arm. Every operation is a service — one request, one response — and the arm's joint angles, tool pose and connection state are published as ordinary topics.

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 robot layer (littlehand/robot/), which was written against this same controller. Fixes made there belong here, and vice versa.

Build

uv sync              # runtime deps
uv sync --group dev  # plus pytest and ruff

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

dora run dataflow.yml --uv

Everything is configured through environment variables — see README.md for the full list.

Test

uv run pytest      # 86 tests, no robot and no dataflow needed
uv run ruff check .

Architecture

Two things carry the design. Who may touch the SDK, and why the arm gets threads of its own.

The xArm SDK blocks: a move waits out the motion, and a read from a controller whose cable was pulled blocks until its own socket timeout. One thread would therefore have to choose between carrying out a motion and reporting where the arm is, and since a motion service does not answer until it finishes, telemetry is the only thing a caller can watch while it runs. So:

  • 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. Its wait between connection attempts serves the queue (_serve_while_down) rather than sleeping through it: a request is answered exactly once whatever the arm is doing, and an arm that is down is an outcome to report, not a reason to go quiet. A motion is refused there rather than held, since a held one would fire unattended the moment the link came back. 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

  • dora Python API (dora): Node(name), node.send_output(id, data, metadata), event iteration via for event in node. The request_id rides in event["metadata"] and in the metadata dict passed to send_output.
  • 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

  • Issue tracker: Gitea. Use the Gitea MCP tools (mcp__gitea__*) to read and manage issues, pull requests, and other repository operations.

Rules

  • Do not mention AI tools in commit messages or documentation.
  • 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.