_run_control never drained the job queue while the arm had not been reached, so every request that arrived during an outage waited for a connection that might never come. That breaks the one-response-per-request rule the node states as absolute, and it was circular: requests are queued before the arm is reached because recover is what a caller sends then, yet recover is served by the control thread and so was the one service that could never run while down. Serve the queue during the wait between connection attempts. Non-motion services are tried on the arm, which is what lets recover and clear reach a controller that answers while refusing to serve. A motion is refused rather than held: the arm is somewhere unknown and nobody is watching, so a held motion would fire unattended the moment the link came back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
12 KiB
Markdown
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 # 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 `<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.
|