Replaces the command/status JSON pair with dora's own service pattern: one request, one response, correlated by the request_id dora puts in the message metadata. Motions take a wait flag, and their lifecycle is published as the arm's state on motion_state rather than as a per-goal stream — the arm is one resource, so a new motion preempts the one in play and a state describes that exactly. 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 already finished. Three threads, because the SDK blocks. The control thread carries out one job at a time and may block; the reporter thread only ever reads the report cache, so telemetry keeps flowing through a ten-second move; the stopper thread exists because a stop fits nowhere else — it must not queue behind the motion it aborts, and emergency_stop re-commands the controller for up to three seconds, which is far too long to stop routing a dataflow. The event loop never calls anything that can block, get_state included: that one is allowed there only because it reads the cache. The node is built before the arm threads start, so INIT_MODE cannot move a robot before the process has joined the dataflow, and a restart leaves the arm where it is rather than driving it home unasked. Shutdown halts the arm first — a motion lives in the controller, and dropping the connection does not abort it.
180 lines
6.4 KiB
Python
180 lines
6.4 KiB
Python
"""The dora event loop: accept requests, publish what the arm threads produced.
|
|
|
|
Nothing here touches the arm. The loop reads the `request_id` dora puts in a
|
|
message's metadata, hands the work to the control thread, and publishes the
|
|
answer under the same id.
|
|
|
|
Two services do not take the ordinary road. `get_state` runs here, because it
|
|
never blocks and is wanted exactly when the control thread is busy. `stop` is
|
|
handed to the stopper thread, because it must not queue behind the motion it
|
|
aborts and must not be run here either — the SDK spends up to three seconds on
|
|
it, and a router cannot go away that long.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
|
|
import pyarrow as pa
|
|
from dora import Node
|
|
|
|
from dora_ulite6 import payload, services
|
|
from dora_ulite6.config import Config
|
|
from dora_ulite6.control import Control, Message, ServiceCall, Telemetry
|
|
from dora_ulite6.log import log
|
|
|
|
# The metadata key dora's service pattern is built on.
|
|
REQUEST_ID = "request_id"
|
|
|
|
|
|
def main() -> None:
|
|
"""Connect to the dataflow, start the arm threads, and run until dora stops us."""
|
|
args = _parse_args()
|
|
config = Config.from_env()
|
|
|
|
# The node comes first, and the arm afterwards. Starting the arm threads
|
|
# first would let INIT_MODE drive a real robot before this process had
|
|
# joined the dataflow at all — and if joining then failed, it would have
|
|
# moved with nobody watching. Connecting first also lets the arm be told
|
|
# whether this is a restart.
|
|
#
|
|
# Passing the name lets the node also run standalone as a dynamic node; when
|
|
# dora spawns it, the id from the dataflow wins.
|
|
node = Node(args.name)
|
|
|
|
control = Control(config, restarted=node.is_restart())
|
|
control.start()
|
|
|
|
try:
|
|
for event in node:
|
|
if event["type"] == "ERROR":
|
|
raise RuntimeError(event["error"])
|
|
if event["type"] != "INPUT":
|
|
continue
|
|
_handle(node, control, event)
|
|
finally:
|
|
if not control.shutdown():
|
|
# The arm has been told to stop, but the control thread is still
|
|
# inside a blocking SDK call and may never come out. Leave rather
|
|
# than hang until dora loses patience and kills us.
|
|
os._exit(0)
|
|
|
|
|
|
def _handle(node: Node, control: Control, event: dict) -> None:
|
|
"""Route one input event."""
|
|
event_id = event["id"]
|
|
if event_id == "tick":
|
|
_publish(node, control)
|
|
elif event_id == "request":
|
|
_accept_request(control, event)
|
|
elif event_id == "jog":
|
|
_accept_jog(control, event)
|
|
else:
|
|
log(f"ignoring unexpected input `{event_id}`")
|
|
|
|
|
|
def _publish(node: Node, control: Control) -> None:
|
|
"""Send everything the arm threads produced since the last tick."""
|
|
for state in control.take_state_changes():
|
|
node.send_output("robot_state", pa.array([state]))
|
|
for message in control.take_messages():
|
|
node.send_output(message.output, pa.array([message.fields]), metadata=message.metadata)
|
|
telemetry = control.take_telemetry()
|
|
if telemetry is not None:
|
|
_send_telemetry(node, telemetry)
|
|
|
|
|
|
def _send_telemetry(node: Node, telemetry: Telemetry) -> None:
|
|
node.send_output(
|
|
"joint_pose",
|
|
pa.array(telemetry.joints, type=pa.float32()),
|
|
metadata={"units": "deg", "timestamp_ns": telemetry.timestamp_ns},
|
|
)
|
|
node.send_output(
|
|
"tcp_pose",
|
|
pa.array(telemetry.tip.as_list(), type=pa.float32()),
|
|
metadata={
|
|
"units_position": "mm",
|
|
"units_rotation": "deg",
|
|
"frame": "tool_tip",
|
|
"timestamp_ns": telemetry.timestamp_ns,
|
|
},
|
|
)
|
|
|
|
|
|
def _accept_request(control: Control, event: dict) -> None:
|
|
"""Take one service request, or refuse it under its own request id.
|
|
|
|
A request the loop cannot parse is answered here rather than queued: a
|
|
caller waiting on a request_id must get exactly one response, never silence.
|
|
A request it *can* parse is queued even before the arm has been reached,
|
|
because `recover` and `get_state` are precisely what a caller sends then.
|
|
|
|
`wait` decides only *when* the one response is sent — at once, or when the
|
|
arm stops. It is read here rather than in the service handler because it is
|
|
about the protocol, not about what the arm does.
|
|
"""
|
|
request_id = event["metadata"].get(REQUEST_ID, "")
|
|
try:
|
|
row = payload.read_row(event["value"])
|
|
name = payload.name_of(row, services.SERVICE, services.NAMES)
|
|
wait = payload.flag(row, "wait", True)
|
|
except payload.PayloadError as error:
|
|
_refuse(control, request_id, str(error))
|
|
return
|
|
if name == services.STOP:
|
|
control.request_stop(request_id)
|
|
return
|
|
if name in services.IMMEDIATE:
|
|
control.call_now(request_id, row)
|
|
return
|
|
call = ServiceCall(request_id=request_id, row=row, wait=wait)
|
|
if call.moves_the_arm:
|
|
# Motions go through their own door: one preempts whatever is already
|
|
# moving, rather than queueing behind it.
|
|
control.start_motion(call)
|
|
return
|
|
control.submit(call)
|
|
|
|
|
|
def _accept_jog(control: Control, event: dict) -> None:
|
|
"""Take one velocity command.
|
|
|
|
A topic rather than a service: a jog is a stream that expires on the
|
|
controller's own TTL, so a sender keeps sending and nobody waits for an
|
|
answer. It still drives the arm, so it preempts a motion and reports
|
|
`moving` the same way one does — an arm that is travelling must never look
|
|
idle.
|
|
"""
|
|
try:
|
|
row = payload.read_row(event["value"])
|
|
velocity = payload.numbers(row, "velocity", count=6)
|
|
except payload.PayloadError as error:
|
|
log(f"ignoring a malformed jog: {error}")
|
|
return
|
|
control.start_jog(velocity)
|
|
|
|
|
|
def _refuse(control: Control, request_id: str, message: str) -> None:
|
|
"""Answer a request the loop rejected, without troubling the arm threads."""
|
|
control.post(
|
|
Message(
|
|
output="response",
|
|
fields={"ok": False, "service": "", "message": message},
|
|
metadata={REQUEST_ID: request_id},
|
|
)
|
|
)
|
|
|
|
|
|
def _parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Dora node for a UFactory Lite6 arm.")
|
|
parser.add_argument(
|
|
"--name",
|
|
default="ulite6",
|
|
help="The name of the node in the dataflow, used when running as a dynamic node.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|