Hand the arm to a human, from a gamepad or a browser #1

Merged
cristhian merged 10 commits from feat/gamepad-and-web-teleop into main 2026-09-15 21:27:52 +00:00
6 changed files with 204 additions and 45 deletions
Showing only changes of commit e9fa4bf36b - Show all commits
+5
View File
@@ -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
+25 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+110
View File
@@ -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()
+42
View File
@@ -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.