Files
dora-ulite6/dora_ulite6/services.py
T
cristhian fdabf7e241 refactor node into services over the dora 1.0 contract
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.
2026-09-12 11:46:59 -03:00

168 lines
5.9 KiB
Python

"""Every operation the node serves, and what each one does to the arm.
A service is one request and one response, correlated by the `request_id` dora
puts in the message metadata. That is the node's whole control surface: there is
no second protocol for the slow ones.
Two groups behave differently enough to be worth naming here:
- The motions in :data:`MOVES_THE_ARM` take as long as the arm does. A caller
chooses with `wait` whether to be held until the arm stops or answered at
once; either way the motion's progress is on `motion_state`. A new motion
preempts one already running, so there is never more than one in play.
- The services in :data:`IMMEDIATE` are run by the event loop rather than queued,
because queued they would sit behind a motion — which is exactly when they are
wanted. Putting one there is a promise that it never blocks: `get_state` reads
the controller's report cache rather than the wire, which is what makes it
answerable *during* a motion.
`stop` is a service to a caller but has no handler here at all. It cannot queue
(it exists to abort what is at the head of the queue) and it cannot run on the
event loop either: the SDK's `emergency_stop` re-commands the controller for up
to three seconds, and three seconds is far too long to stop routing a dataflow.
It gets a thread of its own — see :mod:`dora_ulite6.control`.
"""
from typing import Any
from dora_ulite6 import payload
from dora_ulite6.driver import ULite6
from dora_ulite6.pose import with_tool_offset
# Field naming the service in a request row.
SERVICE = "service"
def run(robot: ULite6, row: dict) -> dict[str, Any]:
"""Carry out the service ``row`` names and return the response fields.
``row`` must name a service with a handler, which `stop` deliberately has
not; the event loop routes that one before it ever reaches here.
Blocks for as long as the service takes — seconds, for a motion.
Raises
------
PayloadError
The row named no known service, or its arguments were unusable.
RobotUnavailableError, UnreachablePoseError, EffectorUnsupportedError
The arm refused; the caller turns it into a failed response.
"""
return SERVICES[payload.name_of(row, SERVICE, SERVICES)](robot, row)
# --- motions: these block, and they move a real arm ---
def _move_to(robot: ULite6, row: dict) -> dict:
tip = payload.pose(row)
# The request names where the tool tip should end up; the controller only
# takes flange poses, so the tool's own reach comes back off here.
robot.move_to(
with_tool_offset(tip, -robot.config.tool_offset_mm),
payload.optional_number(row, "speed", None),
)
return {"message": f"the tool is at ({tip.x:.0f}, {tip.y:.0f}, {tip.z:.0f} mm)"}
def _move_joints(robot: ULite6, row: dict) -> dict:
angles = payload.numbers(row, "angles")
robot.set_joint_angles(angles, payload.optional_number(row, "speed", None))
return {"message": f"the joints are at {angles}"}
def _home(robot: ULite6, row: dict) -> dict:
robot.go_home()
return {"message": "the arm is home"}
def _home_and_clear(robot: ULite6, row: dict) -> dict:
robot.home_and_clear()
return {"message": "the arm was cleared and driven home"}
# --- everything that answers immediately ---
def _clear(robot: ULite6, row: dict) -> dict:
robot.clear()
return {"message": "the controller error is cleared"}
def _recover(robot: ULite6, row: dict) -> dict:
robot.recover()
return {"message": "the arm is usable again"}
def _freedrive(robot: ULite6, row: dict) -> dict:
enabled = payload.flag(row, "enabled", True)
robot.set_freedrive(enabled)
return {"message": f"freedrive {'on' if enabled else 'off'}"}
def _grasp(robot: ULite6, row: dict) -> dict:
robot.engage_tool()
return {"message": "the tool is holding"}
def _release(robot: ULite6, row: dict) -> dict:
robot.release_tool()
return {"message": "the tool has let go"}
def _get_state(robot: ULite6, row: dict) -> dict:
"""Read the arm and hand back everything a caller might otherwise poll for.
Reads the report cache, not the wire. That is what lets this run on the event
loop and answer *during* a motion — the point of polling at all — where a
`get_position()` round trip would block behind the move on the same socket.
An arm the report cache cannot speak for answers `connected: false` rather
than handing back the pose it held before the cable was pulled.
"""
reading = robot.read_report()
state = {
"connected": reading is not None,
"ready": robot.is_ready(),
"tool_engaged": robot.tool_engaged,
"freedrive": robot.freedrive_on,
"pose": [],
"joints": [],
}
if reading is None:
return {"message": "not connected", **state}
joints, flange = reading
state["pose"] = with_tool_offset(flange, robot.config.tool_offset_mm).as_list()
state["joints"] = joints
return {"message": "ready" if state["ready"] else "not ready", **state}
SERVICES = {
"move_to": _move_to,
"move_joints": _move_joints,
"home": _home,
"home_and_clear": _home_and_clear,
"clear": _clear,
"recover": _recover,
"freedrive": _freedrive,
"grasp": _grasp,
"release": _release,
"get_state": _get_state,
}
# The services that drive the arm across the table. They are what `robot_state`
# reports as `moving`, and what a `stop` is there to interrupt.
MOVES_THE_ARM = frozenset({"move_to", "move_joints", "home", "home_and_clear"})
# Served on the event loop rather than the control thread. See the module note.
# Adding to this set is a promise that the service never blocks and is safe from
# a thread other than the control one — check the driver method before you do.
IMMEDIATE = frozenset({"get_state"})
# Named by callers and accepted, but handled by neither of the above. See the
# module note for why it has a thread to itself.
STOP = "stop"
# Every name a request may carry.
NAMES = frozenset(SERVICES) | {STOP}