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

203 lines
12 KiB
Markdown

# 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/<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
```bash
dora run dataflow.yml --uv
```
Everything is configured through environment variables — see README.md for the full list.
## Test
```bash
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.