# 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](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 ```bash 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//`, 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 ```bash dora run dataflow.yml --uv ``` Everything is configured through environment variables — see README.md for the full list. ## Test ```bash uv run pytest # 83 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 `/` names the same file as `/`. 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.