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:
2026-09-12 11:47:06 -03:00
parent fdabf7e241
commit a2bf59065d
4 changed files with 250 additions and 10 deletions
+28 -10
View File
@@ -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: nodes:
- id: ulite6 - id: ulite6
build: uv pip install -e . path: dora-ulite6
path: dora_ulite6/main.py build: pip install .
inputs: inputs:
tick: dora/timer/millis/10 tick: dora/timer/millis/100
request: caller/request
outputs: outputs:
- status - response
- motion_state
- joint_pose - joint_pose
- tcp_pose - tcp_pose
- robot_state
env: env:
ROBOT_IP: "192.168.1.192" 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_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
View File
@@ -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"
+64
View File
@@ -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()
+30
View File
@@ -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.