`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.
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 # 92 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.
- A velocity is set on the controller once and held.
set_cartesian_velocitypasses no duration and skips an unchanged velocity, because re-arming makes the controller re-plan the same motion and the arm steps instead of travelling — four times a second is enough to feel it. Do not reintroduce a per-messageduration, and do not "simplify" the unchanged-velocity check away. _expire_jogis the deadman, not bookkeeping. Nothing else stops an arm whose sender died mid-jog: the controller holds the velocity indefinitely. It must command the stop, not merely reportready— an arm travelling while the node says idle is worse than no expiry at all.- 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.