add dora-node.yml manifest and a service example dataflow
The manifest is the only contract a consumer has, since no source package is shared between node repositories, and dora validate checks a dataflow against it without running the node — so CI needs no robot. request and response are deliberately untyped. They carry request_id, and dora defines that as the marker for a pattern-correlated port where several Arrow schemas legitimately flow, one per service; the runtime skips type checks on those messages, so annotating them would claim a contract neither side keeps. examples/ carries its own manifest because dora resolves a node's manifest by walking up from its path, and a manifest applies only when <manifest dir>/<entrypoint> names the same file as the dataflow's path. Without it the example caller would be matched against the arm node's manifest and strict validation would fail on the entrypoint mismatch.
This commit is contained in:
+28
-10
@@ -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
|
||||
|
||||
+128
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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: <manifest dir>/<entrypoint>
|
||||
# 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.
|
||||
Reference in New Issue
Block a user