diff --git a/AGENTS.md b/AGENTS.md index 1744c0a..7bec0de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,11 @@ a source goes away**. first time somebody picks up the pad. Errors name the variable. - **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a dynamic node's stdout. +- **`examples/bench.py`** — A stand-in for the arm *and* the browser, so `dataflow.yml` + is self-contained and CI can validate it with no sibling clones. **It is not a + simulator** and must not become one: it never integrates a velocity or moves a pose. Its + job is to close the loop so this node can be watched, not to stand in for + `dora-ulite6` — anything needing the arm to actually respond needs the arm. - **`dora_teleop/probe.py`** — `dora-teleop-probe`: shows the raw axes, the buttons and the velocity that would be sent, with no dataflow and no arm. It exists because the indices in `mapping.py` are SDL's documented layout rather than an observed one, and a pad that diff --git a/README.md b/README.md index 45a2d8c..175edd6 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ stops the node at startup rather than the first time somebody picks up the pad. ## Usage -See `dataflow.yml` for the whole stack wired together. The short version: +The real stack is three nodes: this one, the WebSocket bridge and the arm. ```yaml - id: teleop @@ -172,12 +172,34 @@ See `dataflow.yml` for the whole stack wired together. The short version: outputs: [jog, request, ui_state] ``` +Point `ws-transceiver`'s `data_in` at `teleop/ui_state` and its `STATIC_DIR` at this +repo's `web/`, and wire `ulite6`'s `request` and `jog` from this node. + +**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a +gamepad if you want one — the node starts without and says so. + +### Running it with no arm and no browser + +`dataflow.yml` in this repo is **not** that stack. It is this node plus a bench stand-in, +so you can drive the real thing from a real gamepad with no robot in the room: + ```bash dora run dataflow.yml --uv ``` -**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a -gamepad if you want one — the node starts without and says so. +The bench answers every service, publishes plausible telemetry, and prints each velocity +it is sent: + +```text +jog 1 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00] +jog 2 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00] +jog 3 [ +0.00, +0.00, +0.00, +0.00, +0.00, +0.00] <- stop +``` + +It is **not a simulator**: it never integrates the velocity or moves the pose, and a jog +is printed and forgotten. It closes the loop so this node's behaviour can be watched — +the lease changing hands, the deadman firing when you let go — not so the arm can be +pretended at. ## Development diff --git a/dataflow.yml b/dataflow.yml index ad55fe8..162cef5 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -1,12 +1,13 @@ -# The whole operator stack, and what CI validates. +# The repo's own example, and what CI validates. # -# Built from checkouts rather than tags, so `dora validate` resolves each node's -# manifest from its `path:` and checks the wiring against the versions in front -# of you. The sibling repos are expected next to this one, which is how they are -# cloned in practice. +# Self-contained on purpose: teleop plus a bench stand-in for the arm and the +# browser, both built from this checkout, so `dora validate` resolves every +# manifest from `path:` and CI needs no gamepad, no robot and no sibling clones. # -# Three nodes, one loop: the bridge carries the browser, this node decides who -# drives, and the arm node moves the arm and reports back. +# This is **not** the real stack. The bench answers services and publishes +# telemetry but never moves anything — it exists so the node can be driven from +# a real gamepad with no arm in the room. README.md carries the three-node +# wiring a consumer actually uses. strict_types: true @@ -16,13 +17,11 @@ nodes: build: pip install . inputs: tick: dora/timer/millis/50 - ui_command: ws-transceiver/data_out - clients: ws-transceiver/clients - response: ulite6/response - motion_state: ulite6/motion_state - robot_state: ulite6/robot_state - tcp_pose: ulite6/tcp_pose - joint_pose: ulite6/joint_pose + clients: bench/clients + response: bench/response + robot_state: bench/robot_state + tcp_pose: bench/tcp_pose + joint_pose: bench/joint_pose outputs: - jog - request @@ -31,35 +30,16 @@ nodes: SPEED_MM_S: "60" SPEED_DEG_S: "30" - - id: ws-transceiver - path: ../dora-websocket-transceiver - build: pip install . + - id: bench + path: examples/bench.py inputs: - data_in: teleop/ui_state - outputs: - - data_out - - clients - env: - WS_PORT: "9000" - STATIC_DIR: "../dora-teleop/web" - HTTP_PORT: "9010" - - - id: ulite6 - path: ../dora-ulite6 - build: pip install . - inputs: - tick: dora/timer/millis/100 - request: teleop/request + tick: dora/timer/millis/200 jog: teleop/jog + request: teleop/request + ui_state: teleop/ui_state outputs: - response - - motion_state - - joint_pose - - tcp_pose - robot_state - env: - ROBOT_IP: "192.168.1.192" - TOOL_TYPE: "vacuum" - # Nothing moves because the node started. An operator asks for the first - # motion, which is the whole point of a teleop stack. - INIT_MODE: "none" + - tcp_pose + - joint_pose + - clients diff --git a/dora-node.yml b/dora-node.yml index bfef78b..f89ef20 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -15,7 +15,7 @@ apiVersion: 1 name: dora-teleop namespace: liria description: Hands a robot arm to a human, from a gamepad or a browser -categories: [robot, teleop, input] +categories: [robot] keywords: [teleop, gamepad, joystick, jog, arm, manipulator, web-console, operator] runtime: python diff --git a/examples/bench.py b/examples/bench.py new file mode 100644 index 0000000..f770284 --- /dev/null +++ b/examples/bench.py @@ -0,0 +1,110 @@ +"""A bench stand-in for the arm and the browser, so teleop can run with neither. + +Run by `dataflow.yml`. It answers every service, publishes plausible telemetry, +and prints the velocities it is sent — which is enough to drive the node from a +real gamepad and watch what a real arm would have been told to do. + +**It is not a simulator.** It does not integrate the velocity, move a pose, or +model the arm in any way; a jog is printed and forgotten. Its job is to close +the loop so the node's own behaviour can be watched, not to stand in for +`dora-ulite6`. Anything that needs the arm to actually respond needs the arm. +""" + +import json + +import pyarrow as pa +from dora import Node + +REQUEST_ID = "request_id" + +# Somewhere in front of the robot, tool pointing down. Fixed, because a moving +# pose here would look like the arm was responding, which it is not. +POSE = [250.0, 0.0, 200.0, 180.0, 0.0, 0.0] +JOINTS = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + + +def main() -> None: + """Answer whatever teleop asks and report what it would have moved.""" + node = Node("bench") + announced = False + tool_engaged = False + freedrive = False + jogs = 0 + shown = None + + for event in node: + if event["type"] != "INPUT": + continue + + if event["id"] == "tick": + if not announced: + announced = True + node.send_output("robot_state", pa.array(["ready"])) + node.send_output("tcp_pose", pa.array(POSE, type=pa.float32()), {"units_position": "mm"}) + node.send_output("joint_pose", pa.array(JOINTS, type=pa.float32()), {"units": "deg"}) + # No browser is attached to this dataflow, and saying so is what + # keeps teleop from believing one went away mid-jog. + node.send_output("clients", pa.array([{"connected": 0, "event": "close"}])) + + elif event["id"] == "jog": + velocity = event["value"][0].as_py()["velocity"] + jogs += 1 + moving = any(velocity) + shown = ", ".join(f"{v:+7.2f}" for v in velocity) + print(f"jog {jogs:5d} [{shown}] {'' if moving else '<- stop'}", flush=True) + + elif event["id"] == "request": + row = event["value"][0].as_py() + service = row.get("service", "") + request_id = event["metadata"].get(REQUEST_ID, "") + if service == "grasp": + tool_engaged = True + elif service == "release": + tool_engaged = False + elif service == "freedrive": + freedrive = bool(row.get("enabled", True)) + if service != "get_state": + print(f"service {service} ({request_id})", flush=True) + node.send_output( + "response", + pa.array([_answer(service, tool_engaged, freedrive)]), + {REQUEST_ID: request_id}, + ) + + elif event["id"] == "ui_state": + # No browser is attached, so this is where the console's picture of + # the arm would go. Printing it on change rather than on every tick + # makes the things worth watching — the lease moving, the arm + # starting and stopping — visible without 20 lines a second. + snapshot = json.loads(event["value"][0].as_py()) + if snapshot.get("type") != "telemetry": + continue + current = (snapshot["arm_state"], snapshot["owner"], snapshot["estop"]) + if current != shown: + shown = current + state, owner, estop = current + print( + f"console would show: {state}, owner {owner}" + (", E-STOPPED" if estop else ""), + flush=True, + ) + + +def _answer(service: str, tool_engaged: bool, freedrive: bool) -> dict: + """Build the response row the arm node would have sent.""" + if service == "get_state": + return { + "ok": True, + "service": service, + "message": "ready", + "connected": True, + "ready": True, + "tool_engaged": tool_engaged, + "freedrive": freedrive, + "pose": POSE, + "joints": JOINTS, + } + return {"ok": True, "service": service, "message": f"{service} accepted by the bench"} + + +if __name__ == "__main__": + main() diff --git a/examples/dora-node.yml b/examples/dora-node.yml new file mode 100644 index 0000000..5e4199e --- /dev/null +++ b/examples/dora-node.yml @@ -0,0 +1,42 @@ +# Manifest for the bench stand-in. +# +# dora resolves a node's manifest from its `path:`, so without this file the +# bench would be validated against the teleop node's own contract and every port +# would look wrong. + +apiVersion: 1 +name: bench +namespace: liria +description: Bench stand-in for the arm and the browser, so teleop can run with neither + +runtime: python +entrypoint: bench.py +dora: ">=1.0" + +inputs: + tick: + description: Drives the telemetry the arm node would publish. + type: std/core/v1/UInt64 + jog: + description: A velocity teleop wants the arm to move at. Printed, then forgotten. + request: + description: A service call. Every one is answered ok. + ui_state: + description: >- + The console snapshot teleop publishes. Accepted and dropped: no browser is + attached to this dataflow. + +outputs: + response: + description: One answer per request, under the id it arrived with. + robot_state: + description: Reports `ready` once, so teleop stops drawing the arm as disconnected. + type: std/core/v1/String + tcp_pose: + description: A fixed tool-tip pose. Fixed on purpose — nothing here integrates a velocity. + type: std/core/v1/Float32 + joint_pose: + description: Fixed joint angles, for the same reason. + type: std/core/v1/Float32 + clients: + description: Always zero connected, since the WebSocket bridge is not in this dataflow.