From 412aeac9558679b05f5354c815fb3daf5f6410ed Mon Sep 17 00:00:00 2001 From: Cristhian Date: Sat, 12 Sep 2026 11:47:06 -0300 Subject: [PATCH] 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. --- AGENTS.md | 194 ++++++++++++++++++++++++++----- README.md | 337 ++++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 416 insertions(+), 115 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d8d9021..5b943a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//`, 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 `/` 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()`, `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. diff --git a/README.md b/README.md index f51fd3b..d45512e 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,280 @@ -# Dora ULite6 Node +# dora-ulite6 -Dora node for controlling a UFactory Lite6 robot via REST API, web UI, and publishing joint/TCP state. +Dora node for a UFactory Lite6 arm. Every operation is a **service** — dora's own +request/response pattern — and the arm's joint angles, tool pose and connection state +are published as ordinary topics. -## Installation +One request, one response, correlated by the `request_id` you put in the message +metadata. That is the node's whole control surface: there is no second protocol for the +slow operations. A motion takes a `wait` flag — hold me until the arm stops, or answer me +now and I will follow it on `motion_state`. -```bash -uv pip install -e . +Correlation lives in the message metadata because that is dora's convention, not a ROS2 +transliteration: `request_id` is defined in dora's own message crate, and the runtime +treats a port carrying it as one where several Arrow schemas legitimately flow — one per +service. + +The arm runs on threads of its own, so a move that takes ten seconds — or a controller +whose cable was pulled — never stalls the dora event loop, and `tcp_pose` keeps arriving +throughout. + +The driver is ported from the +[littlehand](https://gitea.liria.cl/Littlehand/monolitic-littlehand-python) robot layer, +which was written against this same controller: typed failures, a reachability check +before a move is commanded, and arrival confirmed rather than assumed. + +## The services + +| Service | Arguments | Moves the arm | Description | +| ------- | --------- | ------------- | ----------- | +| `move_to` | `x`, `y`, `z`, optional `roll`, `pitch`, `yaw`, `speed`, `wait` | yes | Move the tool tip to a base-frame pose. Orientation defaults to tool-down (`180, 0, 0`). Refused before anything moves if the straight-line path leaves the joint envelope. | +| `move_joints` | `angles` (list, degrees), optional `speed`, `wait` | yes | Move the joints directly. | +| `home` | optional `wait` | yes | Joint-space home. | +| `home_and_clear` | optional `wait` | yes | Clear a latched error, then drive home. The answer to a joint-limit error, and only to be sent once the area is clear. | +| `stop` | — | no | Halt the arm. Served by a thread of its own, so it neither queues behind the motion it aborts nor holds up the dataflow; the response says the stop was commanded, and whether it took shows on `motion_state`. | +| `clear` | — | no | Clear a latched controller error. Never moves the arm. | +| `recover` | — | no | Reconnect if needed, clear, re-enable motion. Never moves the arm. | +| `freedrive` | `enabled` (bool, default `true`) | no | Enter or leave manual teach mode. | +| `grasp` | — | no | Take hold with whatever `TOOL_TYPE` names. | +| `release` | — | no | Let go. | +| `get_state` | — | no | Adds `connected`, `ready`, `tool_engaged`, `freedrive`, `pose`, `joints` to the response. Served on the event loop and read from the controller's report cache, so it answers *during* a motion. | + +The four that move the arm report `moving` on `robot_state` while they run, and so does +a jog — an arm that is travelling never looks idle. `stop` interrupts whatever is moving; +the interrupted request comes back `ok: false` saying it was aborted. + +## Calling a service + +Send a one-row struct on `request`, with your own id in the metadata: + +```python +node.send_output( + "request", + pa.array([{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0, "speed": 40.0}]), + metadata={"request_id": "move-1"}, +) ``` -## Dataflow +The answer arrives on `response` under the same `request_id`: + +```python +{"ok": True, "service": "move_to", "message": "the tool is at (250, 0, 200 mm)"} +``` + +Exactly one response per request, always — including for a request that was refused +before the arm was touched. Because the answer is matched by the id in the metadata and +not by arrival order, several requests can be in flight at once. + +Poses are the **tool tip**, the same frame `tcp_pose` reports, so the arm can be sent +back to a pose that was read off the dataflow. The tool's reach is taken back off before +the flange is commanded. + +## Blocking and non-blocking motions + +`wait` decides only *when* the one response is sent. + +```python +# wait: true (the default) — the response arrives when the arm has stopped. +{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0} + +# wait: false — the response arrives at once, as an acknowledgement. +{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0, "wait": False} +``` + +Either way it is **one response per request**. With `wait: false` that response is the +acknowledgement, and how the motion actually ended is reported on `motion_state` — never +as a second response, which would break the id a caller correlates by. + +### Following a motion: `motion_state` + +Published on every change, as `{request_id, service, state, message}`: + +| State | Meaning | +| ----- | ------- | +| `accepted` | Taken, not started. | +| `moving` | The arm is being driven. | +| `done` | Finished; the arm arrived. | +| `failed` | The arm refused it, or never got there. | +| `preempted` | A newer motion took its place. | + +`accepted` is not decoration. Without it, a caller that sends a motion and then polls a +two-valued `moving`/`idle` state reads "not moving" in the window before the control +thread picks the request up, and concludes the motion it just asked for had already +finished. Keying the state by `request_id` and publishing `accepted` closes that window: +the caller can always tell *"mine has not started"* from *"mine has finished"*. + +The `request_id` is in the fields rather than the metadata, because this is a broadcast of +the arm's state rather than an answer to a request — anyone may read it, and +`get_state` sees the same value. + +`tcp_pose` also keeps arriving throughout, at `TELEMETRY_INTERVAL_MS`: the thread that +publishes it is not the thread inside the motion. + +### A new motion preempts the one in play + +The arm is one resource — there is never more than one motion. A new motion cuts the +running one short (through the same stop a caller could send) and takes its place; a +motion still waiting its turn is dropped without ever moving anything. Each one is still +answered exactly once: by its response if a caller is still waiting, and on +`motion_state` as `preempted` either way. + +So a sequence of positions is driven by the caller, one at a time — send the next when +you see `done`. Sending them all at once does not queue them; the last one wins. + +## Jogging + +`jog` is a topic, not a service: a velocity command expires on the controller's own TTL, +so a sender keeps sending and nobody waits for an answer. + +```python +node.send_output("jog", pa.array([{"velocity": [20.0, 0, 0, 0, 0, 0]}])) +``` + +It still drives the arm, so it behaves like a motion where that matters: the first +non-zero jog preempts a motion in play and `robot_state` turns to `moving`. A zero +velocity stops it, and so does simply going quiet — the node returns to `ready` once +`VELOCITY_COMMAND_TTL_S` has passed, because that is when the controller has expired the +command. + +## Telemetry + +| Output | Type | Description | +| ------ | ---- | ----------- | +| `joint_pose` | `Float32` | The six joint angles in degrees, base to wrist. | +| `tcp_pose` | `Float32` | `[x, y, z, roll, pitch, yaw]` in mm and degrees: the tool tip in the base frame. | +| `robot_state` | `String` | `connecting`, `ready`, `moving`, `recovering` or `unavailable`, on every change. | +| `motion_state` | struct | Where the current motion stands — see above. | + +`tick` sets how often the newest reading is *published*; `TELEMETRY_INTERVAL_MS` sets how +often the arm is *read*. A tick with nothing new publishes nothing rather than repeating +a stale pose. + +## Usage + +Add to your dataflow: ```yaml nodes: - id: ulite6 git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git - git_checkout: v0.1.0 - build: uv pip install -e . - path: dora_ulite6/main.py + tag: v0.3.0 + build: pip install . + path: dora-ulite6 inputs: - tick: dora/timer/millis/10 - outputs: [status, joint_pose, tcp_pose] - inputs: [command] + tick: dora/timer/millis/100 + request: planner/request + outputs: + - response + - motion_state + - joint_pose + - tcp_pose + - robot_state env: ROBOT_IP: "192.168.1.192" - DEFAULT_SPEED: "30" - DEFAULT_UNITS: "mm" - API_HOST: "0.0.0.0" - API_PORT: "8080" - VACUUM_ENABLED: "false" + TOOL_TYPE: "vacuum" ``` +Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own +managed environment. + +### Runnable example + +[`dataflow.yml`](dataflow.yml) builds the node from this checkout and wires +[`examples/caller.py`](examples/caller.py), which sends a non-blocking motion, follows it +on `motion_state`, and then preempts it with a second one: + +```bash +dora run dataflow.yml --uv +``` + +Building from the checkout (`path:` + `build:`) rather than from a tag is what lets +`dora validate` pick up the manifest in [`dora-node.yml`](dora-node.yml). + +**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and +network access to the arm's controller. + +## Lifecycle + +The node connects to the dataflow **before** it touches the arm, so a startup that never +joins cannot have moved a robot first. + +**On a restart** (`restart_policy` in your dataflow), `INIT_MODE` is skipped: the arm is +left wherever it was. Driving it home because the process came back is a motion nobody +asked for. + +**On shutdown**, the arm is halted before the node exits. A motion lives in the +controller, not in this process — dropping the connection does not abort it — so exiting +without commanding a stop would leave an arm travelling with nobody left to stop it. If +the control thread is still inside a blocking SDK call afterwards, the node leaves +without it rather than hanging until dora loses patience. + ## Configuration | Env Variable | Default | Description | | ------------ | ------- | ----------- | -| `ROBOT_IP` | `192.168.1.192` | Robot IP address | -| `DEFAULT_SPEED` | `30` | Movement speed (mm/s) | -| `DEFAULT_UNITS` | `mm` | Position units (mm or m) | -| `API_HOST` | `0.0.0.0` | API server host | -| `API_PORT` | `8080` | API server port | -| `VACUUM_ENABLED` | `false` | Enable vacuum gripper controls | -| `TOOL_TYPE` | `none` | Mounted tool: `none`, `vacuum`, `gripper` | -| `TOOL_OFFSET_MM` | `0.0` | Additional Z-offset in mm added on top of the tool preset | -| `INIT_MODE` | `none` | Startup positioning: `home`, `pose`, or `none` | -| `INIT_X/Y/Z` | `300/0/250` | Initial pose coordinates (mm) | -| `INIT_ROLL/PITCH/YAW` | `180/0/0` | Initial pose orientation (deg) | -| `INIT_SPEED` | `50` | Startup movement speed (mm/s) | +| `ROBOT_IP` | `192.168.1.192` | Controller address | +| `TOOL_TYPE` | `none` | What is mounted: `none`, `vacuum`, `gripper` | +| `TOOL_OFFSET_MM` | `0.0` | Extra reach past the flange, on top of the tool's own | +| `DEFAULT_SPEED` | `30` | Cartesian speed (mm/s) for a `move_to` with no speed | +| `DEFAULT_JOINT_SPEED` | `20` | Joint speed (deg/s) for a `move_joints` with no speed | +| `TELEMETRY_INTERVAL_MS` | `100` | How often the arm is read, and how often telemetry is produced | +| `RECONNECT_INTERVAL_S` | `5.0` | How long to wait before retrying an unreachable arm | +| `INIT_MODE` | `none` | Startup positioning: `none`, `home`, `pose` | +| `INIT_X/Y/Z` | `300/0/250` | Startup pose position (mm, tool tip) | +| `INIT_ROLL/PITCH/YAW` | `180/0/0` | Startup pose orientation (deg) | +| `INIT_SPEED` | `50` | Startup move speed (mm/s) | -## Web UI +The motion limits the driver enforces are tunable too, and the defaults are the ones +littlehand runs with. The arrival tolerances are wide on purpose: whether a motion +happened is decided by the controller's error, and these only catch an arm that never +left where it stood. -Access at `http://localhost:8080/` +| Env Variable | Default | Description | +| ------------ | ------- | ----------- | +| `ARRIVAL_TOLERANCE_MM` | `25.0` | How far off the target still counts as arrived | +| `ARRIVAL_TOLERANCE_DEG` | `15.0` | The same for orientation, and for homing | +| `ARRIVAL_TIMEOUT_S` | `2.0` | How long to keep asking before calling a move interrupted | +| `JOINT_LIMIT_MARGIN_DEG` | `1.0` | How clear of its bound a joint must stay for a pose to be commanded | +| `PATH_SAMPLES` | `4` | How many points along the straight line are checked for reachability | +| `READY_TIMEOUT_S` | `3.0` | How long `recover` waits for a movable state | +| `HOME_TIMEOUT_S` | `20.0` | How long homing may take | +| `TOOL_SETTLE_S` | `2.0` | How long to wait for suction to build or fingers to close | +| `VELOCITY_COMMAND_TTL_S` | `0.5` | The controller's own expiry for one `jog` command | -- Live status, TCP pose, and joint angles -- Home and Reset buttons -- Move to position form -- Vacuum gripper controls (when enabled) - -## REST API - -Interactive docs at `http://localhost:8080/docs` - -| Method | Endpoint | Description | -| ------ | -------- | ----------- | -| GET | `/` | Web control interface | -| GET | `/api/status` | Robot status (connected, errors) | -| GET | `/api/pose` | Current TCP pose | -| GET | `/api/joints` | Current joint angles | -| GET | `/api/config` | API configuration | -| POST | `/api/home` | Go to home position | -| POST | `/api/reset` | Clear errors and reset state | -| POST | `/api/move_to` | Move to position | -| POST | `/api/move_to_pose` | Move to full pose | -| POST | `/api/disconnect` | Disconnect from robot | - -### Vacuum Gripper (when `VACUUM_ENABLED=true`) - -| Method | Endpoint | Description | -| ------ | -------- | ----------- | -| GET | `/api/vacuum` | Get vacuum status | -| POST | `/api/vacuum/on` | Turn vacuum on | -| POST | `/api/vacuum/off` | Turn vacuum off | - -## CLI Examples +## Development ```bash -# Get pose -curl http://localhost:8080/api/pose - -# Go home -curl -X POST http://localhost:8080/api/home - -# Move to position -curl -X POST http://localhost:8080/api/move_to \ - -H "Content-Type: application/json" \ - -d '{"x": 200, "y": 0, "z": 300}' - -# Move with orientation -curl -X POST http://localhost:8080/api/move_to_pose \ - -H "Content-Type: application/json" \ - -d '{"x": 200, "y": 0, "z": 300, "roll": 180, "pitch": 0, "yaw": 0}' +uv sync --group dev +uv run pytest # 83 tests, no robot needed +uv run ruff check . ``` -## Dora Outputs +## Bridging to ROS2 -- `status` - JSON: `{ok, action, message, timestamp_ns}` -- `joint_pose` - 6 joint angles in degrees -- `tcp_pose` - `[x, y, z, roll, pitch, yaw]` in mm/deg — reflects tool tip when `TOOL_TYPE` is set +Nothing here depends on ROS2, and no ROS2 message definition is shipped. It is worth +knowing anyway that dora's `ros2:` service bridge speaks exactly this convention — it +emits `request` and takes a response under the same `request_id`. So exposing these +services to a ROS2 graph later is a matter of adding bridge nodes to a dataflow and +mapping the struct fields, with no change to this code. -## Dora Inputs +## Migrating from v0.2.x -- `command` - JSON string: `{id, action, payload}` - - `id`: unique command id (required) - - `action`: `home`, `move_to`, or `move_to_pose` - - `payload`: fields for move (`x`, `y`, `z`, `roll`, `pitch`, `yaw`, `speed`) +- **The `command` input and the `status` output are gone**, replaced by `request` and + `response`. A command's `{id, action, payload}` JSON becomes a service request, and the + id moves from the payload into the message metadata, where dora's own pattern puts it. +- **Payloads are Arrow structs, not JSON strings.** `pa.array([{...}])` instead of + `json.dumps`. +- **`action` is now `service`**, and `vacuum_on`/`vacuum_off` are `grasp`/`release`. +- **`jog` is its own input** rather than a command. +- **There is no `started` message.** A request produces exactly one response. Follow a + motion on `motion_state`, which reports `accepted`, `moving` and then how it ended. +- **Telemetry no longer stops while the arm is moving**, thanks to the reporter thread. +- **`stop` answers "commanded" rather than "done."** It is handed to a thread of its own + instead of being run inline, because the SDK spends up to three seconds confirming a + stop and a dataflow router cannot go away that long. -`status` will include `command_id` for command responses. +## Migrating from v0.1.x + +See the v0.2.0 notes in the git history for the removal of the FastAPI REST server and +web UI (still reachable at tag `v0.1.2`), the `DEFAULT_UNITS` removal, and the move to +tool-tip poses.