Rewrite as a dora 1.0 service node #2
+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