diff --git a/dataflow.yml b/dataflow.yml index ee68ccc..8b4310f 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -1,19 +1,37 @@ +# The repository's own example: builds and runs the node from this checkout, so +# `dora validate` picks up the typed contract in dora-node.yml next to it. +# A consumer wires the node from a tag instead — see README.md. +# +# Run it with `--uv` so every Python node gets its own managed environment. +# +# `examples/caller.py` shows the whole convention: a non-blocking motion +# answered at once, its progress followed on `motion_state`, and a second motion +# preempting the first. +strict_types: true + nodes: - id: ulite6 - build: uv pip install -e . - path: dora_ulite6/main.py + path: dora-ulite6 + build: pip install . inputs: - tick: dora/timer/millis/10 + tick: dora/timer/millis/100 + request: caller/request outputs: - - status + - 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: "9000" - VACUUM_ENABLED: "true" TOOL_TYPE: "vacuum" - TOOL_OFFSET_MM: "0.0" + INIT_MODE: "none" + + - id: caller + path: examples/caller.py + inputs: + tick: dora/timer/millis/500 + response: ulite6/response + motion_state: ulite6/motion_state + outputs: + - request diff --git a/dora-node.yml b/dora-node.yml new file mode 100644 index 0000000..4eaf428 --- /dev/null +++ b/dora-node.yml @@ -0,0 +1,128 @@ +# Node manifest: the typed contract a consumer wires against. +# +# No source package is shared between node repositories, so this file and its +# type URNs are the only contract a consumer has. `dora validate` checks a +# dataflow against it without running the node, so CI needs no robot. +# +# The `request` and `response` ports are deliberately **untyped**. They carry +# dora's pattern-correlation metadata (`request_id`), and dora defines that as +# the marker for a port where several Arrow schemas legitimately flow — one per +# service — distinguished by metadata rather than by a fixed Arrow type. The +# runtime skips type checks on such messages, so annotating them would claim a +# contract neither side keeps. +apiVersion: 1 +name: dora-ulite6 +namespace: liria +description: UFactory Lite6 arm node serving motion and control services, and publishing joint and tool pose +categories: [robot, actuator] +keywords: [ufactory, lite6, xarm, robot-arm, manipulator, service, vacuum, gripper] + +runtime: python +entrypoint: dora-ulite6 +# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and +# gives the node its own managed venv under `.dora/python-envs/`, so the +# dependencies it sees at runtime are the ones installed here. +build: pip install . +dora: ">=1.0" + +inputs: + tick: + type: std/core/v1/UInt64 + required: true + description: >- + Each tick publishes whatever the arm threads produced since the last one. + It does not drive the arm — the tick rate is the publication rate, and + TELEMETRY_INTERVAL_MS is how often the arm is actually read. Connect to + dora/timer/millis/N. + request: + required: true + description: >- + A service request: a one-row struct {service, ...arguments}, with the + caller's `request_id` in the metadata. Services: move_to, move_joints, + home, home_and_clear, clear, recover, stop, freedrive, grasp, release, + get_state. Answered on `response` under the same request_id, exactly once. + A motion takes `wait`: true (the default) holds the caller until the arm + stops; false answers at once with an acknowledgement and reports the rest + on `motion_state`. + jog: + required: false + description: >- + A continuous velocity command: a one-row struct {velocity} of 6 numbers + [vx, vy, vz, wx, wy, wz] in mm/s and deg/s. A topic rather than a service + because it expires on the controller's own TTL and a sender must keep + sending; nothing is published in answer. It drives the arm, so the first + non-zero one preempts a motion in play and robot_state turns to moving. + +outputs: + response: + description: >- + The answer to one service request, under its `request_id`: a struct + {ok, service, message, ...extras}. get_state adds connected, ready, + tool_engaged, freedrive, pose and joints. Exactly one response per + request, including for one that was refused or preempted. + motion_state: + description: >- + Where the current motion stands, published on every change: a struct + {request_id, service, state, message} with state one of accepted, moving, + done, failed, preempted. The request_id is in the fields rather than the + metadata because this is the arm's state, not an answer to a request. A + new motion preempts the one in play, so there is only ever one. + joint_pose: + type: std/core/v1/Float32 + description: The six joint angles in degrees, base to wrist. + tcp_pose: + type: std/core/v1/Float32 + description: >- + The mounted tool's tip as [x, y, z, roll, pitch, yaw] in mm and degrees, + in the robot base frame. With TOOL_TYPE none this is the flange itself. + robot_state: + type: std/core/v1/String + description: >- + One of connecting, ready, moving, recovering, unavailable. Published on + every change, never repeated. + +env: + ROBOT_IP: + type: string + description: Controller address. Defaults to 192.168.1.192. + TOOL_TYPE: + type: string + description: What is mounted at the flange - none, vacuum or gripper. Defaults to none. + TOOL_OFFSET_MM: + type: float + description: Extra reach past the flange, added to the mounted tool's own. Defaults to 0. + DEFAULT_SPEED: + type: float + description: Cartesian speed in mm/s for a move_to with no speed of its own. Defaults to 30. + DEFAULT_JOINT_SPEED: + type: float + description: Joint speed in deg/s for a move_joints with no speed of its own. Defaults to 20. + TELEMETRY_INTERVAL_MS: + type: int + description: >- + How often the reporter reads the arm, and so how often telemetry is + produced. Keeps running through a motion. Defaults to 100. + RECONNECT_INTERVAL_S: + type: float + description: How long to wait before trying an unreachable arm again. Defaults to 5. + INIT_MODE: + type: string + description: Where the arm goes on startup - none, home or pose. Defaults to none. + +example: | + - id: ulite6 + git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git + tag: v0.3.0 + build: pip install . + path: dora-ulite6 + inputs: + tick: dora/timer/millis/100 + request: planner/request + outputs: + - response + - joint_pose + - tcp_pose + - robot_state + env: + ROBOT_IP: "192.168.1.192" + TOOL_TYPE: "vacuum" diff --git a/examples/caller.py b/examples/caller.py new file mode 100644 index 0000000..95b8cd3 --- /dev/null +++ b/examples/caller.py @@ -0,0 +1,64 @@ +"""A minimal caller: what using the node's services looks like. + +Run by `dataflow.yml`. It sends a motion with `wait: false`, follows it on +`motion_state`, and then sends a second one that preempts it. That is the whole +convention: + +- a request carries a `request_id` you choose, and gets exactly one `response` + under that id — an acknowledgement when `wait` is false, the outcome when it + is true; +- a motion's progress is on `motion_state`, keyed by the same request_id, so a + non-blocking caller can tell "mine has not started" from "mine has finished" + without a race; +- a new motion replaces the one in play, because the arm is one resource. +""" + +import pyarrow as pa +from dora import Node + + +def main() -> None: + """Send a non-blocking motion, follow it, preempt it, and exit.""" + node = Node("caller") + sent_first = False + sent_second = False + + for event in node: + if event["type"] != "INPUT": + continue + + if event["id"] == "tick" and not sent_first: + sent_first = True + _move(node, "move-1", z=200.0) + + elif event["id"] == "response": + row = event["value"][0].as_py() + print( + f"response to {event['metadata'].get('request_id')}: " + f"ok={row['ok']} {row['message']}", + flush=True, + ) + + elif event["id"] == "motion_state": + row = event["value"][0].as_py() + print(f"{row['request_id']} is {row['state']}", flush=True) + + # Once the first one is really under way, change our mind. + if row["request_id"] == "move-1" and row["state"] == "moving" and not sent_second: + sent_second = True + _move(node, "move-2", z=120.0) + elif row["request_id"] == "move-2" and row["state"] in ("done", "failed"): + break + + +def _move(node: Node, request_id: str, z: float) -> None: + """Ask for a move and come straight back, rather than waiting it out.""" + node.send_output( + "request", + pa.array([{"service": "move_to", "x": 250.0, "y": 0.0, "z": z, "wait": False}]), + metadata={"request_id": request_id}, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/dora-node.yml b/examples/dora-node.yml new file mode 100644 index 0000000..b8bf260 --- /dev/null +++ b/examples/dora-node.yml @@ -0,0 +1,30 @@ +# Manifest for the example caller, so `dora validate` matches it against this +# node rather than against the arm node's manifest at the repository root — +# dora resolves a node's manifest from its path, and without this the caller +# would be checked against an entrypoint it does not have. +apiVersion: 1 +name: caller +namespace: liria +description: Example caller showing a non-blocking motion followed on motion_state, then preempted +categories: [debug] + +runtime: python +# Resolved against this manifest's own directory: / +# must name the same file as the dataflow's `path:`. +entrypoint: caller.py + +inputs: + tick: + type: std/core/v1/UInt64 + required: true + description: Sends the first motion on the first tick. + response: + required: true + description: The answer to a request, under its request_id. + motion_state: + required: true + description: Where the current motion stands, which is how a non-blocking caller follows it. + +outputs: + request: + description: A service request, carrying a request_id in its metadata.