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.
|
first time somebody picks up the pad. Errors name the variable.
|
||||||
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
|
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
|
||||||
dynamic node's stdout.
|
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
|
- **`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
|
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
|
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
|
## 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
|
```yaml
|
||||||
- id: teleop
|
- id: teleop
|
||||||
@@ -172,12 +172,34 @@ See `dataflow.yml` for the whole stack wired together. The short version:
|
|||||||
outputs: [jog, request, ui_state]
|
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
|
```bash
|
||||||
dora run dataflow.yml --uv
|
dora run dataflow.yml --uv
|
||||||
```
|
```
|
||||||
|
|
||||||
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a
|
The bench answers every service, publishes plausible telemetry, and prints each velocity
|
||||||
gamepad if you want one — the node starts without and says so.
|
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
|
## 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
|
# Self-contained on purpose: teleop plus a bench stand-in for the arm and the
|
||||||
# manifest from its `path:` and checks the wiring against the versions in front
|
# browser, both built from this checkout, so `dora validate` resolves every
|
||||||
# of you. The sibling repos are expected next to this one, which is how they are
|
# manifest from `path:` and CI needs no gamepad, no robot and no sibling clones.
|
||||||
# cloned in practice.
|
|
||||||
#
|
#
|
||||||
# Three nodes, one loop: the bridge carries the browser, this node decides who
|
# This is **not** the real stack. The bench answers services and publishes
|
||||||
# drives, and the arm node moves the arm and reports back.
|
# 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
|
strict_types: true
|
||||||
|
|
||||||
@@ -16,13 +17,11 @@ nodes:
|
|||||||
build: pip install .
|
build: pip install .
|
||||||
inputs:
|
inputs:
|
||||||
tick: dora/timer/millis/50
|
tick: dora/timer/millis/50
|
||||||
ui_command: ws-transceiver/data_out
|
clients: bench/clients
|
||||||
clients: ws-transceiver/clients
|
response: bench/response
|
||||||
response: ulite6/response
|
robot_state: bench/robot_state
|
||||||
motion_state: ulite6/motion_state
|
tcp_pose: bench/tcp_pose
|
||||||
robot_state: ulite6/robot_state
|
joint_pose: bench/joint_pose
|
||||||
tcp_pose: ulite6/tcp_pose
|
|
||||||
joint_pose: ulite6/joint_pose
|
|
||||||
outputs:
|
outputs:
|
||||||
- jog
|
- jog
|
||||||
- request
|
- request
|
||||||
@@ -31,35 +30,16 @@ nodes:
|
|||||||
SPEED_MM_S: "60"
|
SPEED_MM_S: "60"
|
||||||
SPEED_DEG_S: "30"
|
SPEED_DEG_S: "30"
|
||||||
|
|
||||||
- id: ws-transceiver
|
- id: bench
|
||||||
path: ../dora-websocket-transceiver
|
path: examples/bench.py
|
||||||
build: pip install .
|
|
||||||
inputs:
|
inputs:
|
||||||
data_in: teleop/ui_state
|
tick: dora/timer/millis/200
|
||||||
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
|
|
||||||
jog: teleop/jog
|
jog: teleop/jog
|
||||||
|
request: teleop/request
|
||||||
|
ui_state: teleop/ui_state
|
||||||
outputs:
|
outputs:
|
||||||
- response
|
- response
|
||||||
- motion_state
|
|
||||||
- joint_pose
|
|
||||||
- tcp_pose
|
|
||||||
- robot_state
|
- robot_state
|
||||||
env:
|
- tcp_pose
|
||||||
ROBOT_IP: "192.168.1.192"
|
- joint_pose
|
||||||
TOOL_TYPE: "vacuum"
|
- clients
|
||||||
# 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"
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ apiVersion: 1
|
|||||||
name: dora-teleop
|
name: dora-teleop
|
||||||
namespace: liria
|
namespace: liria
|
||||||
description: Hands a robot arm to a human, from a gamepad or a browser
|
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]
|
keywords: [teleop, gamepad, joystick, jog, arm, manipulator, web-console, operator]
|
||||||
|
|
||||||
runtime: python
|
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