docs: rewrite README and AGENTS for the service node
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.
This commit is contained in:
@@ -4,56 +4,194 @@ This file provides guidance to AI coding assistants when working with code in th
|
||||
|
||||
## What this is
|
||||
|
||||
A dora-rs Python node that interfaces with a UFactory Lite6 robot arm. On each tick it reads joint angles
|
||||
and TCP pose from the robot and publishes them as dora outputs. It also accepts JSON commands
|
||||
(home, move_to, move_to_pose, vacuum_on/off) via a `command` input. A FastAPI REST server with a web UI
|
||||
runs in a background thread for manual control. The robot control wrapper lives in `dora_ulite6/main.py`
|
||||
as the `ULite6Helper` class, and the dora event loop is the `main()` function in the same file.
|
||||
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 pip install -e .
|
||||
uv sync # runtime deps
|
||||
uv sync --group dev # plus pytest and ruff
|
||||
```
|
||||
|
||||
Requirements: Python >=3.8, uv, and network access to the UFactory Lite6 robot.
|
||||
`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 up
|
||||
dora start dataflow.yml
|
||||
dora run dataflow.yml --uv
|
||||
```
|
||||
|
||||
Robot behavior is configured through environment variables (see README.md for the full list:
|
||||
ROBOT_IP, DEFAULT_SPEED, DEFAULT_UNITS, API_HOST, API_PORT, VACUUM_ENABLED, INIT_MODE, etc.).
|
||||
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
|
||||
|
||||
- **`dora_ulite6/main.py`** -- Single-file implementation containing:
|
||||
- `ULite6Helper`: Wraps the xArm Python SDK for motion control (go_home, move_to_pose,
|
||||
get_current_position, get_joint_angles_deg, get_pose_matrix, vacuum gripper, status, reset).
|
||||
- `MoveToRequest` / `MoveToPoseRequest`: Pydantic models for API validation.
|
||||
- `create_api()`: FastAPI app factory with REST endpoints and embedded HTML/JS web UI for manual control.
|
||||
- `main()`: Dora event loop. Starts FastAPI in a daemon thread, then processes `tick` (publishes
|
||||
joint_pose and tcp_pose) and `command` (executes robot actions and publishes status) inputs.
|
||||
- **`dora_ulite6/__init__.py`** -- Package marker.
|
||||
- **`pyproject.toml`** -- Package metadata, dependencies, and entry point.
|
||||
- **`dataflow.yml`** -- Example dora dataflow using the git: source format.
|
||||
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()`, `node.send_output()`, event iteration via `for event in node`.
|
||||
- **xArm SDK** (`xarm.wrapper.XArmAPI`): `connect()`, `motion_enable()`, `set_mode()`, `set_state()`,
|
||||
`set_position()`, `move_gohome()`, `get_position()`, `get_servo_angle()`, `set_vacuum_gripper()`.
|
||||
- Output data is sent as PyArrow arrays with metadata dicts.
|
||||
- **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.
|
||||
- **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 safety checks.
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user