Two problems, both found by the validate job once it could finally run. categories named `teleop` and `input`, neither of which dora defines. The set is fixed — sensor, actuator, robot, transform, filter, ml-inference, llm, speech, communication, recorder, visualization, simulator, debug — and an unknown variant makes the whole manifest unparseable, so every type annotation in it was being ignored. dataflow.yml spanned three repositories through sibling paths, which meant dora could not resolve the other nodes' manifests and reported their typed outputs as unannotated. It also could not be validated in CI, which clones one repo. The house pattern is a self-contained dataflow, and this now follows it: teleop plus a bench stand-in for the arm and the browser, both from this checkout. The real three-node wiring moves to README.md, where a consumer would look for it. The bench answers every service, publishes plausible telemetry, and prints the velocities it is sent, so the node can be driven from a real gamepad with no robot in the room. It is not a simulator and must not become one: it never integrates a velocity or moves a pose.
215 lines
9.9 KiB
Markdown
215 lines
9.9 KiB
Markdown
# dora-teleop
|
|
|
|
A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the arm, or
|
|
open a browser and move it from there — one of the two at a time, decided here.
|
|
|
|
It drives [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6) through that node's
|
|
ordinary `jog` and `request` ports, and talks to the browser through
|
|
[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver).
|
|
It contains no web server and no robot driver: it is the piece in the middle that decides
|
|
what the arm should be doing, and that decision is the only thing it owns.
|
|
|
|
## The gamepad
|
|
|
|
Analog and blended — several axes at once, and how fast depends on how far you push. That
|
|
is the whole reason a pad beats the web console's four-sector dial pad, which can only ask
|
|
for one axis at a time at one fixed speed.
|
|
|
|
| Control | Does | Units |
|
|
|-------------|-------------------------------------------|-------|
|
|
| Left stick | Move along X (up/down) and Y (left/right) | mm/s |
|
|
| Triggers | Lift on the right, lower on the left | mm/s |
|
|
| Right stick | Roll (left/right) and pitch (up/down) | deg/s |
|
|
| Bumpers | Yaw, left and right | deg/s |
|
|
| A | Close the tool | — |
|
|
| B | Open the tool | — |
|
|
| X | Clear a controller error | — |
|
|
| Y | Toggle freedrive | — |
|
|
| Back | **E-STOP** | — |
|
|
|
|
Up on the stick sends the arm away from you, along **+X** in the robot's base frame —
|
|
the same convention the web console's dial uses, so the two agree.
|
|
|
|
Indices are SDL's, which is what most Xbox- and PlayStation-style pads report. A pad that
|
|
disagrees is a change to `Layout` in `dora_teleop/mapping.py` rather than a new
|
|
environment variable: an index nobody can verify from outside is worse than a line of code
|
|
somebody can read.
|
|
|
|
`home` is deliberately **not** on the pad. It is a large unattended motion and belongs
|
|
behind a deliberate click, not a thumb resting on a face button.
|
|
|
|
### Checking your pad
|
|
|
|
```bash
|
|
uv run dora-teleop-probe
|
|
```
|
|
|
|
Prints the raw axes, the buttons it sees, and the velocity this node would send — live,
|
|
with no dataflow and no arm. **It cannot move anything.** There is no dora node and no
|
|
connection to the robot; it only reads.
|
|
|
|
```text
|
|
axes (6 reported)
|
|
0 left stick X +0.600 ----------|-----#----
|
|
1 left stick Y -0.800 --#-------|----------
|
|
2 left trigger -1.000 #---------|----------
|
|
...
|
|
buttons (6 reported)
|
|
5:yaw right
|
|
|
|
velocity this would send to the arm
|
|
vx +38.89 mm/s forward / back <--
|
|
vy +23.53 mm/s left / right <--
|
|
vz +20.39 mm/s up / down <--
|
|
wz +30.00 deg/s yaw <--
|
|
|
|
moving: yes
|
|
```
|
|
|
|
Run this before trusting the table above. The indices are SDL's documented layout, not
|
|
observed ones, and a pad that numbers its axes differently is much better found here than
|
|
by watching an arm move the wrong way. Two things to look for:
|
|
|
|
- **Every axis is listed, including ones the layout does not use** — an axis marked
|
|
`unused` that moves when you push a stick means the layout needs changing.
|
|
- **A bar that does not sit centred with the stick released** is a worn pad announcing
|
|
itself, and a reason to raise `DEADZONE`.
|
|
|
|
It goes through the same `Gamepad` and `velocity()` the node runs, so what it prints is
|
|
what the arm would be sent.
|
|
|
|
## The lease
|
|
|
|
Two sources, one arm. Whoever holds the lease drives; the other is ignored.
|
|
|
|
- The **gamepad takes it implicitly**, the first time a stick moves, if nobody holds it.
|
|
Nobody should have to reach for a browser to use the thing in their hands.
|
|
- It **gives it back** after `GAMEPAD_IDLE_RELEASE_S` untouched, so a pad left on the
|
|
bench does not lock the browser out forever.
|
|
- The **browser takes it explicitly**, with the console's TAKE CONTROL button, and gives
|
|
it back with RELEASE. It can preempt a held arm, because an operator who can see the arm
|
|
is better placed than this code to decide they should have it.
|
|
- **E-STOP is never gated.** From either source, held lease or not. Somebody reaching for
|
|
the stop is the one moment when asking who owns the arm is the wrong question.
|
|
|
|
**The lease binds only what goes through this node.** `dora-ulite6` has no idea it exists.
|
|
Another node publishing `request` will preempt an operator mid-motion and nothing here can
|
|
stop it — if that matters for your dataflow, do not wire one.
|
|
|
|
## The deadman
|
|
|
|
An arm under a velocity command keeps moving until told otherwise, so the question is
|
|
never whether to start but whether anything has happened that means it must stop. Three
|
|
layers, outermost last:
|
|
|
|
1. **Re-send every tick** (50 ms) while a source is driving.
|
|
2. **This node's watchdog** (`JOG_TTL_S`, 0.4 s) stops the arm when the owner goes quiet —
|
|
and stops it *immediately*, without waiting out the timer, when the gamepad is unplugged
|
|
or the last browser disconnects. An unplugged pad is not a centred pad.
|
|
3. **The arm's own expiry** (`VELOCITY_COMMAND_TTL_S` in `dora-ulite6`, 0.5 s), enforced by
|
|
the controller whether or not this process is alive.
|
|
|
|
Keep them in that order. If this node's timer is longer than the controller's, the backstop
|
|
fires before the thing it is backing up.
|
|
|
|
On shutdown the node sends one all-zero velocity before exiting. A motion lives in the
|
|
controller, not in this process.
|
|
|
|
The stop is sent **once**, not every tick. A stream of zeroes would keep `dora-ulite6`
|
|
believing a jog is live and hold `robot_state` at `moving` forever.
|
|
|
|
## Ports
|
|
|
|
| Direction | Name | Description |
|
|
|-----------|----------------|------------------------------------------------------|
|
|
| in | `tick` | 50 ms — re-send, deadman, telemetry |
|
|
| in | `ui_command` | One console message, from the bridge's `data_out` |
|
|
| in | `clients` | Connect/disconnect from the bridge — the web deadman |
|
|
| in | `response` | An answer from the arm |
|
|
| in | `motion_state` | How a motion ended |
|
|
| in | `robot_state` | The arm's own state |
|
|
| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees |
|
|
| in | `joint_pose` | Joint angles, six float32, degrees |
|
|
| out | `jog` | `{velocity: [vx, vy, vz, wx, wy, wz]}`, mm/s + deg/s |
|
|
| out | `request` | A service call, `request_id` in the metadata |
|
|
| out | `ui_state` | The console's telemetry snapshot, as JSON |
|
|
|
|
## Environment Variables
|
|
|
|
| Variable | Default | Description |
|
|
|--------------------------|---------|--------------------------------------------------------|
|
|
| `SPEED_MM_S` | `60.0` | Translation speed at full deflection |
|
|
| `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection |
|
|
| `DEADZONE` | `0.12` | Stick travel near centre to ignore |
|
|
| `EXPO` | `0.4` | How much of the curve is cubic; 0 is linear |
|
|
| `JOYSTICK_INDEX` | `0` | Which pad to open |
|
|
| `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped |
|
|
| `GAMEPAD_IDLE_RELEASE_S` | `2.0` | Untouched pad before it hands the arm back |
|
|
| `STATE_POLL_S` | `0.5` | How often to ask the arm what telemetry does not carry |
|
|
|
|
A value that cannot be read, a `DEADZONE` outside the stick or an `EXPO` off the curve
|
|
stops the node at startup rather than the first time somebody picks up the pad.
|
|
|
|
## Usage
|
|
|
|
The real stack is three nodes: this one, the WebSocket bridge and the arm.
|
|
|
|
```yaml
|
|
- id: teleop
|
|
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
|
|
tag: v0.1.0
|
|
build: pip install .
|
|
path: dora-teleop
|
|
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
|
|
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
|
|
```
|
|
|
|
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
|
|
|
|
```bash
|
|
uv sync --group dev
|
|
uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed
|
|
uv run ruff check .
|
|
uv run ruff format --check .
|
|
```
|
|
|
|
`dora_teleop/gamepad.py` is the only file that touches pygame, and the only one that
|
|
cannot be tested without hardware. Everything else sees a plain `Sample`.
|