Hand the arm to a human, from a gamepad or a browser #1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+21
-41
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user