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. Routesrequest,jogandtick; reads therequest_idout of the event metadata; publishes what the arm threads produced. It never calls anything that can block,get_stateincluded — 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 theNodebefore starting the arm threads, soINIT_MODEcannot 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, theINIT_MODEmove, 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, sotcp_posekeeps flowing right through a move. Nothing outside this file may call the driver, exceptstopandread_report, both written for another thread. Same lesson, same shape aszed::Capturein 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_stopre-commands the controller for up to three seconds. Requests to it coalesce.start_motionandstart_jogare the two preemption doors, both over_preempt_motions: mark the running motion and any still waiting, ask the stopper to halt the arm, publishaccepted, and acknowledge the caller whenwaitis false._stopped_for_preemptis 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.shutdownhalts 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'sUFactoryRobot. 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_movecalls that refusal finished, andarm.error_codeis 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_reportis 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_ARMis what preempts, is preemptable, and reportsmoving.IMMEDIATEis 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 whyget_statereads 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:UnreachablePoseErroris not aRobotUnavailableErrorbecause the arm is fine and the target is not, andArmNeedsHomingErroris 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—RobotPoseandwith_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 unusableTOOL_TYPEorINIT_MODEraises before the node joins the dataflow. -
dora-node.yml— Node manifest (apiVersion: 1).dora validateinjects these types into any dataflow that reaches the node bypath:; agit:-sourced node does not pick it up, which is whydataflow.ymlbuilds from the checkout. -
examples/caller.pyandexamples/dora-node.yml— The convention in one file: one instant request, one that drives the arm, andtcp_posearriving 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 anddora validate --strictwould 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 viafor event in node. Therequest_idrides inevent["metadata"]and in themetadatadict passed tosend_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 cachedposition/anglesproperties 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.
_stoppingis 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_intervalis there to catch it coming back.- Every request gets exactly one
response, under its ownrequest_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. Await: falsemotion spends its response on the acknowledgement, so its outcome goes onmotion_stateand nowhere else. motion_stateis keyed byrequest_idand publishesacceptedbefore 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.