Documents the eleven services, the wait flag, motion_state and preemption, and the lifecycle guarantees around restart and shutdown. AGENTS.md records the two rules that are easy to break by accident: the stopping event must stay set-to-stop, because an event that is set while running makes every wait() return at once and turns each cadence into a busy loop; and every request gets exactly one response, since a caller has no other way to know its request finished.
198 lines
11 KiB
Markdown
198 lines
11 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 # 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. 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.
|